diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 0926f6eff14..ebea0cca712 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -613,6 +613,80 @@ fn map_spend_result( } } +/// Render a caught panic payload as a human-readable string. +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the +/// operation's contract-appropriate result `code` (with `guidance` appended to the message) +/// instead of letting it reach the `extern "C"` frame. +/// +/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer +/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but +/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the +/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it +/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, +/// note bookkeeping, the SDK) re-panics right here inside the export. +/// +/// The `code` must be chosen per operation to preserve that operation's result contract — a +/// panic can strike after side effects (note reservation, a broadcast) have happened, so the +/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See +/// [`catch_spend_panic`] and the per-export call sites. +/// +/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and +/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles +/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size +/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the +/// process before this guard can see it. +fn catch_panic_to_code( + operation: &str, + code: PlatformWalletFFIResultCode, + guidance: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { + Ok(result) => result, + Err(payload) => PlatformWalletFFIResult::err( + code, + format!( + "{operation} panicked: {}. {guidance}", + panic_payload_message(payload.as_ref()) + ), + ), + } +} + +/// Post-panic guidance for the note-spending exports. Paired with +/// `ErrorShieldedSpendUnconfirmed` in [`catch_spend_panic`]. +const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast — do NOT \ + retry; the next shielded sync reconciles the outcome."; + +/// [`catch_panic_to_code`] specialized for the note-spending exports. +/// +/// The panic is mapped to [`PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed`], NOT to +/// a definitive failure code: a panic can strike after the notes were reserved and even after the +/// transition was broadcast, so the outcome is genuinely ambiguous. That code's contract is +/// exactly the conservative one this needs — the host must not auto-retry, the reservation stays +/// in place, and the next nullifier sync (or an app restart) reconciles whether the spend landed. +fn catch_spend_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed, + SPEND_PANIC_GUIDANCE, + body, + ) +} + /// Preserve the typed "already consumed" funding report across the FFI /// boundary while keeping every other funding failure on the existing generic /// error path. The wallet retains nonterminal consumption-unknown state; the @@ -978,6 +1052,158 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( map_spend_result(result, "shielded shield") } +/// Shield: spend credits from a Platform Payment account into a +/// THIRD-PARTY shielded pool — the Type 15 shield with the note +/// assigned to `recipient_raw_43` (the recipient's raw 43-byte +/// Orchard payment address, same shape +/// `platform_wallet_manager_shielded_transfer` takes) instead of the +/// wallet's own default address. +/// +/// Input selection, fees, and error shapes are identical to +/// [`platform_wallet_manager_shielded_shield`]; the wallet still needs +/// a bound shielded sub-wallet at `shielded_account` because the send +/// is OVK-encrypted to (and its activity recorded under) that account. +/// +/// The recipient must actually be a third party: an address the +/// account's own IVK recognizes (default or any diversified index) is +/// rejected with a wallet-operation error — self-shields go through +/// [`platform_wallet_manager_shielded_shield`]. +/// +/// `memo_text` is an optional NUL-terminated UTF-8 string attached to +/// the recipient's note — same rules as +/// `platform_wallet_manager_shielded_transfer`: `null` or empty means +/// no memo; a non-empty memo's UTF-8 byte length must be ≤ 32. +/// +/// `signer_address_handle` is a `*mut SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` (typically Swift's +/// `KeychainSigner.handle`). The caller retains ownership; this +/// function does not destroy the handle. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `recipient_raw_43` must point to 43 readable bytes. +/// - `memo_text`, when non-null, must be a valid NUL-terminated UTF-8 +/// C string for the duration of the call. +/// - `signer_address_handle` must be a valid, non-destroyed +/// `*const SignerHandle` that outlives this call and points at a +/// `VTableSigner` with the callback variant (the native variant +/// doesn't satisfy `Signer`). +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_to_recipient( + handle: Handle, + wallet_id_bytes: *const u8, + shielded_account: u32, + payment_account: u32, + recipient_raw_43: *const u8, + amount: u64, + memo_text: *const c_char, + signer_address_handle: *const SignerHandle, +) -> PlatformWalletFFIResult { + // The whole body runs under `catch_unwind`: a panic (most concretely `block_on_worker`'s + // `.expect` on a panicking proving task) must NOT reach this `extern "C"` frame, where it + // would abort the process instead of surfacing to the host as a typed error. A shield + // reserves no notes, but the transition may already have been broadcast when the panic + // struck, so the same ambiguous spend-unconfirmed contract applies (matching + // `map_spend_result`'s mapping for this operation); a later manual retry self-heals through + // the address-nonce check. + catch_spend_panic("shielded shield to recipient", || { + shielded_shield_to_recipient_inner( + handle, + wallet_id_bytes, + shielded_account, + payment_account, + recipient_raw_43, + amount, + memo_text, + signer_address_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_shielded_shield_to_recipient`], as an ordinary Rust +/// function so a panic unwinds into [`catch_spend_panic`] instead of across the C ABI. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[allow(clippy::too_many_arguments)] +unsafe fn shielded_shield_to_recipient_inner( + handle: Handle, + wallet_id_bytes: *const u8, + shielded_account: u32, + payment_account: u32, + recipient_raw_43: *const u8, + amount: u64, + memo_text: *const c_char, + signer_address_handle: *const SignerHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(recipient_raw_43); + check_ptr!(signer_address_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let mut recipient = [0u8; 43]; + std::ptr::copy_nonoverlapping(recipient_raw_43, recipient.as_mut_ptr(), 43); + + // Decode the optional memo string before resolving the wallet so a + // malformed memo fails fast without touching wallet state. + let memo_str = if memo_text.is_null() { + None + } else { + match CStr::from_ptr(memo_text).to_str() { + Ok(s) => Some(s), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("memo_text is not valid UTF-8: {e}"), + ); + } + } + }; + let memo = match encode_memo_text(memo_str) { + Ok(m) => m, + Err(result) => return result, + }; + + // Shield writes its live activity entry to the coordinator's shared + // in-memory store, so resolve the coordinator alongside the wallet + // (same resolver the transfer / unshield / withdraw spends use). + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + // Signer pointer round-trip through `usize` — same rationale as + // `platform_wallet_manager_shielded_shield`. + let signer_addr = signer_address_handle as usize; + + // Run the proof on a worker thread (8 MB stack). Halo 2 circuit + // synthesis recurses past the ~512 KB iOS dispatch-thread stack + // and crashes with EXC_BAD_ACCESS at the first + // `synthesize(... measure(pass))` call when polled on the + // calling thread. + let result = block_on_worker(async move { + // SAFETY: re-materialize the borrow under the caller's + // documented lifetime contract; valid for the duration of + // this synchronously-awaited task. + let address_signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + wallet + .shielded_shield_from_account_to_recipient( + &coordinator, + shielded_account, + payment_account, + &recipient, + amount, + memo, + address_signer, + &prover, + ) + .await + }); + map_spend_result(result, "shielded shield to recipient") +} + /// Fund the shielded pool from a Core L1 asset lock, orchestrated /// through the wallet's `AssetLockManager` (build → IS-or-CL → /// submit → consume). The asset-lock-proof signature is produced @@ -1718,6 +1944,56 @@ mod tests { .into_owned() } + /// A non-panicking body passes its result straight through — the guard must be invisible on + /// the happy path. + #[test] + fn catch_spend_panic_passes_results_through() { + let ok = catch_spend_panic("test", PlatformWalletFFIResult::ok); + assert_eq!(ok.code, PlatformWalletFFIResultCode::Success); + + let err = catch_spend_panic("test", || { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "bad input", + ) + }); + assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + assert_eq!(message_of(&err), "bad input"); + } + + /// A panic inside a shielded-spend export must NOT unwind into the `extern "C"` frame (that + /// aborts the process). It becomes `ErrorShieldedSpendUnconfirmed` — the conservative + /// "may have been broadcast, do NOT retry" contract, because a panic can strike after the + /// transition is submitted. + /// + /// The panic hook is deliberately left alone: it is process-global, `cargo test` runs tests + /// concurrently, and `take_hook` + restore from two tests can interleave so that one restores + /// the other's temporary hook last — suppressing panic diagnostics for the rest of the + /// process, including unrelated concurrent panics. The libtest harness already captures this + /// test's output, so the deliberate panic's backtrace does not reach the console anyway. + #[test] + fn catch_spend_panic_maps_a_panic_to_the_unconfirmed_contract() { + let result = catch_spend_panic("shielded shield to recipient", || { + panic!("tokio worker panicked"); + }); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed, + "a panic must map to the ambiguous, do-not-retry code" + ); + let message = message_of(&result); + assert!( + message.contains("shielded shield to recipient panicked") + && message.contains("tokio worker panicked"), + "the panic payload must survive into the FFI message: {message}" + ); + assert!( + message.contains("do NOT retry"), + "the message must carry the do-not-retry guidance: {message}" + ); + } + /// `map_spend_result` pins the retry-relevant code split the three spend /// entry points depend on: /// - `ShieldedSpendUnconfirmed` → `ErrorShieldedSpendUnconfirmed` (host diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b6d29e67c40..aa97d44a223 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1653,6 +1653,97 @@ impl PlatformWallet { signer: &S, prover: P, ) -> Result<(), PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + self.shielded_shield_from_account_impl( + coordinator, + shielded_account, + payment_account, + None, + amount, + [0u8; 36], // empty memo + signer, + prover, + ) + .await + } + + /// Shield credits from a Platform Payment account into a THIRD-PARTY + /// shielded pool: the resulting note is assigned to + /// `recipient_raw_43` (a raw 43-byte Orchard payment address — the + /// same shape [`shielded_transfer_to`](Self::shielded_transfer_to) + /// takes) instead of the wallet's own default address. Input + /// selection, fees, and error shapes are identical to + /// [`shielded_shield_from_account`](Self::shielded_shield_from_account); + /// the wallet still needs a bound shielded sub-wallet at + /// `shielded_account` because the send is OVK-encrypted to (and its + /// activity recorded under) that account — which is how the scan + /// later recovers it as outgoing history. + /// + /// The recipient must actually be a third party: an address this + /// account's own IVK recognizes (default or any diversified index) + /// is rejected, because its note would be spendable here and the + /// live `Sent`/`Out` row would diverge from the self-pay row a + /// restore's scan derives. Self-shields go through + /// [`shielded_shield_from_account`](Self::shielded_shield_from_account). + /// + /// `memo` is the 36-byte on-chain `DashMemo` encoding attached to + /// the recipient's note (all-zero = no memo). + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn shielded_shield_from_account_to_recipient( + &self, + coordinator: &Arc, + shielded_account: u32, + payment_account: u32, + recipient_raw_43: &[u8; 43], + amount: u64, + memo: [u8; 36], + signer: &S, + prover: P, + ) -> Result<(), PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + let recipient = Option::::from( + grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43), + ) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "invalid Orchard payment address bytes".to_string(), + ) + })?; + self.shielded_shield_from_account_impl( + coordinator, + shielded_account, + payment_account, + Some(recipient), + amount, + memo, + signer, + prover, + ) + .await + } + + /// Shared body of the two shield entry points above; `recipient` + /// `None` = the wallet's own default Orchard address. + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + async fn shielded_shield_from_account_impl( + &self, + coordinator: &Arc, + shielded_account: u32, + payment_account: u32, + recipient: Option, + amount: u64, + memo: [u8; 36], + signer: &S, + prover: P, + ) -> Result<(), PlatformWalletError> where S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, @@ -1701,15 +1792,17 @@ impl PlatformWallet { })? .clone() }; - super::shielded::operations::shield( + super::shielded::operations::shield_to( &self.sdk, coordinator.store(), Some(&self.persister), self.wallet_id, &keyset, shielded_account, + recipient.as_ref(), inputs, amount, + memo, signer, &prover, ) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 2b75fbc4609..1e9402962d1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -446,9 +446,75 @@ fn reserve_shield_fee_on_input_0( Ok(inputs) } +/// The resolved Orchard output and live-activity classification for a +/// shield — see [`resolve_shield_recipient`]. +#[derive(Debug)] +struct ShieldRecipient { + /// The address the note is built for. + address: OrchardAddress, + /// Raw 43-byte recipient for the activity row (`Some` only for a + /// third-party recipient). + counterparty: Option>, + kind: ShieldedActivityKind, + direction: ShieldedDirection, +} + +/// Resolve a shield's Orchard output address and live-activity +/// classification from the optional third-party `recipient`. +/// +/// `None` is the internal shield-to-self: the note goes to the +/// account's default address and the live entry is `Shield`/`In` with +/// no counterparty. `Some` pays a THIRD-PARTY address: `Sent`/`Out` +/// with the raw 43-byte address as counterparty — the exact +/// classification the scan deriver produces for an OVK-recovered send +/// to a non-own address, so a restore derives the same row. +/// +/// A `Some` address the account's own IVK recognizes (default or any +/// diversified index — the same `diversifier_index` test the scan's +/// `is_own_orchard_recipient` uses) is rejected instead of classified: +/// its note WOULD be spendable here, so it is not a send, and +/// recording it live as `Sent`/`Out` while a restore scan-derives a +/// self-pay row would fork the two histories. Self-shields take the +/// `None` path. +fn resolve_shield_recipient( + keys: &AccountViewingKeys, + recipient: Option<&PaymentAddress>, +) -> Result { + match recipient { + Some(payment_address) => { + if keys + .incoming_viewing_key + .diversifier_index(payment_address) + .is_some() + { + return Err(PlatformWalletError::ShieldedBuildError( + "recipient belongs to this shielded account; use the self-shield \ + entry point (no recipient) instead" + .to_string(), + )); + } + Ok(ShieldRecipient { + address: payment_address_to_orchard(payment_address)?, + counterparty: Some(payment_address.to_raw_address_bytes().to_vec()), + kind: ShieldedActivityKind::Sent, + direction: ShieldedDirection::Out, + }) + } + None => Ok(ShieldRecipient { + address: default_orchard_address(keys)?, + counterparty: None, + kind: ShieldedActivityKind::Shield, + direction: ShieldedDirection::In, + }), + } +} + /// Shield credits from transparent platform addresses into the /// shielded pool, with the resulting note assigned to `account`'s /// default Orchard payment address derived from `keys`. +/// +/// Self-shield front for [`shield_to`], preserving the pre-recipient +/// signature for existing callers. #[allow(clippy::too_many_arguments)] pub async fn shield, P: OrchardProver>( sdk: &Arc, @@ -462,7 +528,45 @@ pub async fn shield, P: OrchardPr signer: &Sig, prover: &P, ) -> Result<(), PlatformWalletError> { - let recipient_addr = default_orchard_address(keys)?; + shield_to( + sdk, store, persister, wallet_id, keys, account, None, inputs, amount, + [0u8; 36], // empty memo + signer, prover, + ) + .await +} + +/// Shield credits from transparent platform addresses into the +/// shielded pool. `recipient` selects the note's Orchard payment +/// address: `None` assigns it to `account`'s default address derived +/// from `keys` (the internal shield-to-self); `Some` pays a +/// third-party address — the note funds THAT wallet's pool and never +/// becomes spendable here (a `Some` address this account's own IVK +/// recognizes is rejected — see [`resolve_shield_recipient`]). Either +/// way the output is encrypted under our own OVK, so the scan recovers +/// the send from chain data and the live and scan-derived activity ids +/// line up. +#[allow(clippy::too_many_arguments)] +pub async fn shield_to, P: OrchardProver>( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &AccountViewingKeys, + account: u32, + recipient: Option<&PaymentAddress>, + inputs: BTreeMap, + amount: u64, + memo: [u8; 36], + signer: &Sig, + prover: &P, +) -> Result<(), PlatformWalletError> { + let ShieldRecipient { + address: recipient_addr, + counterparty: external_counterparty, + kind, + direction, + } = resolve_shield_recipient(keys, recipient)?; let id = SubwalletId::new(wallet_id, account); // Reserve the flat shielded fee `F` on top of `amount` in the input @@ -515,7 +619,12 @@ pub async fn shield, P: OrchardPr let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - info!(account, credits = amount, "Shield: building proof"); + info!( + account, + credits = amount, + external = external_counterparty.is_some(), + "Shield: building proof" + ); let claimed_inputs = inputs_with_nonce.clone(); @@ -527,7 +636,7 @@ pub async fn shield, P: OrchardPr signer, 0, // user_fee_increase prover, - [0u8; 36], // empty memo + 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. @@ -540,11 +649,12 @@ pub async fn shield, P: OrchardPr trace!("Shield credits: state transition built, broadcasting..."); let network = sdk.network; - // Live activity: Shield is `direction in`, amount = the note value - // entering the pool, fee = the flat shielded fee reserved above. The - // visible output cmx is the recipient note (own address, OVK-keyed), - // which the scan later sees as an outgoing note recovered to self — - // the ids line up. + // Live activity. Kind / direction / counterparty were resolved + // alongside the recipient above (see `resolve_shield_recipient` for + // why the rows match what a restore's scan derives). Fee = the flat + // shielded fee reserved above. The visible output cmx is the + // recipient note (OVK-keyed either way), so the live and scan ids + // line up. let pending_entry = record_pending_activity( store, persister, @@ -552,12 +662,12 @@ pub async fn shield, P: OrchardPr id, keys, LiveEntryParams { - kind: ShieldedActivityKind::Shield, - direction: ShieldedDirection::In, + kind, + direction, amount, fee: Some(fee), - counterparty: None, - memo: None, + counterparty: external_counterparty, + memo: non_zero_memo(&memo), actions: shielded_actions(&state_transition), spent_notes: &[], }, @@ -2470,6 +2580,99 @@ fn deserialize_note(data: &[u8]) -> Option { Note::from_parts(recipient, value, rho, rseed).into_option() } +#[cfg(test)] +mod shield_recipient_tests { + use super::*; + use crate::wallet::shielded::keys::OrchardKeySet; + use dashcore::Network; + + fn keyset(seed_byte: u8) -> OrchardKeySet { + OrchardKeySet::from_seed(&[seed_byte; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed") + } + + /// `None` = the self-shield: the default address, `Shield`/`In`, + /// no counterparty — exactly what the pre-recipient path produced. + #[test] + fn no_recipient_resolves_to_the_default_address_as_shield_in() { + let keys = keyset(0x42).viewing_keys(); + + let resolved = + resolve_shield_recipient(&keys, None).expect("self-shield must always resolve"); + + assert_eq!( + resolved.address.to_raw_bytes(), + keys.default_address.to_raw_address_bytes(), + "the self-shield note must go to the account's default address" + ); + assert_eq!(resolved.counterparty, None); + assert_eq!(resolved.kind, ShieldedActivityKind::Shield); + assert_eq!(resolved.direction, ShieldedDirection::In); + } + + /// A third-party address resolves to `Sent`/`Out` with the raw + /// 43-byte address as counterparty — the classification the scan + /// deriver produces for an OVK-recovered send to a non-own address, + /// so live and restored rows agree. + #[test] + fn external_recipient_resolves_as_sent_out_with_raw_counterparty() { + let keys = keyset(0x42).viewing_keys(); + let external = keyset(0x24).viewing_keys().default_address; + + let resolved = resolve_shield_recipient(&keys, Some(&external)) + .expect("a third-party recipient must resolve"); + + assert_eq!( + resolved.address.to_raw_bytes(), + external.to_raw_address_bytes(), + "the note must be built for the recipient's address" + ); + assert_eq!( + resolved.counterparty, + Some(external.to_raw_address_bytes().to_vec()), + "the activity row must carry the recipient as raw 43 bytes" + ); + assert_eq!(resolved.kind, ShieldedActivityKind::Sent); + assert_eq!(resolved.direction, ShieldedDirection::Out); + } + + /// The account's own default address is not a third party: a + /// `Sent`/`Out` live row for it would diverge from the self-pay row + /// a restore's scan derives, so it must be rejected up front. + #[test] + fn own_default_address_as_recipient_is_rejected() { + let keys = keyset(0x42).viewing_keys(); + let own = keys.default_address; + + let error = resolve_shield_recipient(&keys, Some(&own)) + .expect_err("the account's own address must be rejected"); + assert!( + error + .to_string() + .contains("belongs to this shielded account"), + "unexpected error: {error}" + ); + } + + /// Orchard addresses are diversified, so ownership cannot be a + /// fixed-address comparison: a non-default diversified index of the + /// SAME account must also be recognized (via the IVK) and rejected. + #[test] + fn own_diversified_address_as_recipient_is_rejected() { + let ks = keyset(0x42); + let diversified = ks.address_at(7); + let keys = ks.viewing_keys(); + assert_ne!( + diversified.to_raw_address_bytes(), + keys.default_address.to_raw_address_bytes(), + "test needs a non-default diversified address" + ); + + resolve_shield_recipient(&keys, Some(&diversified)) + .expect_err("an own diversified address must be rejected"); + } +} + #[cfg(test)] mod redrive_tests { use super::*; 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 5072278b72e..fa9dafc2297 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 @@ -150,3 +150,111 @@ async fn shield_built_note_is_trial_decryptable_by_own_ivk() { "decrypted note value must equal the shielded amount" ); } + +/// Shield to an EXTERNAL recipient (`shielded_shield_from_account_to_recipient`): +/// the note must be spendable by the recipient's wallet, invisible to the +/// sender's IVK, and recoverable as outgoing history under the sender's OVK +/// (with the recipient address and memo intact) — the exact triple the +/// scan relies on for the recipient's balance and the sender's `Sent` row. +#[tokio::test] +async fn shield_to_external_recipient_decrypts_for_recipient_and_recovers_for_sender() { + use dash_sdk::platform::shielded::try_recover_outgoing_note; + + let sender_keys = OrchardKeySet::from_seed(&[0x42u8; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + let recipient_keys = OrchardKeySet::from_seed(&[0x77u8; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + + let recipient_raw = recipient_keys.default_address.to_raw_address_bytes(); + let recipient = OrchardAddress::from_raw_bytes(&recipient_raw) + .expect("recipient default address must convert to OrchardAddress"); + + let amount: u64 = 150_000_000_000; // 1.5 DASH in credits + let mut inputs = BTreeMap::new(); + inputs.insert( + PlatformAddress::P2pkh([0xCD; 20]), + (0u32, 500_000_000_000u64), + ); + + let mut memo = [0u8; 36]; + memo[..4].copy_from_slice(&[1, 0, 0, 0]); + memo[4..9].copy_from_slice(b"hello"); + + let prover = CachedOrchardProver::new(); + let st = build_shield_transition( + &recipient, + amount, + inputs, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &DummySigner, + 0, + &&prover, + memo, + // Production config (`operations::shield`): OVK-keyed to the + // SENDER, so the sender's scan can recover the send. + Some(sender_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"); + }; + let wires: Vec = v0 + .actions + .iter() + .map(|a| ShieldedEncryptedNote { + cmx: a.cmx.to_vec(), + nullifier: a.nullifier.to_vec(), + cv_net: a.cv_net.to_vec(), + encrypted_note: a.encrypted_note.clone(), + }) + .collect(); + + // The recipient's scan sees exactly one spendable note of `amount`. + let recipient_ivk = recipient_keys.prepared_ivk(); + let for_recipient: Vec = wires + .iter() + .filter_map(|w| try_decrypt_note(&recipient_ivk, w).map(|(note, _)| note.value().inner())) + .collect(); + assert_eq!( + for_recipient, + vec![amount], + "exactly the real output must decrypt for the recipient, at the sent amount" + ); + + // The sender's IVK sees nothing — the note is not ours to spend. + let sender_ivk = sender_keys.prepared_ivk(); + assert!( + wires + .iter() + .all(|w| try_decrypt_note(&sender_ivk, w).is_none()), + "no action may IVK-decrypt for the sender (the note belongs to the recipient)" + ); + + // The sender's OVK recovers the send — recipient and memo intact — + // which is what drives the pending-row confirmation and the + // restore-path `Sent` classification. + let recovered: Vec<_> = wires + .iter() + .filter_map(|w| try_recover_outgoing_note(&sender_keys.outgoing_viewing_key, w)) + .collect(); + assert_eq!( + recovered.len(), + 1, + "exactly the real output must OVK-recover for the sender" + ); + let (note, recovered_recipient, recovered_memo) = &recovered[0]; + assert_eq!(note.value().inner(), amount); + assert_eq!( + recovered_recipient.to_raw_address_bytes(), + recipient_raw, + "OVK recovery must surface the external recipient's address" + ); + assert_eq!( + &recovered_memo[..], + &memo[..], + "the 36-byte memo must round-trip through OVK recovery" + ); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 1b94323e60e..014b6a09726 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -703,20 +703,113 @@ extension PlatformWalletManager { let signerHandle = addressSigner.handle try await Task.detached(priority: .userInitiated) { - // Keepalive — same rationale as `topUpFromAddresses`. - // The trampoline ctx pointer inside the signer - // dangles unless the Swift owner outlives this - // detached work. - _ = addressSigner + // KeychainSigner is passed to Rust via `passUnretained`, so + // the Rust ctx pointer dangles unless the Swift owner stays + // alive across the whole FFI call — Rust re-materializes it + // inside the proof worker and signs through it. A bare + // `_ = addressSigner` is folklore the optimizer may elide in + // -O builds; `withExtendedLifetime` is the guaranteed + // keepalive (same as `shieldedTransfer`). + try withExtendedLifetime(addressSigner) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try platform_wallet_manager_shielded_shield( + handle, widPtr, shieldedAccount, paymentAccount, amount, signerHandle + ).check() + } + } + }.value + } - try walletId.withUnsafeBytes { widRaw in - guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) - else { - throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + /// Platform → EXTERNAL Shielded. The Type 15 shield with the note + /// assigned to `recipientRaw43` (a third-party raw 43-byte Orchard + /// payment address — same shape [`shieldedTransfer`] takes) instead + /// of the wallet's own default address. Input selection, fees, and + /// error shapes are identical to [`shieldedShield`]; the wallet + /// still needs a bound shielded sub-wallet at `shieldedAccount` + /// because the send is OVK-encrypted to (and its activity recorded + /// under) that account — that is how the wallet's own scan later + /// shows it as sent history. + /// + /// The recipient must actually be a third party: an address the + /// account's own keys recognize (default or diversified) is + /// rejected by Rust — self-shields go through [`shieldedShield`]. + /// + /// `memo` follows [`shieldedTransfer`]'s rules: `nil` / empty means + /// no memo; a non-empty memo's UTF-8 byte length must be at most 32 + /// or Rust rejects it. The 36-byte on-chain encoding is done on the + /// Rust side. + /// + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the + /// broadcast was accepted but its execution result couldn't be + /// confirmed — the shield may already be on chain, so the caller + /// must NOT retry (a retry would rebuild the bundle and could + /// double-pay; the next sync reconciles the outcome). A shield + /// spends no notes, so nothing is reserved wallet-side. + public func shieldedShieldToRecipient( + walletId: Data, + shieldedAccount: UInt32 = 0, + paymentAccount: UInt32 = 0, + recipientRaw43: Data, + amount: UInt64, + memo: String? = nil, + addressSigner: KeychainSigner + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + guard recipientRaw43.count == 43 else { + throw PlatformWalletError.invalidParameter( + "recipient must be exactly 43 raw Orchard bytes" + ) + } + + let handle = self.handle + let signerHandle = addressSigner.handle + + try await Task.detached(priority: .userInitiated) { + // Guaranteed signer keepalive across the whole FFI call — + // same rationale as `shieldedShield`. + try withExtendedLifetime(addressSigner) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try recipientRaw43.withUnsafeBytes { recipientRaw in + guard let recipientPtr = recipientRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "recipient baseAddress is nil" + ) + } + // `nil` / empty → null pointer (no memo); otherwise + // pass the text as a C string — Rust validates the + // 32-byte limit and does the 36-byte encoding. + let send: (UnsafePointer?) throws -> Void = { memoCStr in + try platform_wallet_manager_shielded_shield_to_recipient( + handle, widPtr, shieldedAccount, paymentAccount, + recipientPtr, amount, memoCStr, signerHandle + ).check() + } + if let memo, !memo.isEmpty { + try memo.withCString { try send($0) } + } else { + try send(nil) + } + } } - try platform_wallet_manager_shielded_shield( - handle, widPtr, shieldedAccount, paymentAccount, amount, signerHandle - ).check() } }.value }