diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 431e558ff0..fd03ef9666 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -20,11 +20,12 @@ //! &fvk.address_at(0, Scope::External).to_raw_address_bytes(), //! ); //! -//! // Build a shield transition +//! // Build a shield transition; pass the sender's OVK so the wallet can +//! // later recover its own send from chain data (None = unrecoverable) //! let pk = ProvingKey::build(); //! let st = build_shield_transition( //! &recipient, shield_amount, inputs, fee_strategy, -//! &signer, 0, &pk, [0u8; 36], platform_version, +//! &signer, 0, &pk, [0u8; 36], Some(fvk.to_ovk(Scope::External)), platform_version, //! )?; //! ``` @@ -48,7 +49,8 @@ pub use unshield::build_unshield_transition; use grovedb_commitment_tree::{ Anchor, Authorized, Builder, Bundle, BundleType, DashMemo, Flags as OrchardFlags, - FullViewingKey, MerklePath, Note, NoteValue, PaymentAddress, ProvingKey, SpendAuthorizingKey, + FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey, + Scope, SpendAuthorizingKey, }; use rand::rngs::OsRng; @@ -145,10 +147,18 @@ pub fn serialize_authorized_bundle(bundle: &Bundle) - /// /// Used by Shield and ShieldFromAssetLock transitions where funds enter /// the shielded pool from transparent sources. +/// +/// `sender_ovk` encrypts the output's `out_ciphertext` (Zcash +/// outgoing-transaction-history convention): with `Some`, the sender can +/// later recover the note (recipient, value, memo) from chain data via +/// `try_recover_outgoing_note` under that OVK. With `None`, a random +/// outgoing cipher key is used and the sent note is unrecoverable by +/// anyone. Orchard's padding outputs always use `None`. pub(crate) fn build_output_only_bundle( recipient: &OrchardAddress, amount: u64, memo: [u8; 36], + sender_ovk: Option, prover: &P, ) -> Result, ProtocolError> { let payment_address = PaymentAddress::from(recipient); @@ -162,7 +172,12 @@ pub(crate) fn build_output_only_bundle( ); builder - .add_output(None, payment_address, NoteValue::from_raw(amount), memo) + .add_output( + sender_ovk, + payment_address, + NoteValue::from_raw(amount), + memo, + ) .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?; prove_and_sign_bundle(builder, prover, &[], &[]) @@ -170,8 +185,12 @@ pub(crate) fn build_output_only_bundle( /// Builds a spend+output Orchard bundle. /// -/// Used by ShieldedTransfer, Unshield, and ShieldedWithdrawal where funds -/// are spent from existing notes. +/// Used by Unshield, ShieldedWithdrawal, and IdentityCreateFromShieldedPool +/// where funds are spent from existing notes. The single shielded output is +/// the spender's change note; its `out_ciphertext` is encrypted under the +/// spender's own External-scope OVK (derived from `fvk`) so the wallet can +/// recover the note — including its structured memo, which the compact IVK +/// scan path never sees — from chain data alone. #[allow(clippy::too_many_arguments)] pub(crate) fn build_spend_bundle( spends: Vec, @@ -198,7 +217,7 @@ pub(crate) fn build_spend_bundle( builder .add_output( - None, + Some(fvk.to_ovk(Scope::External)), payment_address, NoteValue::from_raw(output_amount), memo, @@ -328,7 +347,7 @@ mod mod_tests { #[test] fn output_only_bundle_flags_and_value_balance() { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], &TestProver) + let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, &TestProver) .expect("bundle should build"); // Spends are disabled for Shield / ShieldFromAssetLock bundles. @@ -350,7 +369,7 @@ mod mod_tests { #[test] fn serialize_authorized_bundle_preserves_fields() { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], &TestProver) + let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, &TestProver) .expect("bundle should build"); let sb = serialize_authorized_bundle(&bundle); @@ -374,6 +393,76 @@ mod mod_tests { } } + // ------------------------------------------------------------------ + // OVK outgoing-history round trip: an output built with the sender's + // OVK must recover (note, recipient, memo) under that same OVK — the + // Zcash convention that lets a wallet reconstruct its send history + // from chain data alone — and must stay opaque to any other OVK. + // ------------------------------------------------------------------ + + #[test] + fn output_built_with_sender_ovk_recovers_under_that_ovk_only() { + use grovedb_commitment_tree::{try_output_recovery_with_ovk, OrchardDomain, Scope}; + + let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes"); + let sender_ovk = FullViewingKey::from(&sk).to_ovk(Scope::External); + + let recipient = test_orchard_address(); + let amount = 31_337u64; + let mut memo = [0u8; 36]; + memo[..9].copy_from_slice(b"ovk-round"); + + let bundle = build_output_only_bundle( + &recipient, + amount, + memo, + Some(sender_ovk.clone()), + &TestProver, + ) + .expect("bundle should build"); + + let recover_all = |ovk: &grovedb_commitment_tree::OutgoingViewingKey| { + bundle + .actions() + .iter() + .filter_map(|action| { + let domain = OrchardDomain::::for_action(action); + try_output_recovery_with_ovk( + &domain, + ovk, + action, + action.cv_net(), + &action.encrypted_note().out_ciphertext, + ) + }) + .collect::>() + }; + + let recovered = recover_all(&sender_ovk); + assert_eq!( + recovered.len(), + 1, + "exactly the real recipient output must recover; padding stays opaque" + ); + let (note, recovered_addr, recovered_memo) = &recovered[0]; + assert_eq!(note.value().inner(), amount, "recovered value mismatch"); + assert_eq!( + recovered_addr.to_raw_address_bytes(), + recipient.inner().to_raw_address_bytes(), + "recovered recipient mismatch" + ); + assert_eq!(*recovered_memo, memo, "recovered memo mismatch"); + + // A different wallet's OVK opens nothing — no false positives in + // anyone else's send history. + let other_sk = SpendingKey::from_bytes([7u8; 32]).expect("valid spending key bytes"); + let other_ovk = FullViewingKey::from(&other_sk).to_ovk(Scope::External); + assert!( + recover_all(&other_ovk).is_empty(), + "a foreign OVK must not recover the output" + ); + } + // ------------------------------------------------------------------ // `From<&OrchardAddress> for PaymentAddress` delegates to `inner()`. // ------------------------------------------------------------------ diff --git a/packages/rs-dpp/src/shielded/builder/shield.rs b/packages/rs-dpp/src/shielded/builder/shield.rs index e1197f1824..e1a329e559 100644 --- a/packages/rs-dpp/src/shielded/builder/shield.rs +++ b/packages/rs-dpp/src/shielded/builder/shield.rs @@ -27,6 +27,11 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver /// - `user_fee_increase` - Fee multiplier (0 = 100% base fee) /// - `prover` - Orchard prover (holds the Halo 2 proving key; cache with `OnceLock` — ~30s to build) /// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload) +/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the +/// recipient output's `out_ciphertext` is encrypted under it so the sender can later +/// recover the sent note (recipient, value, memo) from chain data via OVK recovery — +/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing +/// cipher key is used and the sent note is unrecoverable by anyone. /// - `platform_version` - Protocol version #[allow(clippy::too_many_arguments)] pub async fn build_shield_transition, P: OrchardProver>( @@ -38,6 +43,7 @@ pub async fn build_shield_transition, P: OrchardProve user_fee_increase: UserFeeIncrease, prover: &P, memo: [u8; 36], + sender_ovk: Option, platform_version: &PlatformVersion, ) -> Result { if fee_strategy.is_empty() { @@ -46,7 +52,7 @@ pub async fn build_shield_transition, P: OrchardProve )); } - let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?; + let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?; let sb = serialize_authorized_bundle(&bundle); ShieldTransition::try_from_bundle_with_signer( @@ -115,6 +121,7 @@ mod tests { 0, &TestProver, [0u8; 36], + None, platform_version, ) .await; @@ -148,6 +155,7 @@ mod tests { 0, &TestProver, [0u8; 36], + None, platform_version, ) .await; @@ -186,6 +194,7 @@ mod tests { 0, &TestProver, [0u8; 36], + None, platform_version, ) .await; @@ -217,6 +226,7 @@ mod tests { 42, // non-zero fee increase &TestProver, [9u8; 36], + None, platform_version, ) .await; @@ -252,6 +262,7 @@ mod tests { 0, &TestProver, memo, + None, platform_version, ) .await; diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index 5d54198d3e..c81766eec0 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -20,6 +20,11 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver /// - `asset_lock_private_key` - Private key for the asset lock (signs the transition) /// - `prover` - Orchard prover (holds the Halo 2 proving key) /// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload) +/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the +/// recipient output's `out_ciphertext` is encrypted under it so the sender can later +/// recover the sent note (recipient, value, memo) from chain data via OVK recovery — +/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing +/// cipher key is used and the sent note is unrecoverable by anyone. /// - `surplus_output` - Optional platform address that receives the asset-lock surplus /// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee /// pools, capped at `shielded_implicit_fee_cap` @@ -32,10 +37,11 @@ pub fn build_shield_from_asset_lock_transition( asset_lock_private_key: &[u8], prover: &P, memo: [u8; 36], + sender_ovk: Option, surplus_output: Option, platform_version: &PlatformVersion, ) -> Result { - let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?; + let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?; let sb = serialize_authorized_bundle(&bundle); // For output-only bundles, Orchard value_balance is negative (value flowing in). @@ -77,6 +83,11 @@ pub fn build_shield_from_asset_lock_transition( /// - `asset_lock_signer` - External signer that produces the outer ECDSA signature /// - `prover` - Orchard prover (holds the Halo 2 proving key) /// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload) +/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the +/// recipient output's `out_ciphertext` is encrypted under it so the sender can later +/// recover the sent note (recipient, value, memo) from chain data via OVK recovery — +/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing +/// cipher key is used and the sent note is unrecoverable by anyone. /// - `surplus_output` - Optional platform address that receives the asset-lock surplus /// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee /// pools, capped at `shielded_implicit_fee_cap` @@ -91,6 +102,7 @@ pub async fn build_shield_from_asset_lock_transition_with_signer( asset_lock_signer: &AS, prover: &P, memo: [u8; 36], + sender_ovk: Option, surplus_output: Option, platform_version: &PlatformVersion, ) -> Result @@ -98,7 +110,7 @@ where P: OrchardProver, AS: ::key_wallet::signer::Signer, { - let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?; + let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?; let sb = serialize_authorized_bundle(&bundle); // For output-only bundles, Orchard value_balance is negative (value flowing in). @@ -141,7 +153,7 @@ mod tests { let recipient = test_orchard_address(); let amount = 50_000u64; - let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], &TestProver) + let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver) .expect("bundle should build successfully"); let sb = serialize_authorized_bundle(&bundle); @@ -169,7 +181,7 @@ mod tests { #[test] fn test_output_only_bundle_serializes_to_min_actions() { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], &TestProver) + let bundle = build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, &TestProver) .expect("bundle should build"); let sb = serialize_authorized_bundle(&bundle); assert_eq!( @@ -211,7 +223,7 @@ mod tests { // negative value_balance equal in magnitude to the requested amount. for amount in [1u64, 100, 1_000_000, u32::MAX as u64] { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], &TestProver) + let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver) .expect("bundle should build"); let sb = serialize_authorized_bundle(&bundle); assert_eq!( diff --git a/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs b/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs index 3c2429970f..3c870f37c3 100644 --- a/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs +++ b/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs @@ -1,5 +1,5 @@ use grovedb_commitment_tree::{ - Anchor, Builder, BundleType, DashMemo, FullViewingKey, NoteValue, PaymentAddress, + Anchor, Builder, BundleType, DashMemo, FullViewingKey, NoteValue, PaymentAddress, Scope, SpendAuthorizingKey, }; @@ -20,6 +20,10 @@ use super::{prove_and_sign_bundle, serialize_authorized_bundle, OrchardProver, S /// fee is deducted from the spent notes. Any remaining change is returned to /// the `change_address`. /// +/// Both real outputs are encrypted with the sender's External-scope OVK +/// (derived from `fvk`), so the sender can recover its own send history +/// (recipient, value, memo) from chain data via OVK recovery. +/// /// # Parameters /// - `spends` - Notes to spend with their Merkle paths /// - `recipient` - Orchard address to receive the transferred note @@ -82,10 +86,17 @@ pub fn build_shielded_transfer_transition( })?; } + // Both real outputs carry an `out_ciphertext` encrypted under the sender's + // External-scope OVK (the Zcash outgoing-transaction-history convention), + // so the sender can recover its own send history — recipient, value, memo — + // from chain data alone. Without it, the outgoing cipher key is random and + // the sent note is unrecoverable by anyone, including the sender. + let sender_ovk = fvk.to_ovk(Scope::External); + // Primary output to recipient builder .add_output( - None, + Some(sender_ovk.clone()), recipient_payment, NoteValue::from_raw(transfer_amount), memo, @@ -97,7 +108,7 @@ pub fn build_shielded_transfer_transition( let change_payment = PaymentAddress::from(change_address); builder .add_output( - None, + Some(sender_ovk), change_payment, NoteValue::from_raw(change_amount), [0u8; 36], diff --git a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs index 6064101cec..aab8ddac1f 100644 --- a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs +++ b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs @@ -17,7 +17,9 @@ use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, Spen /// /// Spends existing notes and withdraws value to a core chain script output. /// The shielded fee is deducted from the spent notes. Any remaining value is -/// returned to the shielded `change_address`. +/// returned to the shielded `change_address`; the change note is encrypted +/// with the sender's External-scope OVK (derived from `fvk`) so the wallet +/// can recover it — including the structured memo — via OVK recovery. /// /// # Parameters /// - `spends` - Notes to spend with their Merkle paths diff --git a/packages/rs-dpp/src/shielded/builder/unshield.rs b/packages/rs-dpp/src/shielded/builder/unshield.rs index e40f0aa262..b024117782 100644 --- a/packages/rs-dpp/src/shielded/builder/unshield.rs +++ b/packages/rs-dpp/src/shielded/builder/unshield.rs @@ -15,7 +15,9 @@ use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, Spen /// /// Spends existing notes and sends part of the value to a transparent platform /// address. The shielded fee is deducted from the spent notes. Any remaining -/// value is returned to the shielded `change_address`. +/// value is returned to the shielded `change_address`; the change note is +/// encrypted with the sender's External-scope OVK (derived from `fvk`) so the +/// wallet can recover it — including the structured memo — via OVK recovery. /// /// # Parameters /// - `spends` - Notes to spend with their Merkle paths diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 0e3f299474..23fadaf3c4 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -237,7 +237,8 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() { &signer, &TestProver, [0u8; 36], - None, + None, // sender_ovk + None, // surplus_output PlatformVersion::latest(), ) .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 515a4d3a8c..5a41f80622 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -284,6 +284,26 @@ impl PlatformWallet { } let (recipient, _) = *recipients.first().expect("preflight enforces len() == 1"); + // Encrypt the output under this wallet's OVK so the shielded sync can + // recover the funding (recipient, value, memo) from chain data alone. + // Prefer the bound account whose IVK recognizes the recipient address + // (the sent-note row then lands under that account); fall back to the + // lowest bound account, or `None` (unrecoverable out_ciphertext) if + // the shielded sub-wallet isn't bound. + let sender_ovk = { + let guard = self.shielded_keys.read().await; + guard.as_ref().and_then(|keys| { + keys.values() + .find(|ks| { + ks.incoming_viewing_key + .diversifier_index(recipient.inner()) + .is_some() + }) + .or_else(|| keys.values().next()) + .map(|ks| ks.outgoing_viewing_key.clone()) + }) + }; + // Step 4: submit. Two Platform-side fallback layers — matching // the address-funding sibling: CL-height-too-low retries bump // `user_fee_increase` (bypasses Tenderdash's invalid-tx hash @@ -314,6 +334,7 @@ impl PlatformWallet { path.clone(), asset_lock_signer, &prover, + sender_ovk.clone(), surplus_output, s, ) @@ -350,6 +371,7 @@ impl PlatformWallet { path.clone(), asset_lock_signer, &prover, + sender_ovk.clone(), surplus_output, s, ) @@ -510,6 +532,7 @@ async fn build_and_broadcast_shielded( path: ::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, prover: &P, + sender_ovk: Option, surplus_output: Option, settings: Option, ) -> Result<(), dash_sdk::Error> @@ -525,6 +548,7 @@ where asset_lock_signer, prover, [0u8; 36], + sender_ovk, surplus_output, sdk.version(), ) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 89d3f11728..28a10a4d21 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -287,6 +287,10 @@ pub async fn shield, P: OrchardProver>( 0, // user_fee_increase prover, [0u8; 36], // empty memo + // Encrypt the output under the account's own OVK so the wallet's + // shielded sync can recover this send (recipient, value, memo) + // from chain data alone. + Some(keys.outgoing_viewing_key.clone()), sdk.version(), ) .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index cae5d8f31b..b18136a85b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1005,6 +1005,13 @@ mod ovk_recovery_tests; #[cfg(test)] mod shield_decrypt_tests; +/// Sender-side mirror of `shield_decrypt_tests`: the shield builder's +/// serialized actions must OVK-recover (recipient, value, memo) under +/// the same keyset's outgoing viewing key and persist as an outgoing +/// note — the wallet's own send history reconstructed from chain data. +#[cfg(test)] +mod ovk_builder_roundtrip_tests; + /// Round-trip guard for the shielded note memo: a `ShieldedMemo` attached /// to an output survives encryption and comes back out of both the IVK /// full-decryption and the OVK send-history recovery primitives. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs index 1c7bc9954e..6cdb39536f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -197,6 +197,7 @@ async fn shield_memo_round_trips_through_ivk_decryption() { 0, &&prover, memo_bytes, + Some(keys.outgoing_viewing_key.clone()), PlatformVersion::latest(), ) .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs new file mode 100644 index 0000000000..35361eb81d --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs @@ -0,0 +1,196 @@ +//! Round-trip: a Type 15 Shield bundle built by the wallet's own +//! builder must OVK-recover under the same wallet's outgoing viewing +//! key — the sender-side mirror of `shield_decrypt_tests`. +//! +//! This is the exact client-side pair the app exercises end-to-end: +//! +//! * build side — `operations::shield` passes the account's +//! `OrchardKeySet::outgoing_viewing_key` into dpp's +//! `build_shield_transition`, which keys the recipient output's +//! `out_ciphertext` to it (the Zcash outgoing-transaction-history +//! convention); the serialized actions are stored verbatim on-chain; +//! * scan side — `sync_notes_across` re-parses each stored item into +//! `ShieldedEncryptedNote` and runs `try_recover_outgoing_note` +//! under every subwallet's OVK, persisting hits via +//! `record_outgoing_note`. +//! +//! Before the OVK was threaded into the builders, every output was +//! encrypted under a per-output RANDOM outgoing cipher key +//! (`add_output(None, ...)`), so this recovery returned `None` forever +//! and the outgoing-notes store could never populate. This test pins +//! the fixed behavior: exactly one action (the real recipient output) +//! recovers — recipient, value, AND memo — and persists as a +//! `ShieldedOutgoingNote` row. + +use std::collections::BTreeMap; + +use dash_sdk::platform::shielded::try_recover_outgoing_note; +use dashcore::Network; +use dpp::address_funds::{ + AddressFundsFeeStrategyStep, AddressWitness, OrchardAddress, PlatformAddress, +}; +use dpp::identity::signer::Signer; +use dpp::platform_value::BinaryData; +use dpp::shielded::builder::build_shield_transition; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive_proof_verifier::types::ShieldedEncryptedNote; +use grovedb_commitment_tree::ExtractedNoteCommitment; + +use crate::wallet::shielded::keys::OrchardKeySet; +use crate::wallet::shielded::prover::CachedOrchardProver; +use crate::wallet::shielded::store::{InMemoryShieldedStore, ShieldedStore, SubwalletId}; + +/// Fake transparent-side signer: the input witnesses are irrelevant +/// to note encryption (they sign the transparent inputs, not the +/// Orchard bundle), so a dummy 65-byte signature suffices. +#[derive(Debug)] +struct DummySigner; + +#[async_trait::async_trait] +impl Signer for DummySigner { + async fn sign( + &self, + _key: &PlatformAddress, + _data: &[u8], + ) -> Result { + Ok(BinaryData::new(vec![0u8; 65])) + } + + async fn sign_create_witness( + &self, + _key: &PlatformAddress, + _data: &[u8], + ) -> Result { + Ok(AddressWitness::P2pkh { + signature: BinaryData::new(vec![0u8; 65]), + }) + } + + fn can_sign_with(&self, _key: &PlatformAddress) -> bool { + true + } +} + +#[tokio::test] +async fn shield_built_note_ovk_recovers_and_persists_as_outgoing() { + let seed = [0x42u8; 32]; + let keys = OrchardKeySet::from_seed(&seed, Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + + let recipient = OrchardAddress::from_raw_bytes(&keys.default_address.to_raw_address_bytes()) + .expect("default address must convert to OrchardAddress"); + + let amount: u64 = 200_000_000_000; // 2 DASH in credits + let mut memo = [0u8; 36]; + memo[..12].copy_from_slice(b"sent-history"); + let mut inputs = BTreeMap::new(); + inputs.insert( + PlatformAddress::P2pkh([0xAB; 20]), + (0u32, 500_000_000_000u64), + ); + + let prover = CachedOrchardProver::new(); + let st = build_shield_transition( + &recipient, + amount, + inputs, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &DummySigner, + 0, + &&prover, + memo, + Some(keys.outgoing_viewing_key.clone()), + PlatformVersion::latest(), + ) + .await + .expect("shield transition build should succeed"); + + let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + panic!("expected a Shield state transition"); + }; + + // Reassemble each action exactly as the chain stores and the sync + // stream re-parses it, then run the scanner's exact OVK-recovery call. + let recovered: Vec<_> = v0 + .actions + .iter() + .filter_map(|a| { + let wire = ShieldedEncryptedNote { + cmx: a.cmx.to_vec(), + nullifier: a.nullifier.to_vec(), + cv_net: a.cv_net.to_vec(), + encrypted_note: a.encrypted_note.clone(), + }; + try_recover_outgoing_note(&keys.outgoing_viewing_key, &wire) + }) + .collect(); + + assert_eq!( + recovered.len(), + 1, + "exactly one action (the real recipient output) must OVK-recover; \ + 0 means the wallet can never reconstruct its send history, \ + 2 means Orchard's dummy padding leaked" + ); + let (note, recovered_recipient, recovered_memo) = &recovered[0]; + assert_eq!( + note.value().inner(), + amount, + "recovered note value must equal the shielded amount" + ); + assert_eq!( + recovered_recipient.to_raw_address_bytes(), + keys.default_address.to_raw_address_bytes(), + "recovered recipient must be the address the builder paid" + ); + assert_eq!( + &recovered_memo[..], + &memo[..], + "recovered memo must round-trip" + ); + + // Persist through the same store path `sync_notes_across` drives — + // the row the host surfaces as an outgoing payment. + let mut store = InMemoryShieldedStore::new(); + let id = SubwalletId::new([0x01; 32], 0); + let outgoing = crate::wallet::shielded::store::ShieldedOutgoingNote { + cmx: ExtractedNoteCommitment::from(note.commitment()).to_bytes(), + recipient: recovered_recipient.to_raw_address_bytes().to_vec(), + value: note.value().inner(), + memo: recovered_memo.to_vec(), + block_height: 42, + }; + assert!( + store.record_outgoing_note(id, &outgoing).unwrap(), + "first record of the recovered send must be newly stored" + ); + let stored = store.get_outgoing_notes(id).unwrap(); + assert_eq!(stored.len(), 1, "one outgoing row must persist"); + assert_eq!(stored[0].value, amount); + assert_eq!(stored[0].memo.as_slice(), &memo[..]); + + // A foreign wallet's OVK opens nothing — the send stays out of + // everyone else's history. + let other = OrchardKeySet::from_seed(&[0x99u8; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + let foreign_hits = v0 + .actions + .iter() + .filter_map(|a| { + let wire = ShieldedEncryptedNote { + cmx: a.cmx.to_vec(), + nullifier: a.nullifier.to_vec(), + cv_net: a.cv_net.to_vec(), + encrypted_note: a.encrypted_note.clone(), + }; + try_recover_outgoing_note(&other.outgoing_viewing_key, &wire) + }) + .count(); + assert_eq!( + foreign_hits, 0, + "a foreign OVK must not recover the wallet's send" + ); +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs index 64279767ac..5072278b72 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs @@ -101,6 +101,10 @@ async fn shield_built_note_is_trial_decryptable_by_own_ivk() { 0, &&prover, [0u8; 36], + // Production config (`operations::shield`): the output's + // out_ciphertext is keyed to the wallet's own OVK. Irrelevant to + // the IVK trial-decryption under test, but kept in lockstep. + Some(keys.outgoing_viewing_key.clone()), PlatformVersion::latest(), ) .await