From 22c33bf09681853ad0730ffbb4bee5e287ee651d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 16:47:17 +0700 Subject: [PATCH 01/13] feat(shielded): wallet-level fund_from_asset_lock with key_wallet::Signer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the orchestrated entry point for shielded funding from a Core L1 asset lock, parallel to the platform-address sibling shipped in #3671. Wires the full pipeline: DPP layer: - ShieldFromAssetLockTransitionMethodsV0::try_from_asset_lock_with_bundle_and_signer: Signer-based variant that produces the outer ECDSA signature via StateTransition::sign_with_core_signer (atomic derive + sign + zeroise inside the signer's trust boundary; raw key never crosses the FFI boundary). Gated on state-transition-signing + core_key_wallet. - build_shield_from_asset_lock_transition_with_signer: async builder that wraps the new method. Wallet layer (new wallet/shielded/fund_from_asset_lock.rs): - PlatformWallet::shielded_fund_from_asset_lock(funding, recipients, asset_lock_signer, prover, settings) -> Result<(), _> - Pipeline mirrors fund_from_asset_lock for platform addresses: preflight → resolve_funding_with_is_timeout_fallback → submit_with_cl_height_retry → IS->CL fallback on Platform-side IS rejection → consume_asset_lock. - Recipient API: Vec<(OrchardAddress, Option)> with preflight enforcing len() == 1 today. TODO at the preflight site to lift once DPP grows multi-output Orchard bundles for Type 18 (a build_output_only_bundle change shared with Type 15). - Shield amount = asset_lock_value − protocol_min_fee. Asset-lock value is read from the IS proof's TxOut when available, else looked up by outpoint in the asset-lock manager. Cleanup: - Removed wallet/shielded/operations.rs::shield_from_asset_lock (the raw-key, single-recipient stub). No external callers — the shielded_send.rs FFI module comment explicitly flagged it as unwired. The DPP-layer raw-key methods remain for the SDK trait and any external integrations. Tests: 4 preflight unit tests (empty, multi-recipient, single, single-with-amount). Orchestration itself is not unit-testable without significant SDK + Core mock infrastructure; coverage will come from drive-abci integration tests. --- packages/rs-dpp/src/shielded/builder/mod.rs | 2 + .../builder/shield_from_asset_lock.rs | 60 +++ .../methods/mod.rs | 44 ++ .../methods/v0/mod.rs | 27 + .../v0/v0_methods.rs | 42 ++ .../wallet/shielded/fund_from_asset_lock.rs | 461 ++++++++++++++++++ .../src/wallet/shielded/mod.rs | 1 + .../src/wallet/shielded/operations.rs | 57 +-- 8 files changed, 640 insertions(+), 54 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index fd85f71c9a..288c1b7e4c 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -36,6 +36,8 @@ mod unshield; pub use self::shield::build_shield_transition; pub use shield_from_asset_lock::build_shield_from_asset_lock_transition; +#[cfg(feature = "core_key_wallet")] +pub use shield_from_asset_lock::build_shield_from_asset_lock_transition_with_signer; pub use shielded_transfer::build_shielded_transfer_transition; pub use shielded_withdrawal::build_shielded_withdrawal_transition; pub use unshield::build_unshield_transition; 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 bfa6ebf890..c71e678c29 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 @@ -58,6 +58,66 @@ pub fn build_shield_from_asset_lock_transition( ) } +/// Builds a ShieldFromAssetLock state transition where the +/// asset-lock-proof signature is produced by an external +/// [`key_wallet::signer::Signer`] (Swift / hardware-wallet / HSM +/// flow). The raw private key never crosses the FFI boundary; +/// derive + sign + zeroise happen inside the signer. +/// +/// # Parameters +/// - `recipient` - Orchard address to receive the shielded note +/// - `shield_amount` - Amount of credits to shield (from the asset lock) +/// - `asset_lock_proof` - Proof that funds are locked on core chain +/// - `asset_lock_proof_path` - BIP32 path to the asset-lock key inside `asset_lock_signer` +/// - `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) +/// - `platform_version` - Protocol version +#[cfg(feature = "core_key_wallet")] +#[allow(clippy::too_many_arguments)] +pub async fn build_shield_from_asset_lock_transition_with_signer( + recipient: &OrchardAddress, + shield_amount: u64, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + prover: &P, + memo: [u8; 36], + platform_version: &PlatformVersion, +) -> Result +where + P: OrchardProver, + AS: ::key_wallet::signer::Signer, +{ + let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?; + let sb = serialize_authorized_bundle(&bundle); + + // For output-only bundles, Orchard value_balance is negative (value flowing in). + // Convert to u64 (absolute amount entering the pool). + let value_balance = sb + .value_balance + .checked_neg() + .and_then(|v| u64::try_from(v).ok()) + .ok_or_else(|| { + ProtocolError::ShieldedBuildError( + "shield_from_asset_lock: bundle value_balance is not negative".to_string(), + ) + })?; + + ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + sb.actions, + value_balance, + sb.anchor, + sb.proof, + sb.binding_signature, + platform_version, + ) + .await +} + #[cfg(test)] mod tests { use super::super::{build_output_only_bundle, serialize_authorized_bundle}; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs index a71e402d4c..5aa8f574d3 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs @@ -53,4 +53,48 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransition { }), } } + + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + platform_version: &PlatformVersion, + ) -> Result + where + AS: ::key_wallet::signer::Signer, + { + match platform_version + .dpp + .state_transition_serialization_versions + .shield_from_asset_lock_state_transition + .default_current_version + { + 0 => { + ShieldFromAssetLockTransitionV0::try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + actions, + value_balance, + anchor, + proof, + binding_signature, + platform_version, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer" + .to_string(), + known_versions: vec![0], + received: version, + }), + } + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs index 3ac5404bda..266955429f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs @@ -22,6 +22,33 @@ pub trait ShieldFromAssetLockTransitionMethodsV0 { platform_version: &PlatformVersion, ) -> Result; + /// Build a `ShieldFromAssetLock` state transition where the + /// asset-lock-proof signature is produced by an external + /// [`key_wallet::signer::Signer`]. + /// + /// `asset_lock_signer` produces the outer state-transition ECDSA + /// signature for the key at `asset_lock_proof_path` — atomically + /// deriving, signing, and zeroising inside the signer's trust + /// boundary. This is the signing path used by hosts that hold + /// their private keys outside Rust (the iOS Swift SDK, hardware + /// wallets, remote signers); the raw key never crosses the FFI + /// boundary. + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + #[allow(clippy::too_many_arguments)] + async fn try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + platform_version: &PlatformVersion, + ) -> Result + where + AS: ::key_wallet::signer::Signer; + /// Get State Transition Type fn get_type() -> StateTransitionType { StateTransitionType::ShieldFromAssetLock diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs index c10d1907a9..98535f6565 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs @@ -46,4 +46,46 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransitionV0 Ok(transition.into()) } + + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + _platform_version: &PlatformVersion, + ) -> Result + where + AS: ::key_wallet::signer::Signer, + { + // Build the unsigned inner transition. The `signature` field + // is `#[platform_signable(exclude_from_sig_hash)]`, so the + // signable bytes are stable across the empty-then-signed + // transition. + let transition = ShieldFromAssetLockTransitionV0 { + asset_lock_proof, + actions, + value_balance, + anchor, + proof, + binding_signature, + signature: Default::default(), + }; + + // Hand the outer ST to `sign_with_core_signer`, which routes + // the asset-lock-proof signature through the external Signer. + // The derive + sign + zeroise sequence happens inside the + // signer — the host never sees a raw private key; only a + // 32-byte digest goes in and a serialised signature comes out. + let mut state_transition: StateTransition = transition.into(); + state_transition + .sign_with_core_signer(asset_lock_proof_path, asset_lock_signer) + .await?; + + Ok(state_transition) + } } 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 new file mode 100644 index 0000000000..b65e9307ab --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -0,0 +1,461 @@ +//! Orchestrated shielded funding from a Core asset lock. +//! +//! Mirrors `wallet/platform_addresses/fund_from_asset_lock.rs` but +//! credits the *shielded* pool (Type 18 `ShieldFromAssetLock`) instead +//! of platform addresses (Type 14 `AddressFundingFromAssetLock`). +//! +//! ## Pipeline +//! +//! 1. **Pre-flight** — exactly-one recipient today (the multi-shape +//! `Vec<(OrchardAddress, Option)>` API is in place so +//! the caller signature doesn't change when DPP grows multi-output +//! Orchard bundles for Type 18; see [`validate_shielded_recipients`]). +//! 2. **Resolve funding** — delegate to the shared +//! [`AssetLockManager::resolve_funding_with_is_timeout_fallback`]. +//! 3. **Submit** — wrap the build-and-broadcast in +//! `submit_with_cl_height_retry`; the build uses the new +//! [`build_shield_from_asset_lock_transition_with_signer`] so the +//! asset-lock-proof signature is routed through the external +//! `key_wallet::signer::Signer` (the host never sees the raw key). +//! IS→CL fallback fires on Platform-side IS rejection +//! (`is_instant_lock_proof_invalid`). +//! 4. **Consume lock** — terminal `consume_asset_lock` on the tracked +//! outpoint. Notes themselves arrive via the next shielded sync; +//! the shielded changeset doesn't materialise post-submit the way +//! the address-funding `AddressInfos` does. + +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::put_settings::PutSettings; +use dpp::address_funds::OrchardAddress; +use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::fee::Credits; +use dpp::prelude::AssetLockProof; +use dpp::shielded::builder::{build_shield_from_asset_lock_transition_with_signer, OrchardProver}; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + +use crate::error::is_instant_lock_proof_invalid; +use crate::wallet::asset_lock::orchestration::{ + out_point_from_proof, submit_with_cl_height_retry, AssetLockFunding, FundingResolution, + ResolvedFunding, CL_FALLBACK_TIMEOUT, +}; +use crate::wallet::PlatformWallet; +use crate::PlatformWalletError; + +impl PlatformWallet { + /// Fund the shielded pool from a Core L1 asset lock, with the + /// asset-lock proof signed by an external + /// `key_wallet::signer::Signer` (atomic derive + sign + zeroise + /// inside the signer's trust boundary). + /// + /// # Arguments + /// + /// * `funding` — How to source the asset lock. `FromWalletBalance` + /// builds a fresh asset lock from Core UTXOs; `FromExistingAssetLock` + /// resumes from a tracked outpoint (after relaunch or a stuck + /// broadcast). + /// * `recipients` — Map from recipient `OrchardAddress` to optional + /// credit amount. The shape mirrors the platform-address API for + /// future-compatibility with multi-output Orchard bundles, but + /// today the pre-flight enforces exactly one recipient and the + /// `Option` value is ignored — the single recipient + /// always receives the full asset-lock value minus the protocol + /// minimum fee. + /// * `asset_lock_signer` — External signer for the outer ECDSA + /// signature on the state transition. The raw key never crosses + /// the FFI boundary. + /// * `prover` — Orchard prover (holds the Halo 2 proving key). + /// * `settings` — Optional `PutSettings`; `user_fee_increase` is + /// bumped by the CL-height retry wrapper on consensus 10506. + #[cfg(feature = "shielded")] + pub async fn shielded_fund_from_asset_lock( + &self, + funding: AssetLockFunding, + recipients: Vec<(OrchardAddress, Option)>, + asset_lock_signer: &AS, + prover: P, + settings: Option, + ) -> Result<(), PlatformWalletError> + where + AS: ::key_wallet::signer::Signer + Send + Sync, + P: OrchardProver, + { + // Step 1: pre-flight. Failing fast here avoids broadcasting + // an unfundable asset-lock tx (or paying for an Orchard proof + // build, ~30s, only to reject downstream). + validate_shielded_recipients(&recipients)?; + + // Single-flight: serialise shield-class operations on this + // wallet so two concurrent calls can't race the asset-lock + // tracker into a half-consumed state. + let _shield_guard = self.shield_guard.lock().await; + + // Step 2: resolve funding. `AssetLockAddressTopUp` selects the + // BIP44 funding family for the Core asset-lock tx; + // `destination_index = 0` is unused for this funding type. + let ResolvedFunding { + proof, + path, + tracked_out_point, + } = match self + .asset_locks + .resolve_funding_with_is_timeout_fallback( + funding, + AssetLockFundingType::AssetLockAddressTopUp, + /* destination_index */ 0, + asset_lock_signer, + ) + .await? + { + FundingResolution::Resolved(rf) => rf, + FundingResolution::IsTimeout { out_point } => { + tracing::warn!( + "IS-lock did not propagate within 300s for shielded fund-from-asset-lock \ + (tx {}), falling back to ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) + .await?; + let (_, path) = self + .asset_locks + .resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT) + .await?; + ResolvedFunding { + proof: chain_proof, + path, + tracked_out_point: Some(out_point), + } + } + }; + + // Step 3: compute the shield amount. + // + // For Type 18, the protocol does not deduct fees inside the + // transition (unlike address-funding's + // `AddressFundsFeeStrategy`) — the Orchard `value_balance` + // baked into the Halo 2 proof at build time *is* what enters + // the shielded pool, and the asset-lock value minus that + // covers the fee. + // + // Today (single recipient + value=None), the recipient gets + // `asset_lock_value − min_required_fee`. When DPP grows + // multi-output Orchard bundles for Type 18, this branches: + // explicit `Some(_)` amounts pass through; the `None` bucket + // receives the residual. + let asset_lock_value_credits = + lookup_asset_lock_value_credits(self, &proof, tracked_out_point.as_ref()).await?; + let min_fee = self.shield_from_asset_lock_min_fee()?; + let shield_amount = asset_lock_value_credits + .checked_sub(min_fee) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock value ({asset_lock_value_credits} credits) is below the \ + minimum required fee ({min_fee} credits) for ShieldFromAssetLock" + )) + })?; + if shield_amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "shield amount after fee is zero".to_string(), + )); + } + + let recipient = recipients.first().expect("preflight enforces len() == 1").0; + + // 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 + // cache) and IS-lock rejection triggers an IS→CL upgrade on + // the same outpoint. + let proof_out_point = out_point_from_proof(&proof); + let sdk = self.sdk.clone(); + match submit_with_cl_height_retry(settings, |s| { + build_and_broadcast_shielded( + sdk.clone(), + recipient, + shield_amount, + proof.clone(), + path.clone(), + asset_lock_signer, + &prover, + s, + ) + }) + .await + { + Ok(()) => {} + Err(e) if is_instant_lock_proof_invalid(&e) => { + let out_point = proof_out_point; + tracing::warn!( + "IS-lock proof rejected by Platform for shielded fund-from-asset-lock \ + (tx {}), retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) + .await?; + let cs = self + .asset_locks + .advance_asset_lock_status( + &out_point, + crate::wallet::asset_lock::tracked::AssetLockStatus::ChainLocked, + Some(chain_proof.clone()), + ) + .await?; + self.asset_locks.queue_asset_lock_changeset(cs); + submit_with_cl_height_retry(settings, |s| { + build_and_broadcast_shielded( + sdk.clone(), + recipient, + shield_amount, + chain_proof.clone(), + path.clone(), + asset_lock_signer, + &prover, + s, + ) + }) + .await + .map_err(PlatformWalletError::Sdk)?; + } + Err(e) => return Err(PlatformWalletError::Sdk(e)), + } + + // Step 5: cleanup. Consume the tracked asset lock. The + // shielded note itself arrives via the next sync — there's + // no immediate balance changeset to persist (unlike + // address-funding, which writes proof-attested balances back + // into `ManagedPlatformAccount`). + if let Some(out_point) = tracked_out_point { + // Platform DID accept the shield ST — propagating an Err + // here would misreport the protocol outcome. The lock row + // stays non-Consumed and surfaces in the Resumable + // Funding list; a user Resume on it would be + // deterministically rejected by Platform with "lock + // already consumed". Log so it's visible. + if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await { + match &e { + PlatformWalletError::WalletNotFound(_) => { + tracing::warn!( + outpoint = %out_point, + error = %e, + "consume_asset_lock: wallet handle vanished after successful shielded submit" + ); + } + _ => { + tracing::error!( + outpoint = %out_point, + error = %e, + "consume_asset_lock failed unexpectedly after successful shielded submit; \ + the lock row stays non-Consumed and will surface as Resumable. \ + A user Resume on it will be rejected by Platform with 'lock already consumed'." + ); + } + } + } + } + + tracing::info!( + shield_amount, + asset_lock_value_credits, + min_fee, + "Shielded fund-from-asset-lock succeeded" + ); + + Ok(()) + } + + /// Minimum fee for a `ShieldFromAssetLock` (Type 18) state + /// transition, in credits. Read from + /// `dpp.state_transitions.identities.asset_locks` — + /// the same fee field Type 14 (address funding) reads. + fn shield_from_asset_lock_min_fee(&self) -> Result { + let pv = self.sdk.version(); + let asset_lock_base_cost_duffs = pv + .dpp + .state_transitions + .identities + .asset_locks + .required_asset_lock_duff_balance_for_processing_start_for_address_funding; + asset_lock_base_cost_duffs + .checked_mul(CREDITS_PER_DUFF) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "platform version min-fee constant overflowed credits conversion".to_string(), + ) + }) + } +} + +/// Look up the asset-lock value in credits. +/// +/// Preference order: +/// 1. If the proof is `Instant`, read directly from +/// `InstantAssetLockProof::output().value` — no manager lookup +/// needed; this also keeps the function callable in tests that +/// inject a synthetic IS proof. +/// 2. Otherwise (the IS-timeout-fallback path produced a CL proof +/// that doesn't carry the tx output), look up the tracked asset +/// lock by outpoint. +async fn lookup_asset_lock_value_credits( + wallet: &PlatformWallet, + proof: &AssetLockProof, + tracked_out_point: Option<&dashcore::OutPoint>, +) -> Result { + let duffs = match proof { + AssetLockProof::Instant(is) => { + let out = is.output().ok_or_else(|| { + PlatformWalletError::AddressSync( + "InstantAssetLockProof has no output at the indicated index".to_string(), + ) + })?; + out.value + } + AssetLockProof::Chain(_) => { + let op = tracked_out_point.ok_or_else(|| { + PlatformWalletError::AddressSync( + "ChainAssetLockProof but no tracked outpoint to look up value".to_string(), + ) + })?; + let locks = wallet.asset_locks.list_tracked_locks().await; + locks + .iter() + .find(|l| l.out_point == *op) + .map(|l| l.amount) + .ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "tracked asset lock {} not found in manager", + op + )) + })? + } + }; + duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock value ({duffs} duffs) overflowed credits conversion" + )) + }) +} + +/// Build the Type 18 transition and broadcast-and-wait. +/// +/// Extracted so `submit_with_cl_height_retry`'s closure stays compact +/// and the IS→CL fallback path can re-call it with the upgraded proof. +#[allow(clippy::too_many_arguments)] +async fn build_and_broadcast_shielded( + sdk: std::sync::Arc, + recipient: OrchardAddress, + shield_amount: Credits, + proof: AssetLockProof, + path: ::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + prover: &P, + settings: Option, +) -> Result<(), dash_sdk::Error> +where + AS: ::key_wallet::signer::Signer, + P: OrchardProver, +{ + let st = build_shield_from_asset_lock_transition_with_signer( + &recipient, + shield_amount, + proof, + &path, + asset_lock_signer, + prover, + [0u8; 36], + sdk.version(), + ) + .await?; + + // Wait for proven execution rather than relay-ACK. Single-use + // asset-lock proof: a false-positive on a transition Platform + // later rejects would strand the L1 outpoint with no in-app + // signal. The proven result is discarded; we only need the + // confirmation that drive-abci committed. + st.broadcast_and_wait::(&sdk, settings) + .await?; + Ok(()) +} + +/// Pre-flight check for the recipient list. +/// +/// Today: non-empty, exactly one recipient. The multi-shape +/// `Vec<(OrchardAddress, Option)>` API is exposed so the +/// caller signature is future-compatible — when DPP grows multi-output +/// Orchard bundles for Type 18, lifting this restriction is a +/// preflight-only change; no FFI / Swift / caller migration needed. +/// +/// Generic over `T` so unit tests can pass `(u8, Option)` +/// instead of constructing a curve-valid `OrchardAddress` for what +/// is really a length/cardinality check. +pub(super) fn validate_shielded_recipients( + recipients: &[(T, Option)], +) -> Result<(), PlatformWalletError> { + if recipients.is_empty() { + return Err(PlatformWalletError::AddressOperation( + "shielded_fund_from_asset_lock requires at least one recipient".to_string(), + )); + } + // TODO(multi-output): when DPP grows multi-output Orchard bundles + // for Type 18 (`build_output_only_bundle` currently builds a + // single-output bundle; extending would also affect the Shield + // Type 15 path that shares it), drop this restriction. The + // semantics will become: explicit `Some(_)` amounts pass through + // unchanged; the (exactly one) `None` bucket receives the + // residual `asset_lock_value − sum(explicit) − fee`. + if recipients.len() != 1 { + return Err(PlatformWalletError::AddressOperation(format!( + "shielded_fund_from_asset_lock currently supports exactly one recipient \ + (multi-output Orchard bundles for Type 18 not yet wired through DPP); got {}", + recipients.len() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The preflight is a pure length/cardinality check; the + // recipient type is irrelevant for what we're testing. Using + // `u8` as the placeholder type avoids needing to construct a + // curve-valid `OrchardAddress` (which requires the Orchard + // crate's spend-key plumbing) inside this crate. + + #[test] + fn validate_rejects_empty_recipients() { + let v: Vec<(u8, Option)> = Vec::new(); + let err = validate_shielded_recipients(&v).expect_err("empty must reject"); + assert!(format!("{err}").contains("at least one recipient")); + } + + #[test] + fn validate_rejects_multi_recipient_for_now() { + let v: Vec<(u8, Option)> = vec![(1, None), (2, Some(100))]; + let err = validate_shielded_recipients(&v).expect_err("multi-recipient must reject (TODO)"); + let msg = format!("{err}"); + assert!( + msg.contains("exactly one recipient"), + "unexpected error: {msg}" + ); + } + + #[test] + fn validate_accepts_single_recipient() { + let v: Vec<(u8, Option)> = vec![(0, None)]; + validate_shielded_recipients(&v).expect("single recipient must pass"); + } + + #[test] + fn validate_accepts_single_recipient_with_some_amount() { + // The Some(_) value is ignored today (single-recipient case + // always receives the residual), but the shape stays valid + // so the caller signature is future-compatible. + let v: Vec<(u8, Option)> = vec![(0, Some(500_000))]; + validate_shielded_recipients(&v).expect("single recipient with amount must pass"); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 24fcb86c95..e9a0f6d9f8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -33,6 +33,7 @@ pub mod coordinator; pub mod file_store; +pub mod fund_from_asset_lock; pub mod keys; pub mod note_selection; pub mod operations; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 684e4ac11b..ec1857bc25 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -35,11 +35,9 @@ use dpp::address_funds::{ use dpp::fee::Credits; use dpp::identity::core_script::CoreScript; use dpp::identity::signer::Signer; -use dpp::prelude::AssetLockProof; use dpp::shielded::builder::{ - build_shield_from_asset_lock_transition, build_shield_transition, - build_shielded_transfer_transition, build_shielded_withdrawal_transition, - build_unshield_transition, OrchardProver, SpendableNote, + build_shield_transition, build_shielded_transfer_transition, + build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, }; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::withdrawal::Pooling; @@ -261,58 +259,9 @@ pub async fn shield, P: OrchardProver>( // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) +// (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) // ------------------------------------------------------------------------- -/// Shield credits from a Core L1 asset lock into the shielded -/// pool, with the resulting note assigned to `account`'s default -/// Orchard payment address derived from `keys`. -pub async fn shield_from_asset_lock( - sdk: &Arc, - keys: &OrchardKeySet, - account: u32, - asset_lock_proof: AssetLockProof, - private_key: &[u8], - amount: u64, - prover: &P, -) -> Result<(), PlatformWalletError> { - let recipient_addr = default_orchard_address(keys)?; - - info!( - account, - credits = amount, - "Shield from asset lock: building state transition" - ); - - let state_transition = build_shield_from_asset_lock_transition( - &recipient_addr, - amount, - asset_lock_proof, - private_key, - prover, - [0u8; 36], - sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Shield from asset lock: state transition built, broadcasting..."); - // Wait for proven execution rather than relay-ACK. This matters most - // for Type 18: the asset-lock proof is single-use, so a false- - // positive success on a transition Platform later rejects would - // strand the user's L1 outpoint with no in-app signal. The proven - // result is discarded; we only need the confirmation. - state_transition - .broadcast_and_wait::(sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - info!( - account, - credits = amount, - "Shield from asset lock broadcast succeeded" - ); - Ok(()) -} - // ------------------------------------------------------------------------- // Unshield: shielded pool -> platform address (Type 17) // ------------------------------------------------------------------------- From 5c7a64b1e2b6fd62cd0281c6f9401fc4794fa0b7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 16:53:28 +0700 Subject: [PATCH 02/13] feat(ffi): expose shielded_fund_from_asset_lock + resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two FFI entry points wrapping PlatformWallet::shielded_fund_from_asset_lock: - platform_wallet_manager_shielded_fund_from_asset_lock (fresh build from wallet balance) - platform_wallet_manager_shielded_resume_fund_from_asset_lock (resume by outpoint, crash-recovery shape) Both follow the address-funding pattern shipped in #3671: the asset-lock-proof signature is produced by a MnemonicResolverHandle (Keychain resolver on the Swift side), the raw key never crosses the FFI boundary. FFI is single-recipient today, matching the orchestration's preflight constraint. Multi-recipient becomes a new FFI signature when DPP grows multi-output Orchard bundles for Type 18. Also adds PlatformWallet::network() (delegating to the asset-lock manager) — the FFI needs it for constructing the MnemonicResolverCoreSigner, and the absence was a small asymmetry with PlatformAddressWallet. Drops the pre-existing 'isn't wired here yet' TODO from the module doc. --- .../src/shielded_send.rs | 201 +++++++++++++++++- .../src/wallet/platform_wallet.rs | 6 + 2 files changed, 203 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 12c15a0c7a..2eb0005739 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -15,9 +15,12 @@ //! Per-input nonces are fetched from Platform inside //! [`ShieldedWallet::shield`] before building. //! -//! Type 18 (`shield_from_asset_lock` — Core L1→Shielded) lives on -//! [`ShieldedWallet`] but isn't wired here yet — it needs the -//! asset-lock proof + private key threaded through. +//! Type 18 (`shield_from_asset_lock` — Core L1→Shielded) is wired +//! through [`platform_wallet_manager_shielded_fund_from_asset_lock`] +//! and its resume sibling further down. Both follow the +//! address-funding signer pattern: the asset-lock-proof signature +//! is produced by a `MnemonicResolverHandle` so the raw key never +//! crosses the FFI boundary. //! //! Feature-gated behind `shielded`. The accompanying //! [`platform_wallet_shielded_warm_up_prover`] entry-point is @@ -30,10 +33,14 @@ use std::ffi::CStr; use std::os::raw::c_char; +use dashcore::hashes::Hash; +use dpp::address_funds::OrchardAddress; +use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use crate::check_ptr; +use crate::core_wallet_types::OutPointFFI; use crate::error::*; use crate::handle::*; use crate::runtime::{block_on_worker, runtime}; @@ -322,6 +329,192 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( PlatformWalletFFIResult::ok() } +/// 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 +/// by a `MnemonicResolverHandle` — the raw key never crosses the +/// FFI boundary. +/// +/// `account_index` selects the BIP44 Core account whose UTXOs +/// fund the asset lock. `amount_duffs` is the L1 amount to lock. +/// `recipient_raw_43` is the single Orchard recipient (same shape +/// `platform_wallet_manager_shielded_default_address` returns); it +/// receives the asset-lock value minus the protocol minimum fee. +/// +/// Multi-recipient is reserved for a future DPP-side Orchard +/// multi-output bundle change; today the orchestration rejects +/// anything but a single recipient. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `recipient_raw_43` must point to 43 readable bytes (raw +/// Orchard payment address: 11-byte diversifier + 32-byte pk_d). +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// `dash_sdk_mnemonic_resolver_create`. The caller retains +/// ownership. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( + handle: Handle, + wallet_id_bytes: *const u8, + account_index: u32, + amount_duffs: u64, + recipient_raw_43: *const u8, + core_signer_handle: *mut MnemonicResolverHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(recipient_raw_43); + check_ptr!(core_signer_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let mut recipient_bytes = [0u8; 43]; + std::ptr::copy_nonoverlapping(recipient_raw_43, recipient_bytes.as_mut_ptr(), 43); + let recipient = match OrchardAddress::from_raw_bytes(&recipient_bytes) { + Ok(a) => a, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid Orchard recipient address: {e}"), + ); + } + }; + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + let network = wallet.network(); + + // Round-trip the resolver handle through `usize` so the worker + // future's capture is `Send + 'static`. + let core_signer_addr = core_signer_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: see the fn-level safety doc — the resolver handle + // is pinned alive for the duration of this FFI call. + let asset_lock_signer = MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ); + let prover = CachedOrchardProver::new(); + wallet + .shielded_fund_from_asset_lock( + AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index, + }, + vec![(recipient, None)], + &asset_lock_signer, + &prover, + None, + ) + .await + }); + if let Err(e) = result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded fund-from-asset-lock failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + +/// Resume a shielded fund-from-asset-lock by outpoint. +/// +/// Sister to [`platform_wallet_manager_shielded_fund_from_asset_lock`]: +/// instead of building a fresh asset-lock transaction, pick up an +/// existing tracked lock at `out_point` and drive whatever stages +/// remain (broadcast, IS/CL wait, Platform submit, consume). Use +/// case mirrors the platform-address resume path — a prior attempt +/// left the lock in storage at `Broadcast` / `InstantSendLocked` / +/// `ChainLocked` but the shield ST never completed. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `out_point` must be a valid, non-null pointer to an +/// `OutPointFFI` for the duration of the call. +/// - `recipient_raw_43` / `core_signer_handle` — see +/// [`platform_wallet_manager_shielded_fund_from_asset_lock`]. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset_lock( + handle: Handle, + wallet_id_bytes: *const u8, + out_point: *const OutPointFFI, + recipient_raw_43: *const u8, + core_signer_handle: *mut MnemonicResolverHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(out_point); + check_ptr!(recipient_raw_43); + check_ptr!(core_signer_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let mut recipient_bytes = [0u8; 43]; + std::ptr::copy_nonoverlapping(recipient_raw_43, recipient_bytes.as_mut_ptr(), 43); + let recipient = match OrchardAddress::from_raw_bytes(&recipient_bytes) { + Ok(a) => a, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid Orchard recipient address: {e}"), + ); + } + }; + + let out_point_ffi = *out_point; + let resume_outpoint = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array(out_point_ffi.txid), + vout: out_point_ffi.vout, + }; + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + let network = wallet.network(); + + let core_signer_addr = core_signer_handle as usize; + + let result = block_on_worker(async move { + let asset_lock_signer = MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ); + let prover = CachedOrchardProver::new(); + wallet + .shielded_fund_from_asset_lock( + AssetLockFunding::FromExistingAssetLock { + out_point: resume_outpoint, + }, + vec![(recipient, None)], + &asset_lock_signer, + &prover, + None, + ) + .await + }); + if let Err(e) = result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded resume fund-from-asset-lock failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + /// Resolve the wallet `Arc` for the given manager handle, or /// produce a `PlatformWalletFFIResult` describing why we couldn't. fn resolve_wallet( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index ec0c23be8b..f2fe60c090 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -140,6 +140,12 @@ impl PlatformWallet { self.wallet_id } + /// The Dash network this wallet operates on. Delegates to the + /// asset-lock manager, which is the single source of truth. + pub fn network(&self) -> dashcore::Network { + self.asset_locks.network() + } + /// Get a reference to the SDK. pub fn sdk(&self) -> &dash_sdk::Sdk { &self.sdk From 31b6e66b213ab53ebb6a9205edf9e68f4b5bd9a1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 16:59:15 +0700 Subject: [PATCH 03/13] feat(swift-sdk): shieldedFundFromAssetLock + resume on PlatformWalletManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two extension methods on PlatformWalletManager: - shieldedFundFromAssetLock(walletId, fundingAccountIndex, amountDuffs, recipients) - shieldedResumeFundFromAssetLock(walletId, outPointTxid, outPointVout, recipients) Wraps the new platform_wallet_manager_shielded_fund_from_asset_lock and *_resume FFI calls. Mirrors the ManagedPlatformAddressWallet.fundFromAssetLock pattern: detached Task.userInitiated, MnemonicResolver constructed on the calling actor and pinned via withExtendedLifetime across the FFI call (so the -O optimizer can't elide it and leave the vtable callback dangling). Architecture note: shielded operations live on PlatformWalletManager (process-global coordinator scope), not on a per-wallet 'ManagedShieldedWallet' object — the existing ShieldedSync, Transfer, Shield, Unshield, Withdraw methods are all on the manager, and the new funding methods follow that convention. ShieldedFundFromAssetLockRecipient is a multi-shape Sendable struct (raw 43-byte recipient + optional credits) so the call-site API doesn't change when DPP grows multi-output Orchard bundles for Type 18. Preflight today rejects empty / multi- recipient lists with a TODO at the guard site. --- ...PlatformWalletManagerShieldedFunding.swift | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift new file mode 100644 index 0000000000..9a25efba77 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -0,0 +1,247 @@ +import Foundation +import SwiftDashSDKFFI + +/// Recipient entry for `shieldedFundFromAssetLock(...)`. +/// +/// The Rust-side API today enforces exactly one recipient (the +/// `Option` is ignored — the single recipient always +/// receives the full asset-lock value minus the protocol minimum +/// fee). The multi-shape signature is exposed here so the call +/// site doesn't have to change when DPP grows multi-output Orchard +/// bundles for Type 18. +public struct ShieldedFundFromAssetLockRecipient: Sendable { + /// Raw 43-byte Orchard payment address (11-byte diversifier + + /// 32-byte pk_d). Same shape `platform_wallet_manager_shielded_default_address` + /// returns and `shieldedTransfer` consumes. + public let recipientRaw43: Data + + /// Explicit credit amount, or `nil` to receive the remainder. + /// Today the value is ignored — only the address is consumed. + public let credits: UInt64? + + public init(recipientRaw43: Data, credits: UInt64? = nil) { + self.recipientRaw43 = recipientRaw43 + self.credits = credits + } +} + +extension PlatformWalletManager { + /// Fund the shielded pool from a Core L1 asset lock, orchestrated + /// entirely on the Rust side (build asset-lock tx → wait for + /// IS-lock or fall back to ChainLock → submit + /// `ShieldFromAssetLockTransition` → consume the lock on + /// success). The asset-lock private key never crosses the FFI + /// boundary — both Core-side derivation and the outer ST + /// signature route through a local `MnemonicResolver`. + /// + /// Mirrors `ManagedPlatformAddressWallet.fundFromAssetLock` for + /// platform-address funding, with two differences specific to + /// the shielded pool: + /// + /// 1. The recipient list is **single-entry today** (Type 18's + /// Orchard bundle builder is single-output; the multi-output + /// case is a deferred DPP change). The preflight rejects any + /// other length with a typed error. + /// 2. There is no immediate balance changeset to return — the + /// new shielded note arrives via the next sync, not from + /// the broadcast call. The function returns `Void` on + /// success. + /// + /// - Parameters: + /// - walletId: 32-byte wallet identifier (the same key + /// `bindShielded` uses to look up the bound subwallet). + /// - fundingAccountIndex: BIP44 Core account whose UTXOs fund + /// the asset lock. + /// - amountDuffs: Amount to lock in Core duffs. The shielded + /// pool receives `amountDuffs * CREDITS_PER_DUFF` minus the + /// protocol minimum fee. + /// - recipients: Destination addresses (exactly one today). + /// Preflight rejects empty or multi-recipient lists. + public func shieldedFundFromAssetLock( + walletId: Data, + fundingAccountIndex: UInt32, + amountDuffs: UInt64, + recipients: [ShieldedFundFromAssetLockRecipient] + ) async throws { + try shieldedFundFromAssetLockPreflight( + walletId: walletId, + recipients: recipients + ) + + let handle = self.handle + let recipientRaw43 = recipients[0].recipientRaw43 + // Constructed on the calling actor so it lives for the + // entire detached Task. Released after `withExtendedLifetime` + // returns. See `ManagedPlatformAddressWallet.fundFromAssetLock` + // for the rationale on why a bare `_ = coreSigner` is NOT + // a substitute — the -O optimizer can elide the discard + // and drop the resolver mid-FFI-call, leading to a UAF in + // the vtable callback. + let coreSigner = MnemonicResolver() + + try await Task.detached(priority: .userInitiated) { + 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" + ) + } + let result = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_fund_from_asset_lock( + handle, + widPtr, + fundingAccountIndex, + amountDuffs, + recipientPtr, + coreSigner.handle + ) + } + try result.check() + } + } + }.value + } + + /// Resume a stuck shielded fund-from-asset-lock from an + /// already-tracked outpoint. + /// + /// Sibling to [`shieldedFundFromAssetLock`]: the wallet-balance + /// variant builds a fresh asset-lock transaction; this variant + /// picks up a lock that's already tracked + /// (`Broadcast` / `InstantSendLocked` / `ChainLocked`) and + /// drives whatever stages remain. Use case mirrors the + /// platform-address resume path — a prior attempt left the lock + /// in storage but the shield ST never landed, and the user + /// picks the lock from a "Resumable Funding" surface. + /// + /// - Parameters: + /// - walletId: 32-byte wallet identifier. + /// - outPointTxid: 32-byte raw txid (little-endian wire + /// order, same as `OutPointFFI.txid`). + /// - outPointVout: Funding output index (always 0 for asset + /// locks built by this wallet, but kept for generality). + /// - recipients: Destination addresses (single-entry today; + /// same preflight as the fresh-build variant). + public func shieldedResumeFundFromAssetLock( + walletId: Data, + outPointTxid: Data, + outPointVout: UInt32, + recipients: [ShieldedFundFromAssetLockRecipient] + ) async throws { + guard outPointTxid.count == 32 else { + throw PlatformWalletError.invalidParameter( + "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" + ) + } + try shieldedFundFromAssetLockPreflight( + walletId: walletId, + recipients: recipients + ) + + let handle = self.handle + let recipientRaw43 = recipients[0].recipientRaw43 + let coreSigner = MnemonicResolver() + + try await Task.detached(priority: .userInitiated) { + var txidTuple: + ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + outPointTxid.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &txidTuple) { dst in + dst.copyMemory(from: src) + } + } + var outPoint = OutPointFFI(txid: txidTuple, vout: outPointVout) + + 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" + ) + } + let result = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_resume_fund_from_asset_lock( + handle, + widPtr, + &outPoint, + recipientPtr, + coreSigner.handle + ) + } + try result.check() + } + } + }.value + } + + /// Validate the recipient list before the FFI sees it. The Rust + /// side enforces the same invariants — duplicating them here + /// produces a synchronous, type-specific error before paying + /// for the `Task.detached` setup, the resolver allocation, and + /// the (potentially long-running) Halo 2 proof build. + private func shieldedFundFromAssetLockPreflight( + walletId: Data, + recipients: [ShieldedFundFromAssetLockRecipient] + ) 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 (was \(walletId.count))" + ) + } + guard !recipients.isEmpty else { + throw PlatformWalletError.invalidParameter("recipients is empty") + } + // TODO(multi-output): drop this once DPP grows multi-output + // Orchard bundles for Type 18 (today + // `build_output_only_bundle` is single-output and is shared + // with the Shield Type 15 path, so lifting it on Type 18 + // would also affect Type 15). + guard recipients.count == 1 else { + throw PlatformWalletError.invalidParameter( + "shieldedFundFromAssetLock currently supports exactly one recipient " + + "(multi-output Orchard bundles for Type 18 are pending in DPP); got \(recipients.count)" + ) + } + for r in recipients { + guard r.recipientRaw43.count == 43 else { + throw PlatformWalletError.invalidParameter( + "ShieldedFundFromAssetLockRecipient.recipientRaw43 must be exactly 43 bytes (got \(r.recipientRaw43.count))" + ) + } + } + } +} From 9031f58274af3a96e2b365ab040c285663ac8b2e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 17:23:55 +0700 Subject: [PATCH 04/13] fix(swift-sdk): import DashSDKFFI not SwiftDashSDKFFI --- .../PlatformWallet/PlatformWalletManagerShieldedFunding.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 9a25efba77..17911b31d8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -1,5 +1,5 @@ import Foundation -import SwiftDashSDKFFI +import DashSDKFFI /// Recipient entry for `shieldedFundFromAssetLock(...)`. /// From cb15ff82f4df6fffbf2a6689adfc36a23e8d7d44 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 17:53:16 +0700 Subject: [PATCH 05/13] test(dpp): signing tests for ShieldFromAssetLockTransition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the AddressFundingFromAssetLockTransition signing_tests pattern shipped with #3671. Four tests: 1. try_from_asset_lock_with_bundle_and_signer_produces_recoverable_compact_sig_v0 — exercises the V0 impl directly via a FixedKeySigner (key_wallet::signer::Signer); pins the 65-byte recoverable compact ECDSA signature shape that Type 18 expects on storage. 2. try_from_asset_lock_with_bundle_and_signer_via_outer_dispatcher — same call routed through the outer-enum version dispatcher in methods/mod.rs; covers the version-routing arm. 3. outer_dispatcher_rejects_unknown_serialization_version — synthesises a platform-version whose shield_from_asset_lock_state_transition.default_current_version is non-zero; pins the UnknownVersionMismatch error path so a future V1 introduction can't silently coerce to V0. 4. build_shield_from_asset_lock_transition_with_signer_end_to_end — full builder path with a real output-only Orchard bundle (TestProver builds the Halo 2 proving key on first call); this is the codepath the wallet orchestration uses in production. Bumps codecov patch coverage above the 50% threshold for the shielded-fund-from-asset-lock PR (the orchestration in rs-platform-wallet remains hard to unit-test without significant SDK mocking, so the DPP layer is where the bulk of the testable new code lives). Tests gated on (state-transition-signing + core_key_wallet), matching where the new method itself is gated. --- .../shield_from_asset_lock_transition/mod.rs | 6 + .../signing_tests.rs | 251 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs index 6c0ff2c8a7..19f4acede1 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/mod.rs @@ -1,6 +1,12 @@ pub mod fields; pub mod methods; mod proved; +#[cfg(all( + test, + feature = "state-transition-signing", + feature = "core_key_wallet" +))] +mod signing_tests; mod state_transition_estimated_fee_validation; mod state_transition_like; mod state_transition_validation; 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 new file mode 100644 index 0000000000..eabc914911 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -0,0 +1,251 @@ +//! Signing tests for `ShieldFromAssetLockTransition` (Type 18). +//! +//! Covers the new external-signer path +//! (`try_from_asset_lock_with_bundle_and_signer`) and exercises the +//! version dispatcher in `methods/mod.rs` plus the high-level builder +//! `build_shield_from_asset_lock_transition_with_signer`. +//! +//! The raw-key path (`try_from_asset_lock_with_bundle`) is already +//! covered by existing tests in `v0/mod.rs` and the byte-parity test +//! in `state_transition::mod` pins `sign_with_core_signer` against +//! `sign_by_private_key` — we don't re-derive that contract here. + +#![cfg(all( + test, + feature = "state-transition-signing", + feature = "core_key_wallet" +))] + +use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use crate::prelude::AssetLockProof; +use crate::shielded::builder::build_shield_from_asset_lock_transition_with_signer; +use crate::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; +use crate::state_transition::shield_from_asset_lock_transition::v0::ShieldFromAssetLockTransitionV0; +use crate::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; +use crate::state_transition::StateTransition; +use dashcore::OutPoint; +use platform_version::version::PlatformVersion; + +use async_trait::async_trait; +use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; +use key_wallet::bip32::DerivationPath; +use key_wallet::signer::{Signer as KwSigner, SignerMethod}; + +/// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how a +/// Swift KeychainSigner behaves: derive once, sign atomically. Path +/// is ignored — the wrapper holds exactly one key. Same pattern as +/// the `AddressFundingFromAssetLockTransition` signing test and the +/// byte-parity test in `state_transition::mod`. +#[derive(Debug)] +struct FixedKeySigner { + secret: SecretKey, + public: PublicKey, +} + +impl FixedKeySigner { + fn new(seed: [u8; 32]) -> Self { + let secp = Secp256k1::new(); + let secret = SecretKey::from_byte_array(&seed).expect("valid secret"); + let public = PublicKey::from_secret_key(&secp, &secret); + Self { secret, public } + } +} + +#[async_trait] +impl KwSigner for FixedKeySigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + let secp = Secp256k1::new(); + let msg = Message::from_digest(sighash); + Ok((secp.sign_ecdsa(&msg, &self.secret), self.public)) + } + + async fn public_key(&self, _path: &DerivationPath) -> Result { + Ok(self.public) + } +} + +fn make_chain_asset_lock_proof() -> AssetLockProof { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point: OutPoint::from([11u8; 36]), + }) +} + +fn extract_v0(state_transition: StateTransition) -> ShieldFromAssetLockTransitionV0 { + let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = + state_transition + else { + panic!("expected ShieldFromAssetLock V0 variant"); + }; + v0 +} + +#[tokio::test] +async fn try_from_asset_lock_with_bundle_and_signer_produces_recoverable_compact_sig_v0() { + // Exercises `ShieldFromAssetLockTransitionV0::try_from_asset_lock_with_bundle_and_signer` + // directly (no version dispatcher). Pins that the outer ECDSA signature + // produced via `sign_with_core_signer` is 65 bytes (recoverable compact + // shape), matching the raw-key path that Type 18 uses on storage. + let asset_lock_proof = make_chain_asset_lock_proof(); + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + + // Bundle fixture values — the trait method doesn't validate the + // Halo 2 proof or anchor, it only computes signable bytes and + // signs them, so any fixed-size payload works here. Real bundle + // construction is covered by the builder-level test below. + let actions: Vec = vec![]; + let value_balance = 1_000_000u64; + let anchor = [0u8; 32]; + let proof = vec![]; + let binding_signature = [0u8; 64]; + + let st = ShieldFromAssetLockTransitionV0::try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof, + &path, + &signer, + actions, + value_balance, + anchor, + proof, + binding_signature, + PlatformVersion::latest(), + ) + .await + .expect("transition should sign"); + + let v0 = extract_v0(st); + assert_eq!(v0.value_balance, value_balance); + assert_eq!( + v0.signature.len(), + 65, + "asset-lock signature must be 65-byte recoverable compact", + ); +} + +#[tokio::test] +async fn try_from_asset_lock_with_bundle_and_signer_via_outer_dispatcher() { + // Same call but routed through the outer-enum dispatcher in + // `methods/mod.rs` — pins that the version-routing path lands in + // the V0 impl and returns an equivalent transition. + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + + let st = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( + make_chain_asset_lock_proof(), + &path, + &signer, + vec![], + 500_000, + [0u8; 32], + vec![], + [0u8; 64], + PlatformVersion::latest(), + ) + .await + .expect("outer dispatch should succeed"); + + let v0 = extract_v0(st); + assert_eq!(v0.value_balance, 500_000); + assert_eq!(v0.signature.len(), 65); +} + +#[tokio::test] +async fn outer_dispatcher_rejects_unknown_serialization_version() { + // Synthesise a platform-version whose + // `shield_from_asset_lock_state_transition.default_current_version` + // is non-zero, and confirm the dispatcher surfaces + // `UnknownVersionMismatch` instead of silently coercing to V0. + // This guards the V0-only assumption baked into the dispatcher + // so a future V1 introduction can't accidentally route through + // the wrong impl without an explicit code change. + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + + let mut bad_version = PlatformVersion::latest().clone(); + bad_version + .dpp + .state_transition_serialization_versions + .shield_from_asset_lock_state_transition + .default_current_version = 99; + + let err = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( + make_chain_asset_lock_proof(), + &path, + &signer, + vec![], + 1, + [0u8; 32], + vec![], + [0u8; 64], + &bad_version, + ) + .await + .expect_err("unknown version must be rejected"); + + match err { + crate::ProtocolError::UnknownVersionMismatch { + method, received, .. + } => { + assert!( + method.contains("ShieldFromAssetLockTransition") + && method.contains("try_from_asset_lock_with_bundle_and_signer"), + "unexpected method: {method}", + ); + assert_eq!(received, 99); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[tokio::test] +async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() { + // Full builder path — constructs a real output-only Orchard + // bundle (via the TestProver, which builds the proving key on + // first call) and signs the outer ST with the external signer. + // This is the codepath the wallet orchestration uses in production. + use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; + + let recipient = test_orchard_address(); + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + let shield_amount = 50_000u64; + + let st = build_shield_from_asset_lock_transition_with_signer( + &recipient, + shield_amount, + make_chain_asset_lock_proof(), + &path, + &signer, + &TestProver, + [0u8; 36], + PlatformVersion::latest(), + ) + .await + .expect("builder should succeed"); + + let v0 = extract_v0(st); + assert_eq!( + v0.value_balance, shield_amount, + "builder must thread the shield amount into the transition's value_balance", + ); + assert_eq!( + v0.signature.len(), + 65, + "asset-lock signature must be 65-byte recoverable compact", + ); + assert!( + !v0.actions.is_empty(), + "output-only bundle must produce at least one Orchard action", + ); +} From 8d266de32f46eb2aa252f220a8587c32dd979c3a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 19:03:07 +0700 Subject: [PATCH 06/13] refactor(shielded): caller passes shield_amount, drop lookup helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: `lookup_asset_lock_value_credits` is unique to the shielded flow — identities and platform-addresses both take the caller-supplied `amount_duffs` and let Platform deduct its fee inside the transition. They never read the lock value at the wallet level. Type 18 is the odd one out because the Orchard `value_balance` is baked into the Halo 2 proof at build time, so the wallet has to know the shielded amount before signing. The cleaner answer is to push the responsibility back to the caller — same convention as the rest of the repo. API changes: - Wallet: `recipients: Vec<(OrchardAddress, Credits)>` (no Option; caller passes credits per recipient). - FFI: `platform_wallet_manager_shielded_fund_from_asset_lock` and `_resume` gain a `shield_amount_credits: u64` parameter; FFI passes `vec![(recipient, shield_amount_credits)]` to the wallet. - Swift: `ShieldedFundFromAssetLockRecipient.credits` becomes non-optional UInt64; caller-side preflight rejects zero amounts. Code removed: - `lookup_asset_lock_value_credits` (was reading from IS-proof output or the AssetLockManager's tracked lock list). - `shield_from_asset_lock_min_fee` (was reading the protocol fee constant). - The `CREDITS_PER_DUFF` import (no longer needed). `build_and_broadcast_shielded` retained per reviewer guidance — it's the de-duplication point between the initial submit and the IS→CL retry submit. --- .../src/shielded_send.rs | 14 +- .../wallet/shielded/fund_from_asset_lock.rs | 202 +++++------------- ...PlatformWalletManagerShieldedFunding.swift | 42 ++-- 3 files changed, 97 insertions(+), 161 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 2eb0005739..db4dc17446 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -337,9 +337,15 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// /// `account_index` selects the BIP44 Core account whose UTXOs /// fund the asset lock. `amount_duffs` is the L1 amount to lock. +/// `shield_amount_credits` is what enters the shielded pool — the +/// Orchard `value_balance` baked into the Halo 2 proof at build +/// time. The caller is responsible for the relationship +/// `amount_duffs * CREDITS_PER_DUFF ≥ shield_amount_credits + Platform fee`; +/// undersized locks fail at Platform submission. +/// /// `recipient_raw_43` is the single Orchard recipient (same shape /// `platform_wallet_manager_shielded_default_address` returns); it -/// receives the asset-lock value minus the protocol minimum fee. +/// receives `shield_amount_credits` credits. /// /// Multi-recipient is reserved for a future DPP-side Orchard /// multi-output bundle change; today the orchestration rejects @@ -360,6 +366,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( wallet_id_bytes: *const u8, account_index: u32, amount_duffs: u64, + shield_amount_credits: u64, recipient_raw_43: *const u8, core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { @@ -412,7 +419,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( amount_duffs, account_index, }, - vec![(recipient, None)], + vec![(recipient, shield_amount_credits)], &asset_lock_signer, &prover, None, @@ -450,6 +457,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset handle: Handle, wallet_id_bytes: *const u8, out_point: *const OutPointFFI, + shield_amount_credits: u64, recipient_raw_43: *const u8, core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { @@ -499,7 +507,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, }, - vec![(recipient, None)], + vec![(recipient, shield_amount_credits)], &asset_lock_signer, &prover, None, 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 b65e9307ab..522e00f449 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 @@ -7,9 +7,9 @@ //! ## Pipeline //! //! 1. **Pre-flight** — exactly-one recipient today (the multi-shape -//! `Vec<(OrchardAddress, Option)>` API is in place so -//! the caller signature doesn't change when DPP grows multi-output -//! Orchard bundles for Type 18; see [`validate_shielded_recipients`]). +//! `Vec<(OrchardAddress, Credits)>` API is in place so the caller +//! signature doesn't change when DPP grows multi-output Orchard +//! bundles for Type 18; see [`validate_shielded_recipients`]). //! 2. **Resolve funding** — delegate to the shared //! [`AssetLockManager::resolve_funding_with_is_timeout_fallback`]. //! 3. **Submit** — wrap the build-and-broadcast in @@ -27,7 +27,6 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::put_settings::PutSettings; use dpp::address_funds::OrchardAddress; -use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::fee::Credits; use dpp::prelude::AssetLockProof; use dpp::shielded::builder::{build_shield_from_asset_lock_transition_with_signer, OrchardProver}; @@ -54,13 +53,20 @@ impl PlatformWallet { /// builds a fresh asset lock from Core UTXOs; `FromExistingAssetLock` /// resumes from a tracked outpoint (after relaunch or a stuck /// broadcast). - /// * `recipients` — Map from recipient `OrchardAddress` to optional - /// credit amount. The shape mirrors the platform-address API for - /// future-compatibility with multi-output Orchard bundles, but - /// today the pre-flight enforces exactly one recipient and the - /// `Option` value is ignored — the single recipient - /// always receives the full asset-lock value minus the protocol - /// minimum fee. + /// * `recipients` — Recipient `OrchardAddress` + explicit credit + /// amount per entry. The shape mirrors the platform-address + /// `BTreeMap` API for future-compatibility + /// with multi-output Orchard bundles, but today the pre-flight + /// enforces exactly one recipient (Type 18's bundle builder is + /// single-output). + /// + /// Unlike platform-address funding, the caller passes the + /// credit amount explicitly. For Type 18 there is no + /// protocol-side `AddressFundsFeeStrategy`; the Orchard + /// `value_balance` (= `sum(recipient credits)`) is baked into + /// the Halo 2 proof at build time, and the asset-lock value + /// minus that covers the Platform fee. The caller is + /// responsible for sizing the L1 lock to cover both. /// * `asset_lock_signer` — External signer for the outer ECDSA /// signature on the state transition. The raw key never crosses /// the FFI boundary. @@ -71,7 +77,7 @@ impl PlatformWallet { pub async fn shielded_fund_from_asset_lock( &self, funding: AssetLockFunding, - recipients: Vec<(OrchardAddress, Option)>, + recipients: Vec<(OrchardAddress, Credits)>, asset_lock_signer: &AS, prover: P, settings: Option, @@ -130,38 +136,17 @@ impl PlatformWallet { } }; - // Step 3: compute the shield amount. - // - // For Type 18, the protocol does not deduct fees inside the - // transition (unlike address-funding's - // `AddressFundsFeeStrategy`) — the Orchard `value_balance` - // baked into the Halo 2 proof at build time *is* what enters - // the shielded pool, and the asset-lock value minus that - // covers the fee. - // - // Today (single recipient + value=None), the recipient gets - // `asset_lock_value − min_required_fee`. When DPP grows - // multi-output Orchard bundles for Type 18, this branches: - // explicit `Some(_)` amounts pass through; the `None` bucket - // receives the residual. - let asset_lock_value_credits = - lookup_asset_lock_value_credits(self, &proof, tracked_out_point.as_ref()).await?; - let min_fee = self.shield_from_asset_lock_min_fee()?; - let shield_amount = asset_lock_value_credits - .checked_sub(min_fee) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "asset lock value ({asset_lock_value_credits} credits) is below the \ - minimum required fee ({min_fee} credits) for ShieldFromAssetLock" - )) - })?; - if shield_amount == 0 { - return Err(PlatformWalletError::ShieldedBuildError( - "shield amount after fee is zero".to_string(), - )); - } - - let recipient = recipients.first().expect("preflight enforces len() == 1").0; + // Step 3: shield amount is whatever the caller specified. For + // Type 18, the Orchard `value_balance` is baked into the + // Halo 2 proof at build time, so the wallet does NOT compute + // it from the lock value the way address-funding's + // `AddressFundsFeeStrategy` does — the caller sizes the L1 + // lock to cover `value_balance + Platform fee`. Identities + // and platform-addresses take `amount_duffs` from the caller + // for the same reason (Platform handles their fee math + // inside the transition). + let (recipient, shield_amount) = + *recipients.first().expect("preflight enforces len() == 1"); // Step 4: submit. Two Platform-side fallback layers — matching // the address-funding sibling: CL-height-too-low retries bump @@ -257,86 +242,10 @@ impl PlatformWallet { } } - tracing::info!( - shield_amount, - asset_lock_value_credits, - min_fee, - "Shielded fund-from-asset-lock succeeded" - ); + tracing::info!(shield_amount, "Shielded fund-from-asset-lock succeeded"); Ok(()) } - - /// Minimum fee for a `ShieldFromAssetLock` (Type 18) state - /// transition, in credits. Read from - /// `dpp.state_transitions.identities.asset_locks` — - /// the same fee field Type 14 (address funding) reads. - fn shield_from_asset_lock_min_fee(&self) -> Result { - let pv = self.sdk.version(); - let asset_lock_base_cost_duffs = pv - .dpp - .state_transitions - .identities - .asset_locks - .required_asset_lock_duff_balance_for_processing_start_for_address_funding; - asset_lock_base_cost_duffs - .checked_mul(CREDITS_PER_DUFF) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "platform version min-fee constant overflowed credits conversion".to_string(), - ) - }) - } -} - -/// Look up the asset-lock value in credits. -/// -/// Preference order: -/// 1. If the proof is `Instant`, read directly from -/// `InstantAssetLockProof::output().value` — no manager lookup -/// needed; this also keeps the function callable in tests that -/// inject a synthetic IS proof. -/// 2. Otherwise (the IS-timeout-fallback path produced a CL proof -/// that doesn't carry the tx output), look up the tracked asset -/// lock by outpoint. -async fn lookup_asset_lock_value_credits( - wallet: &PlatformWallet, - proof: &AssetLockProof, - tracked_out_point: Option<&dashcore::OutPoint>, -) -> Result { - let duffs = match proof { - AssetLockProof::Instant(is) => { - let out = is.output().ok_or_else(|| { - PlatformWalletError::AddressSync( - "InstantAssetLockProof has no output at the indicated index".to_string(), - ) - })?; - out.value - } - AssetLockProof::Chain(_) => { - let op = tracked_out_point.ok_or_else(|| { - PlatformWalletError::AddressSync( - "ChainAssetLockProof but no tracked outpoint to look up value".to_string(), - ) - })?; - let locks = wallet.asset_locks.list_tracked_locks().await; - locks - .iter() - .find(|l| l.out_point == *op) - .map(|l| l.amount) - .ok_or_else(|| { - PlatformWalletError::AddressSync(format!( - "tracked asset lock {} not found in manager", - op - )) - })? - } - }; - duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "asset lock value ({duffs} duffs) overflowed credits conversion" - )) - }) } /// Build the Type 18 transition and broadcast-and-wait. @@ -382,17 +291,18 @@ where /// Pre-flight check for the recipient list. /// -/// Today: non-empty, exactly one recipient. The multi-shape -/// `Vec<(OrchardAddress, Option)>` API is exposed so the -/// caller signature is future-compatible — when DPP grows multi-output -/// Orchard bundles for Type 18, lifting this restriction is a -/// preflight-only change; no FFI / Swift / caller migration needed. +/// Today: non-empty, exactly one recipient, non-zero amount. The +/// multi-shape `Vec<(OrchardAddress, Credits)>` API is exposed so +/// the caller signature is future-compatible — when DPP grows +/// multi-output Orchard bundles for Type 18, lifting the +/// single-recipient restriction is a preflight-only change; no FFI +/// / Swift / caller migration needed. /// -/// Generic over `T` so unit tests can pass `(u8, Option)` -/// instead of constructing a curve-valid `OrchardAddress` for what -/// is really a length/cardinality check. +/// Generic over `T` so unit tests can pass `(u8, Credits)` instead +/// of constructing a curve-valid `OrchardAddress` for what is +/// really a length / cardinality check. pub(super) fn validate_shielded_recipients( - recipients: &[(T, Option)], + recipients: &[(T, Credits)], ) -> Result<(), PlatformWalletError> { if recipients.is_empty() { return Err(PlatformWalletError::AddressOperation( @@ -403,9 +313,9 @@ pub(super) fn validate_shielded_recipients( // for Type 18 (`build_output_only_bundle` currently builds a // single-output bundle; extending would also affect the Shield // Type 15 path that shares it), drop this restriction. The - // semantics will become: explicit `Some(_)` amounts pass through - // unchanged; the (exactly one) `None` bucket receives the - // residual `asset_lock_value − sum(explicit) − fee`. + // semantics will become: each recipient's `Credits` flows into + // its Orchard output, and the bundle's `value_balance` becomes + // `sum(credits)`. if recipients.len() != 1 { return Err(PlatformWalletError::AddressOperation(format!( "shielded_fund_from_asset_lock currently supports exactly one recipient \ @@ -413,6 +323,11 @@ pub(super) fn validate_shielded_recipients( recipients.len() ))); } + if recipients[0].1 == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "shield amount must be > 0".to_string(), + )); + } Ok(()) } @@ -428,14 +343,14 @@ mod tests { #[test] fn validate_rejects_empty_recipients() { - let v: Vec<(u8, Option)> = Vec::new(); + let v: Vec<(u8, Credits)> = Vec::new(); let err = validate_shielded_recipients(&v).expect_err("empty must reject"); assert!(format!("{err}").contains("at least one recipient")); } #[test] fn validate_rejects_multi_recipient_for_now() { - let v: Vec<(u8, Option)> = vec![(1, None), (2, Some(100))]; + let v: Vec<(u8, Credits)> = vec![(1, 100), (2, 200)]; let err = validate_shielded_recipients(&v).expect_err("multi-recipient must reject (TODO)"); let msg = format!("{err}"); assert!( @@ -445,17 +360,18 @@ mod tests { } #[test] - fn validate_accepts_single_recipient() { - let v: Vec<(u8, Option)> = vec![(0, None)]; - validate_shielded_recipients(&v).expect("single recipient must pass"); + fn validate_rejects_zero_amount() { + let v: Vec<(u8, Credits)> = vec![(0, 0)]; + let err = validate_shielded_recipients(&v).expect_err("zero amount must reject"); + assert!( + format!("{err}").contains("must be > 0"), + "unexpected error: {err}" + ); } #[test] - fn validate_accepts_single_recipient_with_some_amount() { - // The Some(_) value is ignored today (single-recipient case - // always receives the residual), but the shape stays valid - // so the caller signature is future-compatible. - let v: Vec<(u8, Option)> = vec![(0, Some(500_000))]; + fn validate_accepts_single_recipient_with_amount() { + let v: Vec<(u8, Credits)> = vec![(0, 500_000)]; validate_shielded_recipients(&v).expect("single recipient with amount must pass"); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 17911b31d8..723b724368 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -3,23 +3,23 @@ import DashSDKFFI /// Recipient entry for `shieldedFundFromAssetLock(...)`. /// -/// The Rust-side API today enforces exactly one recipient (the -/// `Option` is ignored — the single recipient always -/// receives the full asset-lock value minus the protocol minimum -/// fee). The multi-shape signature is exposed here so the call -/// site doesn't have to change when DPP grows multi-output Orchard -/// bundles for Type 18. +/// The Rust-side API today enforces exactly one recipient — Type +/// 18's Orchard bundle builder is single-output; multi-recipient +/// lands when DPP grows multi-output bundles. The list shape is +/// exposed here so the call site doesn't have to change when that +/// happens. public struct ShieldedFundFromAssetLockRecipient: Sendable { /// Raw 43-byte Orchard payment address (11-byte diversifier + /// 32-byte pk_d). Same shape `platform_wallet_manager_shielded_default_address` /// returns and `shieldedTransfer` consumes. public let recipientRaw43: Data - /// Explicit credit amount, or `nil` to receive the remainder. - /// Today the value is ignored — only the address is consumed. - public let credits: UInt64? + /// Credit amount this recipient receives. Becomes the Orchard + /// `value_balance` baked into the Halo 2 proof at build time + /// (or its share, once multi-recipient lands). + public let credits: UInt64 - public init(recipientRaw43: Data, credits: UInt64? = nil) { + public init(recipientRaw43: Data, credits: UInt64) { self.recipientRaw43 = recipientRaw43 self.credits = credits } @@ -52,11 +52,14 @@ extension PlatformWalletManager { /// `bindShielded` uses to look up the bound subwallet). /// - fundingAccountIndex: BIP44 Core account whose UTXOs fund /// the asset lock. - /// - amountDuffs: Amount to lock in Core duffs. The shielded - /// pool receives `amountDuffs * CREDITS_PER_DUFF` minus the - /// protocol minimum fee. - /// - recipients: Destination addresses (exactly one today). - /// Preflight rejects empty or multi-recipient lists. + /// - amountDuffs: L1 amount to lock in Core duffs. Must be + /// large enough to cover `recipients.credits + Platform fee`; + /// undersized locks fail at Platform submission. + /// - recipients: Destination addresses with explicit credit + /// amounts (exactly one entry today; preflight rejects + /// empty or multi-recipient lists). Each recipient's + /// `credits` becomes the Orchard `value_balance` for that + /// output. public func shieldedFundFromAssetLock( walletId: Data, fundingAccountIndex: UInt32, @@ -70,6 +73,7 @@ extension PlatformWalletManager { let handle = self.handle let recipientRaw43 = recipients[0].recipientRaw43 + let shieldAmountCredits = recipients[0].credits // Constructed on the calling actor so it lives for the // entire detached Task. Released after `withExtendedLifetime` // returns. See `ManagedPlatformAddressWallet.fundFromAssetLock` @@ -103,6 +107,7 @@ extension PlatformWalletManager { widPtr, fundingAccountIndex, amountDuffs, + shieldAmountCredits, recipientPtr, coreSigner.handle ) @@ -151,6 +156,7 @@ extension PlatformWalletManager { let handle = self.handle let recipientRaw43 = recipients[0].recipientRaw43 + let shieldAmountCredits = recipients[0].credits let coreSigner = MnemonicResolver() try await Task.detached(priority: .userInitiated) { @@ -193,6 +199,7 @@ extension PlatformWalletManager { handle, widPtr, &outPoint, + shieldAmountCredits, recipientPtr, coreSigner.handle ) @@ -242,6 +249,11 @@ extension PlatformWalletManager { "ShieldedFundFromAssetLockRecipient.recipientRaw43 must be exactly 43 bytes (got \(r.recipientRaw43.count))" ) } + guard r.credits > 0 else { + throw PlatformWalletError.invalidParameter( + "ShieldedFundFromAssetLockRecipient.credits must be > 0" + ) + } } } } From 8494ccfa273611018ba1bf11561315b462aa8e28 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 20:19:11 +0700 Subject: [PATCH 07/13] docs+style(shielded): pin CL-retry OsRng assumption + explicit unsafe blocks Two review-driven cleanups: 1. Add a colocated comment in shielded_fund_from_asset_lock noting that submit_with_cl_height_retry's user_fee_increase bump is a no-op for Type 18 (set_user_fee_increase is pinned to 0). Retries still avoid Tenderdash's invalid-tx cache because build_output_only_bundle draws fresh randomness from OsRng on every call; if the prover is ever made deterministic, an explicit diversifier would be needed. 2. Wrap MnemonicResolverCoreSigner::new calls in explicit unsafe { ... } blocks at both FFI entry points (fresh-build and resume), matching the Type 14 sibling in platform_addresses/fund_from_asset_lock.rs. Compiles today because Rust 2021 makes unsafe-fn bodies implicit unsafe contexts, but the explicit block preserves the audit trail and keeps the 2024-edition migration (unsafe_op_in_unsafe_fn) a no-op. --- .../src/shielded_send.rs | 26 ++++++++++++------- .../wallet/shielded/fund_from_asset_lock.rs | 14 ++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index db4dc17446..44c10319bf 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -407,11 +407,13 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( let result = block_on_worker(async move { // SAFETY: see the fn-level safety doc — the resolver handle // is pinned alive for the duration of this FFI call. - let asset_lock_signer = MnemonicResolverCoreSigner::new( - core_signer_addr as *mut MnemonicResolverHandle, - wallet_id, - network, - ); + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; let prover = CachedOrchardProver::new(); wallet .shielded_fund_from_asset_lock( @@ -496,11 +498,15 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset let core_signer_addr = core_signer_handle as usize; let result = block_on_worker(async move { - let asset_lock_signer = MnemonicResolverCoreSigner::new( - core_signer_addr as *mut MnemonicResolverHandle, - wallet_id, - network, - ); + // SAFETY: see the fn-level safety doc — the resolver handle + // is pinned alive for the duration of this FFI call. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; let prover = CachedOrchardProver::new(); wallet .shielded_fund_from_asset_lock( 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 522e00f449..c8aceb0f42 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 @@ -153,6 +153,20 @@ impl PlatformWallet { // `user_fee_increase` (bypasses Tenderdash's invalid-tx hash // cache) and IS-lock rejection triggers an IS→CL upgrade on // the same outpoint. + // + // Subtle: `ShieldFromAssetLockTransition::set_user_fee_increase` + // is a no-op (pinned at `state_transition::mod`'s + // `test_shield_from_asset_lock_user_fee_increase_is_zero_and_setter_noop`), + // so the wrapper's bump cannot directly diversify the ST hash + // here the way it does for address-funding. Retries still avoid + // Tenderdash's invalid-tx cache because `build_output_only_bundle` + // draws fresh randomness from `OsRng` on every call + // (`packages/rs-dpp/src/shielded/builder/mod.rs`), so a re-built + // bundle has a different Halo 2 proof and therefore a different + // signable hash. If the prover is ever made deterministic for + // reproducibility, this orchestration would need an explicit + // diversifier (e.g. a memo-derived bump) to keep CL-height + // retries from silently degrading into duplicate-hash submits. let proof_out_point = out_point_from_proof(&proof); let sdk = self.sdk.clone(); match submit_with_cl_height_retry(settings, |s| { From 43845ed0846a577ccea6596ec0a2f9cbd4e18b5f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 20:50:31 +0700 Subject: [PATCH 08/13] fix(shielded): use AssetLockShieldedAddressTopUp + sizing preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes from PR review: 1. **Blocking**: pass AssetLockShieldedAddressTopUp (not AssetLockAddressTopUp) into resolve_funding_with_is_timeout_fallback. The variant is load-bearing in three places: build.rs selects the BIP44 source account (asset_lock_shielded_address_topup vs asset_lock_address_topup), sync/recovery.rs keys derivation re- lookup off the stored funding type, and manager/accessors.rs maps the variant to fundingTypeRaw 4 vs 5 (the public persistence/UI tag). As written the shielded flow was deriving from the platform-address bucket and would conflate with Type 14 in persistence/UI; resume would re-derive against the wrong account. 2. Pre-flight sizing sanity check on the FromWalletBalance path: refuse obviously-undersized configurations (amount_duffs * CREDITS_PER_DUFF < shield_amount + min_fee) BEFORE broadcasting the single-use asset-lock tx or building the ~30s Halo 2 proof. Catches the common-case caller mistake (forgot the fee, or duffs/credits scale slip) before any L1 funds are committed. Best-effort only — Platform's real fee depends on state we don't track. We just verify the lower bound. Resume path skips the check (lock value is already pinned on-chain and looking it up would re-introduce the asset-lock-value lookup we deliberately removed in 8d266de32f). This re-uses ShieldFromAssetLock::calculate_min_required_fee's underlying platform_version constant (required_asset_lock_duff_balance_for_processing_start_for_address_funding) — same constant Platform's StateTransitionEstimatedFeeValidation reads for Type 18. --- .../wallet/shielded/fund_from_asset_lock.rs | 70 +++++++++++++++---- 1 file changed, 55 insertions(+), 15 deletions(-) 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 c8aceb0f42..307a4308b9 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 @@ -27,6 +27,7 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::put_settings::PutSettings; use dpp::address_funds::OrchardAddress; +use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::fee::Credits; use dpp::prelude::AssetLockProof; use dpp::shielded::builder::{build_shield_from_asset_lock_transition_with_signer, OrchardProver}; @@ -91,13 +92,64 @@ impl PlatformWallet { // build, ~30s, only to reject downstream). validate_shielded_recipients(&recipients)?; + // Caller specifies shield_amount per recipient (Type 18's + // Orchard `value_balance` is baked into the Halo 2 proof at + // build time, unlike address-funding's protocol-level fee + // strategy). Identities and platform-addresses take + // `amount_duffs` from the caller for the same reason — + // Platform handles their fee math inside the transition, + // shielded can't. + let (recipient, shield_amount) = + *recipients.first().expect("preflight enforces len() == 1"); + + // Sizing sanity check on the FromWalletBalance path: refuse + // obviously-undersized configurations BEFORE we broadcast + // the asset-lock tx (single-use L1 funds) or spend ~30s + // building a Halo 2 proof Platform would reject. + // + // Best-effort, not authoritative — Platform's real fee + // depends on state we don't track. We only check the lower + // bound: lock_credits >= shield_amount + min_required_fee. + // The resume path takes an existing tracked outpoint; the + // lock value is already pinned on-chain, and checking it + // here would re-introduce the asset-lock-value lookup we + // deliberately removed in favour of caller-supplied + // `shield_amount`. + if let AssetLockFunding::FromWalletBalance { amount_duffs, .. } = &funding { + let lock_credits = (*amount_duffs).saturating_mul(CREDITS_PER_DUFF); + let min_fee_duffs = self + .sdk + .version() + .dpp + .state_transitions + .identities + .asset_locks + .required_asset_lock_duff_balance_for_processing_start_for_address_funding; + let min_fee_credits = min_fee_duffs.saturating_mul(CREDITS_PER_DUFF); + let required = shield_amount.saturating_add(min_fee_credits); + if lock_credits < required { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "asset lock ({lock_credits} credits, from {amount_duffs} duffs) cannot cover \ + shield_amount ({shield_amount}) + protocol min fee ({min_fee_credits}); \ + refusing to broadcast a single-use asset lock and build a ~30s Halo 2 proof \ + for a submission Platform would reject" + ))); + } + } + // Single-flight: serialise shield-class operations on this // wallet so two concurrent calls can't race the asset-lock // tracker into a half-consumed state. let _shield_guard = self.shield_guard.lock().await; - // Step 2: resolve funding. `AssetLockAddressTopUp` selects the - // BIP44 funding family for the Core asset-lock tx; + // Step 2: resolve funding. `AssetLockShieldedAddressTopUp` + // selects the BIP44 funding family dedicated to shielded + // top-ups (`accounts.asset_lock_shielded_address_topup` — + // distinct from the platform-address bucket Type 14 uses); + // see `wallet/asset_lock/build.rs` for the source-account + // selection, `sync/recovery.rs` for resume-time key re- + // derivation, and `manager/accessors.rs` for the + // persistence/UI tag (`fundingTypeRaw == 5`). // `destination_index = 0` is unused for this funding type. let ResolvedFunding { proof, @@ -107,7 +159,7 @@ impl PlatformWallet { .asset_locks .resolve_funding_with_is_timeout_fallback( funding, - AssetLockFundingType::AssetLockAddressTopUp, + AssetLockFundingType::AssetLockShieldedAddressTopUp, /* destination_index */ 0, asset_lock_signer, ) @@ -136,18 +188,6 @@ impl PlatformWallet { } }; - // Step 3: shield amount is whatever the caller specified. For - // Type 18, the Orchard `value_balance` is baked into the - // Halo 2 proof at build time, so the wallet does NOT compute - // it from the lock value the way address-funding's - // `AddressFundsFeeStrategy` does — the caller sizes the L1 - // lock to cover `value_balance + Platform fee`. Identities - // and platform-addresses take `amount_duffs` from the caller - // for the same reason (Platform handles their fee math - // inside the transition). - let (recipient, shield_amount) = - *recipients.first().expect("preflight enforces len() == 1"); - // 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 From 39282af208727a53c77659f123ebc8b71fe48908 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 21:58:14 +0700 Subject: [PATCH 09/13] feat(example-app): UI for shielded fund-from-asset-lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the platform-address funding UI shipped with #3671 for the new shielded flow (Type 18, ShieldFromAssetLockTransition): - ShieldedFundFromAssetLockController — per-slot phase machine (.idle / .inFlight / .completed / .failed). Keyed on (walletId, recipientRaw43); no platformAccountIndex because shielded recipients are external Orchard addresses, not allocated from a wallet account. Phase .completed carries no balance payload (FFI returns Void — note arrives via next sync). - ShieldedFundFromAssetLockCoordinator — singleton hub keyed by the same composite, with the same 30s retention sweep for .completed rows and indefinite retention for .failed rows until user dismissal. - PlatformWalletManager extension — objc-associated-object hook matching the sibling pattern; per-manager coordinator stays example-app-only state. - ShieldedFundFromAssetLockProgressView + Section — 5-step UI. Step 5 is meaningfully longer than the address-funding sibling because Type 18 builds a ~30s Halo 2 proof inside the FFI; the step-5 footer calls that out so users don't think the app is hung. PersistentAssetLock filter on fundingTypeRaw == 5. - ShieldedFundFromAssetLockView — Form with Core funding picker, recipient (defaults to wallet's own shielded default via shieldedDefaultAddress; overridable via 86-char raw hex input; bech32m parsing not wired yet), L1 amount, shield amount (auto-filled from L1 - 1mDASH conservative fee buffer, editable). Single-flight via the coordinator. Resume support shared with the same view in resume mode. WalletDetailView gets a new `+` affordance on the Shielded Balance row that opens the new sheet (parallel to the existing Platform Balance row `+`). BalanceCardView gains an onFundShielded callback alongside onFundPlatform. What's deliberately NOT mirrored: - Per-account recipient back-fill — shielded asset-lock rows don't carry a recipient stamp because the recipient is an external Orchard address, not allocated from a wallet account. - Pending Shielded Top Ups list view — deferred; the coordinator + per-slot controller machinery supports it but the surface isn't wired into WalletDetailView yet (the existing PendingPlatformFundFromAssetLocksList is address-funding-specific). Files use Xcode 16 filesystem-synchronized groups so no .pbxproj edits are needed. --- .../Core/Views/WalletDetailView.swift | 38 +- ...ShieldedFundFromAssetLockCoordinator.swift | 38 ++ .../ShieldedFundFromAssetLockController.swift | 138 ++++ ...ShieldedFundFromAssetLockCoordinator.swift | 154 +++++ ...hieldedFundFromAssetLockProgressView.swift | 378 +++++++++++ .../Views/ShieldedFundFromAssetLockView.swift | 635 ++++++++++++++++++ 6 files changed, 1375 insertions(+), 6 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+ShieldedFundFromAssetLockCoordinator.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 723e321b05..f11ee3e723 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -29,6 +29,7 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false @State private var showFundPlatformAddress = false + @State private var showShieldFromAssetLock = false /// Set by `PendingPlatformFundFromAssetLocksList`'s Resume tap. @State private var resumingAssetLock: PersistentAssetLock? @@ -83,9 +84,11 @@ struct WalletDetailView: View { .padding(.top, 8) // Balance Card - BalanceCardView(wallet: wallet) { - showFundPlatformAddress = true - } + BalanceCardView( + wallet: wallet, + onFundPlatform: { showFundPlatformAddress = true }, + onFundShielded: { showShieldFromAssetLock = true } + ) .padding() // Action Buttons @@ -202,6 +205,9 @@ struct WalletDetailView: View { .sheet(item: $resumingAssetLock) { lock in FundFromAssetLockPlatformAddressView(wallet: wallet, resumeFromLock: lock) } + .sheet(isPresented: $showShieldFromAssetLock) { + ShieldedFundFromAssetLockView(wallet: wallet) + } .onAppear { appUIState.showWalletsSyncDetails = false // Repoint the singleton ShieldedService at THIS wallet — @@ -700,6 +706,10 @@ struct BalanceCardView: View { /// `nil` hides the affordance entirely (e.g. for read-only /// surfaces). var onFundPlatform: (() -> Void)? + /// Same shape as `onFundPlatform`, for the Shielded Balance row. + /// Opens the Core L1 → shielded-pool funding sheet + /// (`ShieldedFundFromAssetLockView`, Type 18). + var onFundShielded: (() -> Void)? @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @EnvironmentObject var shieldedService: ShieldedService @@ -708,9 +718,14 @@ struct BalanceCardView: View { @Query private var addressBalances: [PersistentPlatformAddress] @Query private var syncStates: [PersistentPlatformAddressesSyncState] - init(wallet: PersistentWallet, onFundPlatform: (() -> Void)? = nil) { + init( + wallet: PersistentWallet, + onFundPlatform: (() -> Void)? = nil, + onFundShielded: (() -> Void)? = nil + ) { self.wallet = wallet self.onFundPlatform = onFundPlatform + self.onFundShielded = onFundShielded let walletId = wallet.walletId let walletNetworkRaw = (wallet.network ?? .testnet).rawValue _addressBalances = Query( @@ -791,13 +806,24 @@ struct BalanceCardView: View { } ) - // Shielded Balance row + // Shielded Balance row — mirrors the Platform + // Balance row's trailing `+` affordance. When + // `onFundShielded` is wired the user can open the + // Core L1 → shielded-pool funding sheet (Type 18, + // `ShieldFromAssetLockTransition`). WalletBalanceRow( label: "Shielded Balance", amount: shieldedService.shieldedBalance, color: .purple, unit: .credits, - showSyncIndicator: shieldedService.isSyncing + showSyncIndicator: shieldedService.isSyncing, + trailingAction: onFundShielded.map { fund in + WalletBalanceRow.TrailingAction( + systemImage: "plus.circle.fill", + accessibilityLabel: "Shield from Core Asset Lock", + action: fund + ) + } ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+ShieldedFundFromAssetLockCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+ShieldedFundFromAssetLockCoordinator.swift new file mode 100644 index 0000000000..335d21eb0d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+ShieldedFundFromAssetLockCoordinator.swift @@ -0,0 +1,38 @@ +import Foundation +import ObjectiveC +import SwiftDashSDK + +/// Per-manager `ShieldedFundFromAssetLockCoordinator` accessor. +/// Mirrors the [`addressFundFromAssetLockCoordinator`] +/// (PlatformWalletManager.addressFundFromAssetLockCoordinator) shape +/// — lazy-initialized on first access and lifetime-tied to the +/// `PlatformWalletManager` instance via an `objc_getAssociatedObject` +/// slot. +/// +/// Why this shape: the coordinator is example-app-only state (it +/// stores `ShieldedFundFromAssetLockController` instances, which live +/// in the app, not the SDK). The associated-object hook keeps the +/// call site clean while leaving the SDK module untouched. +@MainActor +extension PlatformWalletManager { + private static var shieldedFundFromAssetLockCoordinatorKey: UInt8 = 0 + + /// Per-manager shielded-funding coordinator. Created on first + /// access; subsequent reads return the same instance. + var shieldedFundFromAssetLockCoordinator: ShieldedFundFromAssetLockCoordinator { + if let existing = objc_getAssociatedObject( + self, + &PlatformWalletManager.shieldedFundFromAssetLockCoordinatorKey + ) as? ShieldedFundFromAssetLockCoordinator { + return existing + } + let fresh = ShieldedFundFromAssetLockCoordinator() + objc_setAssociatedObject( + self, + &PlatformWalletManager.shieldedFundFromAssetLockCoordinatorKey, + fresh, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return fresh + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift new file mode 100644 index 0000000000..d86e1ac692 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift @@ -0,0 +1,138 @@ +import Foundation +import SwiftDashSDK + +/// Per-slot state owned by a single shielded fund-from-asset-lock +/// attempt. +/// +/// Mirrors [`AddressFundFromAssetLockController`] for the Type 18 +/// (`ShieldFromAssetLockTransition`) flow. One controller is created +/// per `(walletId, recipientRaw43)` slot when the user submits +/// `ShieldedFundFromAssetLockView`. The controller owns the in-flight +/// `Task`, exposes its current `phase` via `@Published`, and survives +/// view dismissal via `ShieldedFundFromAssetLockCoordinator` on +/// `PlatformWalletManager`. +/// +/// Unlike address-funding, shielded doesn't carry a +/// `platformAccountIndex` in the key — recipients are external +/// Orchard payment addresses (43 raw bytes) that aren't allocated +/// from a wallet account. Single-flighting is keyed on the recipient +/// alone: a wallet can fund many distinct shielded recipients +/// concurrently, but the same recipient can only have one in-flight +/// shield at a time. +@MainActor +final class ShieldedFundFromAssetLockController: ObservableObject { + enum Phase: Equatable { + /// Pre-submit. The controller exists but `submit` hasn't + /// fired yet. Not surfaced by the progress view (the view + /// only opens after a submit). + case idle + /// Steps 1-3 inclusive: the FFI shield call is in flight. + /// Stage within this phase is read from the matching + /// `PersistentAssetLock.statusRaw` row. + case inFlight + /// Step 4 / terminal: the shield ST has been accepted by + /// Platform. The Orchard note arrives via the next shielded + /// sync — the FFI call returns `Void`, so unlike the + /// address-funding sibling there's no proof-attested + /// balance to surface here. + case completed + /// Failure terminal state. Message is shown inline in the + /// progress view's step 4; the row stays in the + /// coordinator's map until the user dismisses it manually. + case failed(String) + + /// Whether the controller is currently holding its slot. + /// Used by the Resumable Funding surface to hide orphan + /// asset locks whose slot is mid-flight — otherwise the + /// same lock could appear in both Pending and Resumable + /// lists during the broadcast-to-success window, letting + /// the user race a duplicate Resume tap against the + /// original FFI call. + var isActive: Bool { + switch self { + case .inFlight: + return true + case .idle, .completed, .failed: + return false + } + } + } + + /// Current phase. Updates flow: + /// `.idle` → `.inFlight` (submit) → + /// `.completed | .failed(message)`. + @Published private(set) var phase: Phase = .idle + + /// Wallet this controller is bound to. Stored so the coordinator + /// and the progress view can filter `PersistentAssetLock` rows + /// by `(walletId, fundingTypeRaw == AssetLockShieldedAddressTopUp)`. + let walletId: Data + + /// 43-byte raw Orchard payment address (11-byte diversifier + + /// 32-byte pk_d). Composite-key component so two concurrent + /// shields to different recipients on the same wallet don't + /// collide. + let recipientRaw43: Data + + /// Caller-specified shielded credits — the Orchard + /// `value_balance` baked into the Halo 2 proof at build time. + /// Surfaced in the terminal banner so the user can confirm what + /// they shielded. + let shieldAmountCredits: UInt64 + + /// Timestamp of the most recent `submit` call. Used by the + /// coordinator's TTL-based retention policy (`.completed` rows + /// purge ~30s after the success transition). + private(set) var lastSubmittedAt: Date? + + /// Active funding task. Holds a reference so the coordinator's + /// stash retains the work until completion; cancellation isn't + /// wired today (the FFI call doesn't yet support clean abort). + private var task: Task? + + init(walletId: Data, recipientRaw43: Data, shieldAmountCredits: UInt64) { + self.walletId = walletId + self.recipientRaw43 = recipientRaw43 + self.shieldAmountCredits = shieldAmountCredits + } + + /// Submit the funding. Defensively rejects any phase that + /// shouldn't fire a fresh FFI call: + /// - `.inFlight`: a second FFI call would race the first + /// (and burn the same asset lock twice). + /// - `.completed`: re-submitting after success would flip the + /// UI from "Done" back to a spinner before failing on the + /// consumed lock. + /// `.idle` and `.failed` are allowed — the coordinator drives + /// the legitimate-restart flow through them (a user retries a + /// failure via `failed → submit`). + /// + /// `body` performs the actual FFI call. It runs detached on a + /// background priority. Unlike the address-funding sibling, the + /// FFI returns `Void` — the shielded note arrives via the next + /// sync, not from the broadcast call — so the controller flips + /// `phase` to `.completed` (no balance payload) / `.failed` + /// accordingly. + func submit(body: @escaping () async throws -> Void) { + switch phase { + case .idle, .failed: + break + case .inFlight, .completed: + return + } + phase = .inFlight + lastSubmittedAt = Date() + task = Task { [weak self] in + do { + try await body() + await MainActor.run { + self?.phase = .completed + } + } catch { + await MainActor.run { + self?.phase = .failed(error.localizedDescription) + } + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift new file mode 100644 index 0000000000..152d02ece2 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift @@ -0,0 +1,154 @@ +import Foundation +import SwiftDashSDK + +/// Singleton hub for in-flight shielded fund-from-asset-lock attempts, +/// hosted on `PlatformWalletManager` so funds survive view dismissal +/// and network-toggle pressure. +/// +/// Mirrors [`AddressFundFromAssetLockCoordinator`] for Type 18 +/// (`ShieldFromAssetLockTransition`). Keyed by +/// `(walletId, recipientRaw43)` — a wallet can shield to many +/// distinct Orchard recipients concurrently, but the single-flight +/// invariant prevents a user from double-tapping the same recipient +/// during the asset-lock + Halo 2 proof window (~30s). +@MainActor +final class ShieldedFundFromAssetLockCoordinator: ObservableObject { + /// Composite key — `walletId` is 32 raw bytes; `recipientRaw43` + /// is the 43-byte raw Orchard payment address (11-byte + /// diversifier + 32-byte pk_d). + struct SlotKey: Hashable { + let walletId: Data + let recipientRaw43: Data + } + + /// Active controllers keyed by slot. Stored as `@Published` so + /// the "Pending Shielded Top Ups" row on the Wallet Detail + /// screen can observe map mutations via `objectWillChange`. + @Published private(set) var controllers: [SlotKey: ShieldedFundFromAssetLockController] = [:] + + /// True when at least one slot is currently in flight (phase + /// `.inFlight`). Used by the network toggle's `.disabled(_:)` + /// modifier — switching testnet ↔ mainnet mid-flight tears + /// down the FFI manager and would abort the in-flight call. + var hasInFlightFundings: Bool { + controllers.contains { _, controller in + if case .inFlight = controller.phase { return true } + return false + } + } + + /// Look up the controller for a slot if one exists. Returns + /// `nil` when there's no active funding for the slot — callers + /// use that to decide whether to spawn a new controller or + /// reuse the existing one. + func controller( + walletId: Data, + recipientRaw43: Data + ) -> ShieldedFundFromAssetLockController? { + controllers[SlotKey(walletId: walletId, recipientRaw43: recipientRaw43)] + } + + /// Snapshot of every active controller, sorted by recency of + /// last submit (most recent first). Used by the "Pending + /// Shielded Funding" row so dismissed-but-still-running flows + /// remain reachable. + func activeControllers() -> [ShieldedFundFromAssetLockController] { + controllers.values.sorted { lhs, rhs in + (lhs.lastSubmittedAt ?? .distantPast) > (rhs.lastSubmittedAt ?? .distantPast) + } + } + + /// Start a funding for the slot, or reuse an existing controller + /// if one is already in flight. Returns the controller for + /// `ShieldedFundFromAssetLockView` to bind a progress section + /// against. + /// + /// Single-flighting is enforced here at the coordinator level — + /// the controller's `submit()` only guards within its own phase + /// machine, so without a phase check before fresh-slot creation + /// a second tap during the FFI window would race two FFI calls + /// for the same recipient + asset lock. + func startFunding( + walletId: Data, + recipientRaw43: Data, + shieldAmountCredits: UInt64, + body: @escaping () async throws -> Void + ) -> ShieldedFundFromAssetLockController { + let key = SlotKey(walletId: walletId, recipientRaw43: recipientRaw43) + if let existing = controllers[key] { + switch existing.phase { + case .inFlight, .completed: + // Active or just-completed — don't re-enter. + // Returning the existing controller lets the caller + // bind to its progress / terminal state without + // disrupting it. + return existing + case .idle, .failed: + // Legitimate restart paths. + existing.submit(body: body) + // No retention sweep here — the slot is sticky on + // .failed (we want the user to see + dismiss the + // error) and a duplicate sweep on retry would + // spawn a second 30s poll Task against the same + // controller. Sweep was already scheduled when the + // controller was first created. + return existing + } + } + let controller = ShieldedFundFromAssetLockController( + walletId: walletId, + recipientRaw43: recipientRaw43, + shieldAmountCredits: shieldAmountCredits + ) + controllers[key] = controller + controller.submit(body: body) + scheduleRetentionSweep(key: key, controller: controller) + return controller + } + + /// Manually drop a controller from the map. Used by the UI's + /// "Dismiss" action on a `.failed` row (failures stay + /// indefinitely until acknowledged so the user can read the + /// error). + func dismiss(walletId: Data, recipientRaw43: Data) { + let key = SlotKey(walletId: walletId, recipientRaw43: recipientRaw43) + controllers.removeValue(forKey: key) + } + + // MARK: - Retention sweep + + /// Auto-purge `.completed` controllers ~30s after the success + /// transition so the wallet's Pending list doesn't accumulate + /// stale rows. `.failed` controllers stay indefinitely until + /// the user dismisses them. Same shape as the address-funding + /// sibling. + private func scheduleRetentionSweep( + key: SlotKey, + controller: ShieldedFundFromAssetLockController + ) { + Task { [weak self, weak controller] in + guard let controller = controller else { return } + var completedAt: Date? + while !Task.isCancelled { + let phase = await MainActor.run { controller.phase } + switch phase { + case .completed: + if completedAt == nil { + completedAt = Date() + } else if let at = completedAt, + Date().timeIntervalSince(at) >= 30 { + await MainActor.run { + _ = self?.controllers.removeValue(forKey: key) + } + return + } + case .failed: + return + default: + completedAt = nil + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift new file mode 100644 index 0000000000..fe02bb7cf5 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift @@ -0,0 +1,378 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Embeddable 5-step progress section for a shielded +/// fund-from-asset-lock flow. Mirrors +/// [`AddressFundFromAssetLockProgressSection`] but for Type 18 +/// (`ShieldFromAssetLockTransition`). +/// +/// Step mapping: +/// +/// 1. Building asset-lock tx → activeLock `statusRaw == 0` +/// 2. Broadcasting → activeLock `statusRaw == 1` and +/// < `broadcastingWindow` since +/// the row's `updatedAt` +/// 3. Waiting for InstantSend proof → activeLock `statusRaw == 1` and +/// between `broadcastingWindow` +/// and `instantLockTimeout` +/// 4. Waiting for ChainLock proof → activeLock `statusRaw == 1` and +/// >= `instantLockTimeout` (Rust +/// side has fallen back to CL); +/// also done when +/// `statusRaw == 3` (CL-locked). +/// 5. Shielding (Halo 2 + submit) → activeLock `statusRaw ∈ {2, 3}` +/// AND controller still `.inFlight` +/// +/// Step 5 is meaningfully longer than its address-funding sibling +/// because Type 18 builds a ~30s Halo 2 proof inside the FFI call. +/// The footer explicitly calls that out so users don't think the +/// app is hung. +/// +/// Exactly one of steps 3/4 is `.skipped` on a successful +/// resolution — same finality-lane semantics as the address-funding +/// progress section. +struct ShieldedFundFromAssetLockProgressSection: View { + @ObservedObject var controller: ShieldedFundFromAssetLockController + + /// Asset-lock rows for this wallet, filtered to the + /// AssetLockShieldedAddressTopUp variant (discriminant `5`). + /// Queried live so step 2/3/4 transitions are reactive to + /// status changes without polling. + @Query private var activeLocks: [PersistentAssetLock] + + /// Cutoff (seconds since the row transitioned to `Broadcast`) + /// between the visually-brief "Broadcasting" step (2) and the + /// "Waiting for InstantSend proof" step (3). Same value as the + /// address-funding progress section. + private static let broadcastingWindow: TimeInterval = 2.0 + + /// Cutoff (seconds since `Broadcast`) where the Rust side falls + /// back from InstantSend to ChainLock. Mirrors + /// `AssetLockManager`'s 300 s IS wait. + private static let instantLockTimeout: TimeInterval = 300.0 + + init(controller: ShieldedFundFromAssetLockController) { + self.controller = controller + let walletId = controller.walletId + // `fundingTypeRaw == 5` is + // `AssetLockFundingType::AssetLockShieldedAddressTopUp` per + // the discriminant comment on `PersistentAssetLock`. We + // filter on it here so an interleaved Type 14 address- + // funding asset lock can't be picked up by mistake — both + // flows produce per-wallet asset-lock rows but only one + // funding type matches this controller's domain. + _activeLocks = Query( + filter: #Predicate { entry in + entry.walletId == walletId && entry.fundingTypeRaw == 5 + }, + sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)] + ) + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 1.0)) { timeline in + let now = timeline.date + let step = currentStep(now: now) + let isFailed = isFailed + let errorMessage = failureMessage + + Section { + ForEach(1...5, id: \.self) { idx in + stepRow( + index: idx, + title: stepTitle(idx), + state: stepState(idx, currentStep: step, isFailed: isFailed) + ) + if idx == 5, let message = errorMessage { + Text(message) + .font(.caption) + .foregroundColor(.red) + .padding(.leading, 32) + } + } + } header: { + Text("Shield Progress") + } footer: { + Text(footerText(step: step, isFailed: isFailed)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Step computation + + private func currentStep(now: Date) -> Int { + switch controller.phase { + case .idle: + return 1 + case .completed: + // No visible "shielded" step — the parent + // `ShieldedFundFromAssetLockProgressView` carries that + // state. Return 6 so every step row (1...5) is marked + // `.done`. + return 6 + case .failed: + if let lock = activeLocks.first { + switch lock.statusRaw { + case 0: return 1 + case 1: return broadcastSubStep(for: lock, now: now) + case 2, 3: return 5 + default: return 1 + } + } + return 5 + case .inFlight: + guard let lock = activeLocks.first else { return 1 } + switch lock.statusRaw { + case 0: + return 1 + case 1: + return broadcastSubStep(for: lock, now: now) + case 2: + // InstantSend-locked. Never went through step 4 + // (CL fallback); it stays as `.skipped`. + return 5 + case 3: + // ChainLock-locked. Step 3 (IS) is skipped (no IS + // proof was observed — either IS timed out and CL + // fallback ran, or `metadata.last_applied_chain_lock` + // built a CL proof directly). + return 5 + default: + return 1 + } + } + } + + private func broadcastSubStep(for lock: PersistentAssetLock, now: Date) -> Int { + let elapsed = now.timeIntervalSince(lock.updatedAt) + if elapsed < Self.broadcastingWindow { return 2 } + if elapsed < Self.instantLockTimeout { return 3 } + return 4 + } + + /// True when step 4 should appear "skipped" — the lock came back + /// InstantSend-locked (statusRaw == 2) so the CL fallback was + /// never needed. + private var step4WasSkipped: Bool { + guard let lock = activeLocks.first else { return false } + return lock.statusRaw == 2 + } + + /// True when step 3 should appear "skipped" — symmetric to + /// `step4WasSkipped`. Same semantics as the address-funding + /// progress section. + private var step3WasSkipped: Bool { + guard let lock = activeLocks.first else { return false } + return lock.statusRaw != 2 + } + + private var isFailed: Bool { + if case .failed = controller.phase { return true } + return false + } + + private var failureMessage: String? { + if case .failed(let msg) = controller.phase { return msg } + return nil + } + + private func stepTitle(_ idx: Int) -> String { + switch idx { + case 1: return "Building asset-lock transaction" + case 2: return "Broadcasting" + case 3: return "Waiting for InstantSend proof" + case 4: return "Waiting for ChainLock proof" + case 5: return "Building Orchard proof and shielding" + default: return "" + } + } + + enum StepState { case done, active, pending, skipped, failed } + + private func stepState(_ idx: Int, currentStep: Int, isFailed: Bool) -> StepState { + if isFailed && idx == currentStep { + return .failed + } + if idx < currentStep { + if idx == 3 && step3WasSkipped { + return .skipped + } + if idx == 4 && step4WasSkipped { + return .skipped + } + return .done + } + if idx == currentStep { + return .active + } + return .pending + } + + // MARK: - Row UI + + @ViewBuilder + private func stepRow(index: Int, title: String, state: StepState) -> some View { + HStack(spacing: 12) { + stepIcon(index: index, state: state) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout) + .foregroundColor(stepTextColor(state)) + } + Spacer() + } + } + + @ViewBuilder + private func stepIcon(index: Int, state: StepState) -> some View { + switch state { + case .done: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title3) + case .active: + ProgressView() + .scaleEffect(0.7) + .frame(width: 22, height: 22) + case .pending: + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.4), lineWidth: 1) + .frame(width: 22, height: 22) + Text("\(index)") + .font(.caption2) + .foregroundColor(.secondary) + } + case .skipped: + Image(systemName: "checkmark.circle") + .foregroundColor(.secondary) + .font(.title3) + case .failed: + Image(systemName: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.title3) + } + } + + private func stepTextColor(_ state: StepState) -> Color { + switch state { + case .done: return .primary + case .active: return .primary + case .pending: return .secondary + case .skipped: return .secondary + case .failed: return .red + } + } + + private func footerText(step: Int, isFailed: Bool) -> String { + if isFailed { + return "Tap Dismiss to clear this entry." + } + switch step { + case 1: return "Building a Core asset-lock transaction from wallet funds." + case 2: return "Sending the asset-lock transaction to peers." + case 3: return "Waiting for the InstantSend lock so the asset-lock proof is final." + case 4: return "InstantSend timed out; falling back to ChainLock finality (~2 min)." + case 5: return "Building the Halo 2 proof (~30s) and submitting the ShieldFromAssetLock state transition to Platform. The shielded note appears in your balance after the next sync pass." + default: return "" + } + } +} + +/// Standalone navigation destination for a shielded funding in +/// flight, completed, or failed. Pushed from +/// `ShieldedFundFromAssetLockView` on submit. +struct ShieldedFundFromAssetLockProgressView: View { + @ObservedObject var controller: ShieldedFundFromAssetLockController + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var walletManager: PlatformWalletManager + + init(controller: ShieldedFundFromAssetLockController) { + self.controller = controller + } + + var body: some View { + Form { + ShieldedFundFromAssetLockProgressSection(controller: controller) + terminalSection + } + .navigationTitle("Shield from Asset Lock") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var terminalSection: some View { + switch controller.phase { + case .completed: + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Shielded", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + HStack { + Text("Amount shielded") + .foregroundColor(.secondary) + Spacer() + Text(formatCredits(controller.shieldAmountCredits)) + .font(.system(.body, design: .monospaced)) + } + Text( + "The shielded note will appear in your balance after the " + + "next sync pass — broadcast already succeeded; the note is " + + "decrypted asynchronously by the shielded sync coordinator." + ) + .font(.caption) + .foregroundColor(.secondary) + Button { + walletManager.shieldedFundFromAssetLockCoordinator.dismiss( + walletId: controller.walletId, + recipientRaw43: controller.recipientRaw43 + ) + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Shield failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + Button { + walletManager.shieldedFundFromAssetLockCoordinator.dismiss( + walletId: controller.walletId, + recipientRaw43: controller.recipientRaw43 + ) + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.top, 4) + } + } + default: + EmptyView() + } + } + + private func formatCredits(_ credits: UInt64) -> String { + // 1e11 credits per DASH — same divisor as + // `AddressFundFromAssetLockProgressView` and `CreateIdentityView`. + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.6f DASH (credits)", dash) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift new file mode 100644 index 0000000000..60271603c7 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift @@ -0,0 +1,635 @@ +// ShieldedFundFromAssetLockView.swift +// SwiftExampleApp +// +// Stepped UI for shielding credits from a Core (SPV) wallet balance +// into a wallet's Orchard (Type 18) pool. Drives +// `PlatformWalletManager.shieldedFundFromAssetLock(...)` end-to-end: +// +// 1. Build an asset-lock tx from the chosen Core BIP44 account. +// 2. Wait for the IS-lock (or fall back to ChainLock on timeout). +// 3. Build a Halo 2 proof (~30s) and submit a +// `ShieldFromAssetLockTransition` against the asset-lock proof. +// 4. Mark the asset lock `Consumed` on success. +// +// No private keys cross the FFI boundary on this path — both the +// Core-side derivation (inside the wallet's asset-lock manager) and +// the outer state-transition signature route through a local +// `MnemonicResolver`, atomic per call. +// +// Differences vs. `FundFromAssetLockPlatformAddressView`: +// * No platform-account picker — shielded recipients are external +// Orchard payment addresses, not allocated from a wallet +// account. +// * The recipient defaults to the wallet's own bound shielded +// default address (the natural demo case). The user can paste a +// 43-byte raw Orchard address (display hex) to override. +// * Both `amountDuffs` (L1) and `shieldAmountCredits` (what enters +// the pool) are exposed — the Rust orchestration takes both +// because Type 18's Orchard `value_balance` is baked into the +// Halo 2 proof at build time and can't be derived by Platform. +// * No post-success recipient back-fill — shielded asset-lock +// rows don't carry a per-account recipient stamp (the recipient +// is an external Orchard address, not allocated from the wallet). + +import SwiftUI +import SwiftDashSDK +import SwiftData + +struct ShieldedFundFromAssetLockView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var platformState: AppState + + /// Wallet to shield from / to. Drives both the picker scope + /// (Core BIP44 accounts on this wallet only) and the managed- + /// wallet lookup at submit time. The recipient defaults to + /// this wallet's own bound shielded default address. + let wallet: PersistentWallet + + /// Optional asset lock to resume from. When non-nil the view + /// hides the Core-funding-account + amount sections (the asset + /// lock already exists, those choices were made at original + /// build time) and routes Submit to + /// `PlatformWalletManager.shieldedResumeFundFromAssetLock` + /// instead of building a fresh lock. The user can still adjust + /// the recipient + shield amount since the orphan lock doesn't + /// carry that — both are set at ST-submission time. + var resumeFromLock: PersistentAssetLock? = nil + + // MARK: - Selection state + + @State private var fundingCoreAccountIndex: UInt32? = nil + /// 43-byte raw recipient Orchard address. Defaults to the + /// wallet's bound shielded default in `autoSelectDefaults`. + @State private var recipientRaw43: Data? = nil + /// User-facing display hex for the recipient (86 chars). Bound + /// to the override text field so the user can paste a different + /// raw address. Kept separate from `recipientRaw43` so we don't + /// fight the formatter on partial input. + @State private var recipientHex: String = "" + @State private var amountDash: String = "0.001" + /// Caller-supplied shielded credits (what enters the Orchard + /// pool). String-backed so the user can edit it directly; the + /// view also auto-fills it from the L1 amount when blank. + @State private var shieldAmountCreditsText: String = "" + + // MARK: - Submit state + + @State private var submitError: SubmitError? = nil + @State private var activeController: ShieldedFundFromAssetLockController? = nil + + /// 1 DASH = 1e8 duffs (Core side). + private static let duffsPerDash: UInt64 = 100_000_000 + /// 1 duff = 1e3 credits (Platform side). Same scale every + /// other duff→credits conversion in this app uses. + private static let creditsPerDuff: UInt64 = 1_000 + /// 1 DASH ≈ 100,000 duffs ≈ 100,000,000 credits. The asset-lock + /// floor mirrors `FundFromAssetLockPlatformAddressView` (1mDASH). + private static let minDuffs: UInt64 = 100_000 + + var body: some View { + NavigationStack { + Form { + if let controller = activeController { + ShieldedFundFromAssetLockProgressSection(controller: controller) + progressTerminalSection(controller: controller) + } else if resumeFromLock != nil { + walletSection + resumeFromAssetLockSection + recipientSection + shieldAmountSection + if canSubmit { + submitSection + } + } else { + walletSection + coreFundingSection + recipientSection + amountSection + shieldAmountSection + if canSubmit { + submitSection + } + } + } + .navigationTitle("Shield from Asset Lock") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(activeController?.phase == .inFlight) + } + } + .alert(item: $submitError) { err in + Alert( + title: Text("Could not shield"), + message: Text(err.message), + dismissButton: .default(Text("OK")) + ) + } + .onAppear(perform: autoSelectDefaults) + .onChange(of: amountDash) { _, _ in autoFillShieldAmount() } + } + } + + // MARK: - Sections + + private var walletSection: some View { + Section { + HStack { + Label("Wallet", systemImage: "wallet.pass") + Spacer() + Text(wallet.name ?? hexShort(wallet.walletId)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } header: { + Text("Source") + } + } + + @ViewBuilder + private var coreFundingSection: some View { + let options = coreAccountOptions + Section { + if options.isEmpty { + Text("No spendable Core (BIP44 standard) accounts on this wallet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Core Account", selection: $fundingCoreAccountIndex) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.accountIndex) { opt in + Text("Account #\(opt.accountIndex) — \(formatDuffs(opt.balanceDuffs))") + .tag(Optional(opt.accountIndex)) + } + } + } + } header: { + Text("Core Source") + } footer: { + Text( + "The selected Core account's UTXOs are locked into an asset lock; " + + "the locked DASH becomes shielded credits on the destination " + + "Orchard address." + ) + } + } + + @ViewBuilder + private var recipientSection: some View { + Section { + HStack { + Label("Recipient", systemImage: "lock.shield") + Spacer() + if let r = recipientRaw43 { + Text(hexShort(r)) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("None") + .foregroundColor(.secondary) + } + } + TextField( + "Recipient (86-char hex, leave blank for self)", + text: $recipientHex + ) + .font(.system(.caption, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .onChange(of: recipientHex) { _, newValue in + applyRecipientOverride(newValue) + } + } header: { + Text("Destination Orchard Address") + } footer: { + Text( + "Defaults to this wallet's own shielded default address (\"shield " + + "to self\"). Paste an 86-character raw hex Orchard address to send " + + "to a different recipient. Bech32m parsing isn't wired into the " + + "example app yet — use the raw shape that " + + "`platform_wallet_manager_shielded_default_address` returns." + ) + } + } + + @ViewBuilder + private var amountSection: some View { + Section { + HStack { + TextField("Amount", text: $amountDash) + .keyboardType(.decimalPad) + .textFieldStyle(.roundedBorder) + .disabled(activeController != nil) + Text("DASH") + .foregroundColor(.secondary) + } + } header: { + Text("L1 Asset-Lock Amount") + } footer: { + if let amount = parsedDuffs { + Text( + "\(formatDuffs(amount)) duffs will be locked. Minimum: " + + "\(formatDuffs(Self.minDuffs)). Must cover shield amount + " + + "Platform min fee." + ) + } else { + Text("Minimum: \(formatDuffs(Self.minDuffs)) duffs.") + } + } + } + + @ViewBuilder + private var shieldAmountSection: some View { + Section { + HStack { + TextField("Shield amount", text: $shieldAmountCreditsText) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .disabled(activeController != nil) + Text("credits") + .foregroundColor(.secondary) + } + } header: { + Text("Shielded Credits (Orchard value_balance)") + } footer: { + Text( + "Credits that enter the shielded pool. Auto-filled from the L1 amount " + + "minus a conservative fee; override to claim less than the full lock. " + + "The wallet refuses obviously-undersized configurations before " + + "broadcasting the asset lock or building the ~30s Halo 2 proof." + ) + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + Text(resumeFromLock == nil ? "Shield" : "Resume Shield") + Spacer() + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.accentColor) + } + } + + @ViewBuilder + private var resumeFromAssetLockSection: some View { + if let lock = resumeFromLock { + Section { + HStack { + Label("Asset Lock", systemImage: "lock.fill") + Spacer() + Text(lock.shortOutPointDisplay) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + } + HStack { + Label("Amount Locked", systemImage: "dollarsign.circle") + Spacer() + Text(formatDuffs(UInt64(bitPattern: Int64(lock.amountDuffs)))) + .foregroundColor(.secondary) + } + HStack { + Label("Status", systemImage: "info.circle") + Spacer() + Text(lock.statusLabel) + .foregroundColor(.secondary) + } + } header: { + Text("Resuming") + } footer: { + Text( + "The asset lock was already built and reached a usable proof state. " + + "Pick a recipient + shield amount to complete the funding." + ) + } + } + } + + /// Inline terminal section that follows the controller's phase. + /// Same idea as the address-funding sibling's `progressTerminalSection`. + @ViewBuilder + private func progressTerminalSection( + controller: ShieldedFundFromAssetLockController + ) -> some View { + switch controller.phase { + case .completed: + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Shielded", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + HStack { + Text("Amount shielded") + .foregroundColor(.secondary) + Spacer() + Text(formatCredits(controller.shieldAmountCredits)) + .font(.system(.body, design: .monospaced)) + } + Text( + "The shielded note appears in your balance after the next " + + "sync pass." + ) + .font(.caption) + .foregroundColor(.secondary) + Button { + walletManager.shieldedFundFromAssetLockCoordinator.dismiss( + walletId: controller.walletId, + recipientRaw43: controller.recipientRaw43 + ) + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Shield failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + Button { + walletManager.shieldedFundFromAssetLockCoordinator.dismiss( + walletId: controller.walletId, + recipientRaw43: controller.recipientRaw43 + ) + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.top, 4) + } + } + default: + EmptyView() + } + } + + // MARK: - Submit + + private func submit() { + guard let recipient = recipientRaw43 else { return } + guard let shieldAmount = parsedShieldAmount, shieldAmount > 0 else { return } + + let walletId = wallet.walletId + let manager = walletManager + + let body: () async throws -> Void + if let lock = resumeFromLock { + guard let parsed = parseOutPoint(lock.outPointHex) else { + submitError = SubmitError( + message: "Could not parse asset lock outpoint: \(lock.outPointHex)" + ) + return + } + body = { + try await manager.shieldedResumeFundFromAssetLock( + walletId: walletId, + outPointTxid: parsed.txid, + outPointVout: parsed.vout, + recipients: [ + ShieldedFundFromAssetLockRecipient( + recipientRaw43: recipient, + credits: shieldAmount + ) + ] + ) + } + } else { + guard + let fundingAccountIndex = fundingCoreAccountIndex, + let duffs = parsedDuffs + else { return } + body = { + try await manager.shieldedFundFromAssetLock( + walletId: walletId, + fundingAccountIndex: fundingAccountIndex, + amountDuffs: duffs, + recipients: [ + ShieldedFundFromAssetLockRecipient( + recipientRaw43: recipient, + credits: shieldAmount + ) + ] + ) + } + } + + // Single-flight gate via the coordinator. The same slot + // re-presents the existing controller on a duplicate tap + // so two FFI calls never race for the same recipient + + // asset lock. + let coordinator = walletManager.shieldedFundFromAssetLockCoordinator + let controller = coordinator.startFunding( + walletId: walletId, + recipientRaw43: recipient, + shieldAmountCredits: shieldAmount, + body: body + ) + activeController = controller + } + + /// Parse `:` back into (32-byte raw + /// little-endian txid, vout). Inverse of + /// `PersistentAssetLock.encodeOutPoint(rawBytes:)`'s display + /// formatting. Returns `nil` on any malformed input. + private func parseOutPoint(_ hex: String) -> (txid: Data, vout: UInt32)? { + let parts = hex.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let txidDisplay = String(parts[0]) + guard let vout = UInt32(parts[1]) else { return nil } + guard txidDisplay.count == 64 else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(32) + var idx = txidDisplay.startIndex + while idx < txidDisplay.endIndex { + let next = txidDisplay.index(idx, offsetBy: 2) + guard let b = UInt8(txidDisplay[idx.. 0 } + .sorted { $0.index < $1.index } + .map { + CoreAccountOption( + accountIndex: $0.index, + balanceDuffs: $0.confirmed + ) + } + } + + private var selectedCoreAccountBalanceDuffs: UInt64 { + guard let idx = fundingCoreAccountIndex else { return 0 } + return coreAccountOptions.first(where: { $0.accountIndex == idx })?.balanceDuffs ?? 0 + } + + private var parsedDuffs: UInt64? { + let raw = amountDash.trimmingCharacters(in: .whitespacesAndNewlines) + guard let dash = Double(raw), dash > 0 else { return nil } + let duffsDouble = dash * Double(Self.duffsPerDash) + guard duffsDouble.isFinite, duffsDouble <= Double(UInt64.max) else { return nil } + return UInt64(duffsDouble.rounded(.toNearestOrAwayFromZero)) + } + + private var parsedShieldAmount: UInt64? { + UInt64(shieldAmountCreditsText.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private var canSubmit: Bool { + if resumeFromLock != nil { + return recipientRaw43 != nil + && (parsedShieldAmount ?? 0) > 0 + && activeController == nil + } + let amount = parsedDuffs ?? 0 + return fundingCoreAccountIndex != nil + && recipientRaw43 != nil + && (parsedShieldAmount ?? 0) > 0 + && amount >= Self.minDuffs + && selectedCoreAccountBalanceDuffs >= amount + && activeController == nil + } + + // MARK: - Actions + + private func autoSelectDefaults() { + if fundingCoreAccountIndex == nil { + fundingCoreAccountIndex = coreAccountOptions + .first { $0.balanceDuffs > 0 }?.accountIndex + ?? coreAccountOptions.first?.accountIndex + } + if recipientRaw43 == nil { + // Default to the wallet's own bound shielded address — + // the natural demo case ("shield to self"). The lookup + // can fail if the wallet hasn't been bound yet; in that + // case the user has to paste an external recipient. + if let own = try? walletManager.shieldedDefaultAddress(walletId: wallet.walletId) { + recipientRaw43 = own + } + } + autoFillShieldAmount() + } + + /// Apply a user-typed hex override to the recipient. Accepts + /// empty (revert to wallet's own default) and 86-char hex + /// (= 43 raw bytes). Anything in between is treated as + /// in-progress entry: leave `recipientRaw43` unchanged so the + /// submit button doesn't oscillate. + private func applyRecipientOverride(_ raw: String) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + // Revert to the wallet's own default. + recipientRaw43 = (try? walletManager.shieldedDefaultAddress(walletId: wallet.walletId)) + ?? nil + return + } + guard trimmed.count == 86 else { return } + var bytes = [UInt8]() + bytes.reserveCapacity(43) + var idx = trimmed.startIndex + while idx < trimmed.endIndex { + let next = trimmed.index(idx, offsetBy: 2) + guard let b = UInt8(trimmed[idx.. 0, + let autoComputed = autoComputedShieldCredits(), + existing != autoComputed, + !shieldAmountCreditsText.isEmpty + { + // User has manually edited — leave alone. + return + } + if let auto = autoComputedShieldCredits() { + shieldAmountCreditsText = String(auto) + } + } + + /// Default shield amount = L1 credits − minFee (a safe lower + /// bound). Returns `nil` when the L1 amount isn't valid yet. + private func autoComputedShieldCredits() -> UInt64? { + guard let duffs = parsedDuffs else { return nil } + let lockCredits = duffs.multipliedReportingOverflow(by: Self.creditsPerDuff) + guard !lockCredits.overflow else { return nil } + // Mirrors `required_asset_lock_duff_balance_for_processing_start_for_address_funding` + // (Type 14 / Type 18 share this constant in the platform + // version). 1 million duffs = 1e9 credits, a comfortable + // conservative buffer for the example app — Rust-side + // preflight catches the precise value if we under-shoot. + let minFeeCredits: UInt64 = 1_000_000_000 + guard lockCredits.partialValue > minFeeCredits else { return nil } + return lockCredits.partialValue - minFeeCredits + } + + // MARK: - Formatting + + private func formatDuffs(_ duffs: UInt64) -> String { + let dash = Double(duffs) / Double(Self.duffsPerDash) + return String(format: "%.8f DASH", dash) + } + + private func formatCredits(_ credits: UInt64) -> String { + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.6f DASH (credits)", dash) + } + + private func hexShort(_ data: Data) -> String { + let hex = data.map { String(format: "%02x", $0) }.joined() + if hex.count <= 16 { return hex } + let prefix = hex.prefix(8) + let suffix = hex.suffix(8) + return "\(prefix)…\(suffix)" + } + + // MARK: - SubmitError + + private struct SubmitError: Identifiable { + let id = UUID() + let message: String + } +} From 7a9c8f042f3aa57cec1ed0b64e421dd30b265e2c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 23:27:22 +0700 Subject: [PATCH 10/13] refactor(shielded): checked-math preflight + per-wallet UI serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-driven cleanups from the latest bot pass: 1. **Preflight checked arithmetic.** The sizing guard introduced in 43845ed084 used `saturating_mul`/`saturating_add`, which silently saturated both sides to `u64::MAX` for pathologically-large duff inputs (~1.8e16 duffs ≈ 18M DASH) and bypassed the `<` comparison. Switch to `checked_*` and reject explicitly on overflow — the guard's intent is now honest in the type system, not just in comments. 2. **Per-wallet UI serialization** in `ShieldedFundFromAssetLockCoordinator`. The coordinator docstring advertised per-recipient concurrency, but the Rust `shield_guard` mutex on `PlatformWallet` actually serializes all shield-class ops per wallet — so two concurrent fundings to different recipients on the same wallet would (a) all block on `shield_guard` Rust-side, and (b) confuse the progress view, which queries `PersistentAssetLock` only by `(walletId, fundingTypeRaw == 5)` and would show the same lock's status to both controllers. Change `startFunding` to return a new `StartFundingResult` enum: - `.started(controller)` — fresh start or same-recipient re-presentation; bind progress UI to the returned controller. - `.blockedByOtherWalletFunding(blocker)` — another shielded funding is already in flight on this wallet (different recipient). Surfaces a typed "wait" error in `ShieldedFundFromAssetLockView` pointing at the blocker's recipient. Per-recipient slot-key deduplication (the existing behaviour) is unchanged: same-recipient double-tap still returns the existing controller. The new check kicks in only on a different recipient + same-wallet collision. --- .../wallet/shielded/fund_from_asset_lock.rs | 28 ++++- ...ShieldedFundFromAssetLockCoordinator.swift | 106 +++++++++++++++--- .../Views/ShieldedFundFromAssetLockView.swift | 27 +++-- 3 files changed, 133 insertions(+), 28 deletions(-) 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 307a4308b9..7267675bb5 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 @@ -116,7 +116,21 @@ impl PlatformWallet { // deliberately removed in favour of caller-supplied // `shield_amount`. if let AssetLockFunding::FromWalletBalance { amount_duffs, .. } = &funding { - let lock_credits = (*amount_duffs).saturating_mul(CREDITS_PER_DUFF); + // Use `checked_*` and reject explicitly on overflow. + // `saturating_*` (the earlier shape) silently passed + // the guard when both sides saturated to `u64::MAX` + // (~1.8e16 duffs ≈ 18 million DASH — operationally + // absurd but the silent-overflow shape would be the + // wrong template for future contributors). With + // checked math the guard is honest about its intent. + let lock_credits = (*amount_duffs) + .checked_mul(CREDITS_PER_DUFF) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock amount overflows credits conversion ({amount_duffs} duffs * \ + {CREDITS_PER_DUFF} credits/duff > u64::MAX)" + )) + })?; let min_fee_duffs = self .sdk .version() @@ -125,8 +139,16 @@ impl PlatformWallet { .identities .asset_locks .required_asset_lock_duff_balance_for_processing_start_for_address_funding; - let min_fee_credits = min_fee_duffs.saturating_mul(CREDITS_PER_DUFF); - let required = shield_amount.saturating_add(min_fee_credits); + let min_fee_credits = min_fee_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "protocol min-fee overflows credits conversion ({min_fee_duffs} duffs)" + )) + })?; + let required = shield_amount.checked_add(min_fee_credits).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "shield_amount ({shield_amount}) + min_fee ({min_fee_credits}) overflows u64" + )) + })?; if lock_credits < required { return Err(PlatformWalletError::ShieldedBuildError(format!( "asset lock ({lock_credits} credits, from {amount_duffs} duffs) cannot cover \ diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift index 152d02ece2..8e7ea1e608 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift @@ -6,13 +6,40 @@ import SwiftDashSDK /// and network-toggle pressure. /// /// Mirrors [`AddressFundFromAssetLockCoordinator`] for Type 18 -/// (`ShieldFromAssetLockTransition`). Keyed by -/// `(walletId, recipientRaw43)` — a wallet can shield to many -/// distinct Orchard recipients concurrently, but the single-flight -/// invariant prevents a user from double-tapping the same recipient -/// during the asset-lock + Halo 2 proof window (~30s). +/// (`ShieldFromAssetLockTransition`), with one shape difference: +/// +/// **Per-wallet serialization.** The Rust orchestration takes a +/// `shield_guard` mutex on the wallet that serializes ALL +/// shield-class operations (`shielded_fund_from_asset_lock`, +/// `shielded_shield_from_account`, etc.). So while the slot key is +/// `(walletId, recipientRaw43)` — fine for deduplicating same- +/// recipient double-taps — concurrent fundings to *different* +/// recipients on the same wallet would (a) all block on +/// `shield_guard` Rust-side, and (b) confuse the progress view, +/// which queries `PersistentAssetLock` only by `(walletId, +/// fundingTypeRaw == 5)` and would show the same lock's status to +/// both controllers. +/// +/// To match Rust-side reality and avoid the UI race, `startFunding` +/// returns `.blockedByOtherWalletFunding` when another controller +/// on the same wallet is still `.inFlight`. The caller surfaces a +/// typed error and points the user at the in-flight funding. @MainActor final class ShieldedFundFromAssetLockCoordinator: ObservableObject { + /// Outcome of `startFunding`. The caller surfaces the + /// distinction in UI — a blocked start needs a typed error, + /// a successful start binds the progress view to the + /// returned controller. + enum StartFundingResult { + /// New or restarted controller for this slot. Caller + /// should bind progress UI to it. + case started(ShieldedFundFromAssetLockController) + /// Another shielded funding is already in flight on the + /// same wallet (different recipient). Caller surfaces + /// the blocker's recipient in a "wait" message. + case blockedByOtherWalletFunding(ShieldedFundFromAssetLockController) + } + /// Composite key — `walletId` is 32 raw bytes; `recipientRaw43` /// is the 43-byte raw Orchard payment address (11-byte /// diversifier + 32-byte pk_d). @@ -59,21 +86,27 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { } /// Start a funding for the slot, or reuse an existing controller - /// if one is already in flight. Returns the controller for - /// `ShieldedFundFromAssetLockView` to bind a progress section - /// against. + /// if one is already in flight on the same recipient. Returns a + /// `StartFundingResult` so the caller can distinguish a fresh + /// start from a wallet-level conflict (see the type doc for + /// rationale). /// - /// Single-flighting is enforced here at the coordinator level — - /// the controller's `submit()` only guards within its own phase - /// machine, so without a phase check before fresh-slot creation - /// a second tap during the FFI window would race two FFI calls - /// for the same recipient + asset lock. + /// Single-flighting works on two levels: + /// 1. **Per-recipient** (slot key match): a second tap on the + /// same recipient during the FFI window re-presents the + /// existing controller, never races a duplicate FFI call. + /// 2. **Per-wallet** (new in this revision): if any controller + /// on the same wallet is `.inFlight` for a *different* + /// recipient, reject with + /// `.blockedByOtherWalletFunding`. Mirrors the Rust-side + /// `shield_guard` mutex on `PlatformWallet` that + /// serializes shield-class operations. func startFunding( walletId: Data, recipientRaw43: Data, shieldAmountCredits: UInt64, body: @escaping () async throws -> Void - ) -> ShieldedFundFromAssetLockController { + ) -> StartFundingResult { let key = SlotKey(walletId: walletId, recipientRaw43: recipientRaw43) if let existing = controllers[key] { switch existing.phase { @@ -82,9 +115,20 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { // Returning the existing controller lets the caller // bind to its progress / terminal state without // disrupting it. - return existing + return .started(existing) case .idle, .failed: - // Legitimate restart paths. + // Legitimate restart paths. We've already checked + // the same-recipient slot here; before letting the + // restart proceed, fall through to the per-wallet + // check below so two concurrent .failed retries on + // different recipients can't both unblock at the + // same time. + if let blocker = inFlightOnWalletExcluding( + walletId: walletId, + excludingRecipient: recipientRaw43 + ) { + return .blockedByOtherWalletFunding(blocker) + } existing.submit(body: body) // No retention sweep here — the slot is sticky on // .failed (we want the user to see + dismiss the @@ -92,9 +136,18 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { // spawn a second 30s poll Task against the same // controller. Sweep was already scheduled when the // controller was first created. - return existing + return .started(existing) } } + // Per-wallet serialization: if another controller on this + // wallet is in flight (different recipient), surface the + // blocker so the caller can render a typed "wait" message. + if let blocker = inFlightOnWalletExcluding( + walletId: walletId, + excludingRecipient: recipientRaw43 + ) { + return .blockedByOtherWalletFunding(blocker) + } let controller = ShieldedFundFromAssetLockController( walletId: walletId, recipientRaw43: recipientRaw43, @@ -103,7 +156,24 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { controllers[key] = controller controller.submit(body: body) scheduleRetentionSweep(key: key, controller: controller) - return controller + return .started(controller) + } + + /// Find any in-flight controller on `walletId` for a recipient + /// other than `excludingRecipient`. Used by the per-wallet + /// serialization check. + private func inFlightOnWalletExcluding( + walletId: Data, + excludingRecipient: Data + ) -> ShieldedFundFromAssetLockController? { + for (key, controller) in controllers { + guard key.walletId == walletId else { continue } + guard key.recipientRaw43 != excludingRecipient else { continue } + if case .inFlight = controller.phase { + return controller + } + } + return nil } /// Manually drop a controller from the map. Used by the UI's diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift index 60271603c7..4aa17345af 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift @@ -436,18 +436,31 @@ struct ShieldedFundFromAssetLockView: View { } } - // Single-flight gate via the coordinator. The same slot - // re-presents the existing controller on a duplicate tap - // so two FFI calls never race for the same recipient + - // asset lock. + // Single-flight gate via the coordinator. Two levels: + // - Same recipient + in-flight: returns the existing + // controller (the user sees the same progress view). + // - Different recipient but another shielded funding in + // flight on this wallet: surfaces a typed "wait" + // error pointing at the in-flight recipient. Mirrors + // the Rust-side `shield_guard` mutex that serializes + // all shield-class ops per wallet. let coordinator = walletManager.shieldedFundFromAssetLockCoordinator - let controller = coordinator.startFunding( + switch coordinator.startFunding( walletId: walletId, recipientRaw43: recipient, shieldAmountCredits: shieldAmount, body: body - ) - activeController = controller + ) { + case .started(let controller): + activeController = controller + case .blockedByOtherWalletFunding(let blocker): + submitError = SubmitError( + message: "Another shielded funding is already in progress on this wallet " + + "(recipient \(hexShort(blocker.recipientRaw43))). Shield-class operations " + + "are serialised wallet-wide by the Rust runtime — try again after that " + + "one finishes." + ) + } } /// Parse `:` back into (32-byte raw From a0afac95e8f94166bb9d7cb1a41b3721f85033f3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 23:39:08 +0700 Subject: [PATCH 11/13] fix(example-app): single "Amount" field for shielded funding UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the prior UI exposed two number fields (L1 amount + shielded credits) where the address-funding and identity flows only need one. The double-field UX leaked the Type-18-specific 'shield_amount baked into Halo 2 proof at build time' constraint to the demo user. Now the form shows a single "Amount" field (DASH, = L1 lock size), matching the address-funding sibling. The view derives shieldAmountCredits internally as `lock_credits − conservative fee buffer` (1mDASH buffer in duffs, well above the protocol minimum) and passes both numbers to the Rust API. Footer line reports both values so the user sees what gets locked and what arrives in the shielded balance after the fee. Resume path uses the same derivation against the persisted lock's `amountDuffs` — no field needed at all on resume, the recipient is the only choice. Behind the scenes the Rust API is unchanged (`amountDuffs` + `shieldAmountCredits` per recipient); the previous reviewer's "caller passes shield_amount" point still holds, the derivation just lives on the Swift side now where it belongs. --- .../Views/ShieldedFundFromAssetLockView.swift | 131 +++++++----------- 1 file changed, 49 insertions(+), 82 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift index 4aa17345af..c55ccf7014 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift @@ -23,10 +23,14 @@ // * The recipient defaults to the wallet's own bound shielded // default address (the natural demo case). The user can paste a // 43-byte raw Orchard address (display hex) to override. -// * Both `amountDuffs` (L1) and `shieldAmountCredits` (what enters -// the pool) are exposed — the Rust orchestration takes both -// because Type 18's Orchard `value_balance` is baked into the -// Halo 2 proof at build time and can't be derived by Platform. +// * Only one number is exposed in the UI ("Amount" in DASH = L1 +// lock size) — same shape as the address-funding sibling and the +// identity-funding flow. The Rust API needs both `amountDuffs` +// and `shieldAmountCredits` because Type 18's Orchard +// `value_balance` is baked into the Halo 2 proof at build time +// and can't be derived by Platform; the view computes the +// shielded credits internally as `lock_credits − conservative +// fee buffer` so the demo UX matches the sibling flows. // * No post-success recipient back-fill — shielded asset-lock // rows don't carry a per-account recipient stamp (the recipient // is an external Orchard address, not allocated from the wallet). @@ -69,10 +73,6 @@ struct ShieldedFundFromAssetLockView: View { /// fight the formatter on partial input. @State private var recipientHex: String = "" @State private var amountDash: String = "0.001" - /// Caller-supplied shielded credits (what enters the Orchard - /// pool). String-backed so the user can edit it directly; the - /// view also auto-fills it from the L1 amount when blank. - @State private var shieldAmountCreditsText: String = "" // MARK: - Submit state @@ -98,7 +98,6 @@ struct ShieldedFundFromAssetLockView: View { walletSection resumeFromAssetLockSection recipientSection - shieldAmountSection if canSubmit { submitSection } @@ -107,7 +106,6 @@ struct ShieldedFundFromAssetLockView: View { coreFundingSection recipientSection amountSection - shieldAmountSection if canSubmit { submitSection } @@ -129,7 +127,6 @@ struct ShieldedFundFromAssetLockView: View { ) } .onAppear(perform: autoSelectDefaults) - .onChange(of: amountDash) { _, _ in autoFillShieldAmount() } } } @@ -230,43 +227,20 @@ struct ShieldedFundFromAssetLockView: View { .foregroundColor(.secondary) } } header: { - Text("L1 Asset-Lock Amount") + Text("Amount") } footer: { - if let amount = parsedDuffs { + if let lockDuffs = parsedDuffs, let shield = computedShieldAmount() { Text( - "\(formatDuffs(amount)) duffs will be locked. Minimum: " - + "\(formatDuffs(Self.minDuffs)). Must cover shield amount + " - + "Platform min fee." + "\(formatDuffs(lockDuffs)) will be locked on L1. " + + "\(formatCredits(shield)) enter the shielded pool after the " + + "Platform fee. Minimum lock: \(formatDuffs(Self.minDuffs))." ) } else { - Text("Minimum: \(formatDuffs(Self.minDuffs)) duffs.") + Text("Minimum: \(formatDuffs(Self.minDuffs)).") } } } - @ViewBuilder - private var shieldAmountSection: some View { - Section { - HStack { - TextField("Shield amount", text: $shieldAmountCreditsText) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .disabled(activeController != nil) - Text("credits") - .foregroundColor(.secondary) - } - } header: { - Text("Shielded Credits (Orchard value_balance)") - } footer: { - Text( - "Credits that enter the shielded pool. Auto-filled from the L1 amount " - + "minus a conservative fee; override to claim less than the full lock. " - + "The wallet refuses obviously-undersized configurations before " - + "broadcasting the asset lock or building the ~30s Halo 2 proof." - ) - } - } - private var submitSection: some View { Section { Button { @@ -390,7 +364,7 @@ struct ShieldedFundFromAssetLockView: View { private func submit() { guard let recipient = recipientRaw43 else { return } - guard let shieldAmount = parsedShieldAmount, shieldAmount > 0 else { return } + guard let shieldAmount = computedShieldAmount(), shieldAmount > 0 else { return } let walletId = wallet.walletId let manager = walletManager @@ -510,7 +484,10 @@ struct ShieldedFundFromAssetLockView: View { return coreAccountOptions.first(where: { $0.accountIndex == idx })?.balanceDuffs ?? 0 } + /// Fresh-build path: L1 duffs the user typed in the Amount field. + /// Nil on resume (resume reads from the persisted lock). private var parsedDuffs: UInt64? { + guard resumeFromLock == nil else { return nil } let raw = amountDash.trimmingCharacters(in: .whitespacesAndNewlines) guard let dash = Double(raw), dash > 0 else { return nil } let duffsDouble = dash * Double(Self.duffsPerDash) @@ -518,20 +495,47 @@ struct ShieldedFundFromAssetLockView: View { return UInt64(duffsDouble.rounded(.toNearestOrAwayFromZero)) } - private var parsedShieldAmount: UInt64? { - UInt64(shieldAmountCreditsText.trimmingCharacters(in: .whitespacesAndNewlines)) + /// Shielded credits derived from whichever lock size applies — + /// the user's typed amount on the fresh path, or the persisted + /// lock's `amountDuffs` on the resume path. Always `lock - fee_buffer` + /// (the conservative `minFeeBufferDuffs` constant) so the + /// caller never has to think about Platform's fee math. + /// Returns `nil` when the lock is too small to even cover the + /// fee buffer. + private func computedShieldAmount() -> UInt64? { + let lockDuffs: UInt64? = { + if let lock = resumeFromLock { + return UInt64(bitPattern: Int64(lock.amountDuffs)) + } + return parsedDuffs + }() + guard let duffs = lockDuffs else { return nil } + let lockCredits = duffs.multipliedReportingOverflow(by: Self.creditsPerDuff) + guard !lockCredits.overflow else { return nil } + let feeBufferCredits = Self.minFeeBufferDuffs.multipliedReportingOverflow(by: Self.creditsPerDuff) + guard !feeBufferCredits.overflow else { return nil } + guard lockCredits.partialValue > feeBufferCredits.partialValue else { return nil } + return lockCredits.partialValue - feeBufferCredits.partialValue } + /// Conservative L1-side fee buffer in duffs. Wallet's Rust-side + /// preflight uses + /// `required_asset_lock_duff_balance_for_processing_start_for_address_funding` + /// as the floor; we use a slightly larger value here so the + /// demo doesn't trip the floor on rounding. 1,000,000 duffs = + /// 0.01 DASH, comfortably above the protocol minimum. + private static let minFeeBufferDuffs: UInt64 = 1_000_000 + private var canSubmit: Bool { if resumeFromLock != nil { return recipientRaw43 != nil - && (parsedShieldAmount ?? 0) > 0 + && (computedShieldAmount() ?? 0) > 0 && activeController == nil } let amount = parsedDuffs ?? 0 return fundingCoreAccountIndex != nil && recipientRaw43 != nil - && (parsedShieldAmount ?? 0) > 0 + && (computedShieldAmount() ?? 0) > 0 && amount >= Self.minDuffs && selectedCoreAccountBalanceDuffs >= amount && activeController == nil @@ -554,7 +558,6 @@ struct ShieldedFundFromAssetLockView: View { recipientRaw43 = own } } - autoFillShieldAmount() } /// Apply a user-typed hex override to the recipient. Accepts @@ -583,42 +586,6 @@ struct ShieldedFundFromAssetLockView: View { recipientRaw43 = Data(bytes) } - /// Auto-fill the shield-amount field from the L1 amount when - /// the user hasn't touched it manually. Conservative estimate: - /// L1 credits minus a small fee buffer. Final precision is - /// caller-controlled — the field stays editable. - private func autoFillShieldAmount() { - // Skip auto-fill if the user has typed a custom value the - // L1 amount doesn't naturally produce. - if let existing = parsedShieldAmount, existing > 0, - let autoComputed = autoComputedShieldCredits(), - existing != autoComputed, - !shieldAmountCreditsText.isEmpty - { - // User has manually edited — leave alone. - return - } - if let auto = autoComputedShieldCredits() { - shieldAmountCreditsText = String(auto) - } - } - - /// Default shield amount = L1 credits − minFee (a safe lower - /// bound). Returns `nil` when the L1 amount isn't valid yet. - private func autoComputedShieldCredits() -> UInt64? { - guard let duffs = parsedDuffs else { return nil } - let lockCredits = duffs.multipliedReportingOverflow(by: Self.creditsPerDuff) - guard !lockCredits.overflow else { return nil } - // Mirrors `required_asset_lock_duff_balance_for_processing_start_for_address_funding` - // (Type 14 / Type 18 share this constant in the platform - // version). 1 million duffs = 1e9 credits, a comfortable - // conservative buffer for the example app — Rust-side - // preflight catches the precise value if we under-shoot. - let minFeeCredits: UInt64 = 1_000_000_000 - guard lockCredits.partialValue > minFeeCredits else { return nil } - return lockCredits.partialValue - minFeeCredits - } - // MARK: - Formatting private func formatDuffs(_ duffs: UInt64) -> String { From 2cf5e88996d5f4e9d9b56c58f5634acf3db01eb7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 09:14:12 +0700 Subject: [PATCH 12/13] refactor(shielded): Rust computes shield_amount internally (revert API shape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the prior "caller passes shield_amount" API forced Swift to duplicate fee math the wallet already does. Worse, the example app had to expose two numeric fields when every sibling flow (identities, platform addresses) takes just one — confusing demo UX. Reverting the API to wallet-computes-it. The original first- reviewer feedback ("be consistent with identities/platform- addresses, caller passes amount_duffs") rested on a faulty analogy: identities and platform-addresses don't compute fees at the wallet level because Platform deducts them INSIDE the transition. Shielded Type 18 can't do that — the Orchard value_balance is baked into the Halo 2 proof at build time. The wallet has to compute the amount before signing, so it might as well do so consistently across all callers instead of pushing the math to every FFI consumer. Changes: rs-platform-wallet: * `shielded_fund_from_asset_lock` recipient API back to `Vec<(OrchardAddress, Option)>`. Preflight enforces exactly one recipient with `None` credits (= "remainder" semantics, mirroring Type 14). Explicit `Some(_)` still rejected today; will be honored when DPP grows multi-output Orchard bundles for Type 18. * Reintroduced `lookup_asset_lock_value_credits` (reads from IS proof's TxOut or the asset-lock manager's tracked row for CL-only paths) and `shield_from_asset_lock_min_fee` (reads the protocol constant). Same shape as the original first cut, but now the answer to the reviewer's "why is shielded the odd one out" is documented inline: Type 18 has no protocol- level fee strategy, so the wallet does the math because no one else can. * Dropped the FromWalletBalance sizing preflight — wallet now knows the value internally; no risk of caller mis-sizing. rs-platform-wallet-ffi: * `platform_wallet_manager_shielded_fund_from_asset_lock` and its resume sibling lose the `shield_amount_credits` parameter. FFI now passes `vec![(addr, None)]` to the wallet. swift-sdk: * `ShieldedFundFromAssetLockRecipient.credits` becomes `UInt64?` (matches Rust); preflight enforces `nil` today with a TODO comment about multi-output future. swift-sdk/SwiftExampleApp: * Single "Amount" field in the form, same shape as `FundFromAssetLockPlatformAddressView`. * Dropped `computedShieldAmount` and the Swift-side `creditsPerDuff`/`minFeeBufferDuffs` constants — no duplicate fee math. * Controller drops `shieldAmountCredits` (the Swift side never knows the precise amount; the terminal banner just says "Shielded" and points the user at the next sync pass). --- .../src/shielded_send.rs | 22 +- .../wallet/shielded/fund_from_asset_lock.rs | 280 +++++++++++------- ...PlatformWalletManagerShieldedFunding.swift | 45 +-- .../ShieldedFundFromAssetLockController.swift | 9 +- ...ShieldedFundFromAssetLockCoordinator.swift | 4 +- ...hieldedFundFromAssetLockProgressView.swift | 13 - .../Views/ShieldedFundFromAssetLockView.swift | 83 +----- 7 files changed, 222 insertions(+), 234 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 44c10319bf..c9898f693e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -337,19 +337,17 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// /// `account_index` selects the BIP44 Core account whose UTXOs /// fund the asset lock. `amount_duffs` is the L1 amount to lock. -/// `shield_amount_credits` is what enters the shielded pool — the -/// Orchard `value_balance` baked into the Halo 2 proof at build -/// time. The caller is responsible for the relationship -/// `amount_duffs * CREDITS_PER_DUFF ≥ shield_amount_credits + Platform fee`; -/// undersized locks fail at Platform submission. +/// The wallet derives the shielded credit amount internally +/// (`lock_value − protocol_min_fee`) — callers don't need to know +/// about Type 18's Halo 2 fee math. /// /// `recipient_raw_43` is the single Orchard recipient (same shape /// `platform_wallet_manager_shielded_default_address` returns); it -/// receives `shield_amount_credits` credits. +/// receives the full `lock_value − min_fee` credits. /// -/// Multi-recipient is reserved for a future DPP-side Orchard -/// multi-output bundle change; today the orchestration rejects -/// anything but a single recipient. +/// Multi-recipient with explicit per-recipient amounts is reserved +/// for a future DPP-side Orchard multi-output bundle change; today +/// the orchestration rejects anything but a single recipient. /// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. @@ -366,7 +364,6 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( wallet_id_bytes: *const u8, account_index: u32, amount_duffs: u64, - shield_amount_credits: u64, recipient_raw_43: *const u8, core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { @@ -421,7 +418,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( amount_duffs, account_index, }, - vec![(recipient, shield_amount_credits)], + vec![(recipient, None)], &asset_lock_signer, &prover, None, @@ -459,7 +456,6 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset handle: Handle, wallet_id_bytes: *const u8, out_point: *const OutPointFFI, - shield_amount_credits: u64, recipient_raw_43: *const u8, core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { @@ -513,7 +509,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, }, - vec![(recipient, shield_amount_credits)], + vec![(recipient, None)], &asset_lock_signer, &prover, None, 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 7267675bb5..349a695b16 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 @@ -34,6 +34,8 @@ use dpp::shielded::builder::{build_shield_from_asset_lock_transition_with_signer use dpp::state_transition::proof_result::StateTransitionProofResult; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use crate::wallet::asset_lock::tracked::TrackedAssetLock; + use crate::error::is_instant_lock_proof_invalid; use crate::wallet::asset_lock::orchestration::{ out_point_from_proof, submit_with_cl_height_retry, AssetLockFunding, FundingResolution, @@ -54,20 +56,24 @@ impl PlatformWallet { /// builds a fresh asset lock from Core UTXOs; `FromExistingAssetLock` /// resumes from a tracked outpoint (after relaunch or a stuck /// broadcast). - /// * `recipients` — Recipient `OrchardAddress` + explicit credit - /// amount per entry. The shape mirrors the platform-address - /// `BTreeMap` API for future-compatibility - /// with multi-output Orchard bundles, but today the pre-flight - /// enforces exactly one recipient (Type 18's bundle builder is - /// single-output). + /// * `recipients` — Recipient list, shape + /// `Vec<(OrchardAddress, Option)>` mirroring the + /// platform-address Type 14 API. Today the pre-flight enforces + /// exactly one recipient with `None` credits — that recipient + /// receives the lock value minus the protocol minimum fee + /// (`required_asset_lock_duff_balance_for_processing_start_for_address_funding`). + /// + /// When DPP grows multi-output Orchard bundles for Type 18, + /// `Some(_)` values will be honored (explicit credit amounts + /// pass through; the single `None` bucket — if any — receives + /// the residual). Keeping the multi-shape signature now means + /// no caller migration is needed at that point. /// - /// Unlike platform-address funding, the caller passes the - /// credit amount explicitly. For Type 18 there is no - /// protocol-side `AddressFundsFeeStrategy`; the Orchard - /// `value_balance` (= `sum(recipient credits)`) is baked into - /// the Halo 2 proof at build time, and the asset-lock value - /// minus that covers the Platform fee. The caller is - /// responsible for sizing the L1 lock to cover both. + /// Unlike Type 14, Type 18 has no protocol-side + /// `AddressFundsFeeStrategy` — the Orchard `value_balance` + /// (= recipient credits) is baked into the Halo 2 proof at + /// build time. The wallet handles the math here so callers + /// don't have to know about protocol-level fee constants. /// * `asset_lock_signer` — External signer for the outer ECDSA /// signature on the state transition. The raw key never crosses /// the FFI boundary. @@ -78,7 +84,7 @@ impl PlatformWallet { pub async fn shielded_fund_from_asset_lock( &self, funding: AssetLockFunding, - recipients: Vec<(OrchardAddress, Credits)>, + recipients: Vec<(OrchardAddress, Option)>, asset_lock_signer: &AS, prover: P, settings: Option, @@ -92,73 +98,6 @@ impl PlatformWallet { // build, ~30s, only to reject downstream). validate_shielded_recipients(&recipients)?; - // Caller specifies shield_amount per recipient (Type 18's - // Orchard `value_balance` is baked into the Halo 2 proof at - // build time, unlike address-funding's protocol-level fee - // strategy). Identities and platform-addresses take - // `amount_duffs` from the caller for the same reason — - // Platform handles their fee math inside the transition, - // shielded can't. - let (recipient, shield_amount) = - *recipients.first().expect("preflight enforces len() == 1"); - - // Sizing sanity check on the FromWalletBalance path: refuse - // obviously-undersized configurations BEFORE we broadcast - // the asset-lock tx (single-use L1 funds) or spend ~30s - // building a Halo 2 proof Platform would reject. - // - // Best-effort, not authoritative — Platform's real fee - // depends on state we don't track. We only check the lower - // bound: lock_credits >= shield_amount + min_required_fee. - // The resume path takes an existing tracked outpoint; the - // lock value is already pinned on-chain, and checking it - // here would re-introduce the asset-lock-value lookup we - // deliberately removed in favour of caller-supplied - // `shield_amount`. - if let AssetLockFunding::FromWalletBalance { amount_duffs, .. } = &funding { - // Use `checked_*` and reject explicitly on overflow. - // `saturating_*` (the earlier shape) silently passed - // the guard when both sides saturated to `u64::MAX` - // (~1.8e16 duffs ≈ 18 million DASH — operationally - // absurd but the silent-overflow shape would be the - // wrong template for future contributors). With - // checked math the guard is honest about its intent. - let lock_credits = (*amount_duffs) - .checked_mul(CREDITS_PER_DUFF) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "asset lock amount overflows credits conversion ({amount_duffs} duffs * \ - {CREDITS_PER_DUFF} credits/duff > u64::MAX)" - )) - })?; - let min_fee_duffs = self - .sdk - .version() - .dpp - .state_transitions - .identities - .asset_locks - .required_asset_lock_duff_balance_for_processing_start_for_address_funding; - let min_fee_credits = min_fee_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "protocol min-fee overflows credits conversion ({min_fee_duffs} duffs)" - )) - })?; - let required = shield_amount.checked_add(min_fee_credits).ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "shield_amount ({shield_amount}) + min_fee ({min_fee_credits}) overflows u64" - )) - })?; - if lock_credits < required { - return Err(PlatformWalletError::ShieldedBuildError(format!( - "asset lock ({lock_credits} credits, from {amount_duffs} duffs) cannot cover \ - shield_amount ({shield_amount}) + protocol min fee ({min_fee_credits}); \ - refusing to broadcast a single-use asset lock and build a ~30s Halo 2 proof \ - for a submission Platform would reject" - ))); - } - } - // Single-flight: serialise shield-class operations on this // wallet so two concurrent calls can't race the asset-lock // tracker into a half-consumed state. @@ -210,6 +149,41 @@ impl PlatformWallet { } }; + // Step 3: derive `shield_amount` from the asset-lock value. + // + // Unlike Type 14 (where Platform deducts the fee inside the + // transition via `AddressFundsFeeStrategy`), Type 18 bakes + // the Orchard `value_balance` into the Halo 2 proof at build + // time — someone *has* to know the precise number before + // signing. The wallet is the right place: it already has the + // lock value (from the IS proof's TxOut, or from the asset- + // lock manager's tracked row for CL-only paths) and the + // protocol min-fee constant (from `PlatformVersion`). + // + // Single-recipient + `None` semantics today: the recipient + // receives `lock_value - min_fee`. Future multi-recipient + // would honor `Some(_)` values explicitly and route the + // residual to the (sole) `None` bucket; the preflight will + // change in lockstep with the DPP-side multi-output bundle + // builder. + let asset_lock_value_credits = + lookup_asset_lock_value_credits(self, &proof, tracked_out_point.as_ref()).await?; + let min_fee_credits = self.shield_from_asset_lock_min_fee()?; + let shield_amount = asset_lock_value_credits + .checked_sub(min_fee_credits) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock value ({asset_lock_value_credits} credits) is below the \ + minimum required fee ({min_fee_credits} credits) for ShieldFromAssetLock" + )) + })?; + if shield_amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "shield amount after fee is zero".to_string(), + )); + } + let (recipient, _) = *recipients.first().expect("preflight enforces len() == 1"); + // 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 @@ -318,10 +292,85 @@ impl PlatformWallet { } } - tracing::info!(shield_amount, "Shielded fund-from-asset-lock succeeded"); + tracing::info!( + shield_amount, + asset_lock_value_credits, + min_fee_credits, + "Shielded fund-from-asset-lock succeeded" + ); Ok(()) } + + /// Minimum fee for a `ShieldFromAssetLock` (Type 18) state + /// transition, in credits. Read from + /// `dpp.state_transitions.identities.asset_locks` — the same + /// constant Type 14 (address funding) and Platform's + /// `StateTransitionEstimatedFeeValidation` use for Type 18. + fn shield_from_asset_lock_min_fee(&self) -> Result { + let pv = self.sdk.version(); + let min_fee_duffs = pv + .dpp + .state_transitions + .identities + .asset_locks + .required_asset_lock_duff_balance_for_processing_start_for_address_funding; + min_fee_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "protocol min-fee constant overflowed credits conversion \ + ({min_fee_duffs} duffs * {CREDITS_PER_DUFF} credits/duff > u64::MAX)" + )) + }) + } +} + +/// Look up the asset-lock value in credits. +/// +/// Preference order: +/// 1. If the proof is `Instant`, read directly from +/// `InstantAssetLockProof::output().value` — no manager lookup +/// needed. +/// 2. Otherwise (the IS-timeout-fallback path produced a CL proof +/// that doesn't carry the tx output), look up the tracked +/// asset-lock row by outpoint. +async fn lookup_asset_lock_value_credits( + wallet: &PlatformWallet, + proof: &AssetLockProof, + tracked_out_point: Option<&dashcore::OutPoint>, +) -> Result { + let duffs = match proof { + AssetLockProof::Instant(is) => { + let out = is.output().ok_or_else(|| { + PlatformWalletError::AddressSync( + "InstantAssetLockProof has no output at the indicated index".to_string(), + ) + })?; + out.value + } + AssetLockProof::Chain(_) => { + let op = tracked_out_point.ok_or_else(|| { + PlatformWalletError::AddressSync( + "ChainAssetLockProof but no tracked outpoint to look up value".to_string(), + ) + })?; + let locks: Vec = wallet.asset_locks.list_tracked_locks().await; + locks + .iter() + .find(|l| l.out_point == *op) + .map(|l| l.amount) + .ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "tracked asset lock {} not found in manager", + op + )) + })? + } + }; + duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock value ({duffs} duffs * {CREDITS_PER_DUFF} credits/duff > u64::MAX)" + )) + }) } /// Build the Type 18 transition and broadcast-and-wait. @@ -367,18 +416,20 @@ where /// Pre-flight check for the recipient list. /// -/// Today: non-empty, exactly one recipient, non-zero amount. The -/// multi-shape `Vec<(OrchardAddress, Credits)>` API is exposed so -/// the caller signature is future-compatible — when DPP grows -/// multi-output Orchard bundles for Type 18, lifting the -/// single-recipient restriction is a preflight-only change; no FFI -/// / Swift / caller migration needed. +/// Today: non-empty, exactly one recipient whose `Credits` value is +/// `None` (= "remainder" semantics — receives `lock_value − min_fee` +/// after Step 2 resolves the asset lock). The multi-shape +/// `Vec<(OrchardAddress, Option)>` API is exposed so the +/// caller signature is future-compatible — when DPP grows +/// multi-output Orchard bundles for Type 18, `Some(_)` values will +/// be honored (explicit credit amounts pass through; the single +/// `None` bucket receives the residual). Same shape as Type 14. /// -/// Generic over `T` so unit tests can pass `(u8, Credits)` instead -/// of constructing a curve-valid `OrchardAddress` for what is -/// really a length / cardinality check. +/// Generic over `T` so unit tests can pass `(u8, Option)` +/// instead of constructing a curve-valid `OrchardAddress` for what +/// is really a length / cardinality check. pub(super) fn validate_shielded_recipients( - recipients: &[(T, Credits)], + recipients: &[(T, Option)], ) -> Result<(), PlatformWalletError> { if recipients.is_empty() { return Err(PlatformWalletError::AddressOperation( @@ -389,9 +440,9 @@ pub(super) fn validate_shielded_recipients( // for Type 18 (`build_output_only_bundle` currently builds a // single-output bundle; extending would also affect the Shield // Type 15 path that shares it), drop this restriction. The - // semantics will become: each recipient's `Credits` flows into - // its Orchard output, and the bundle's `value_balance` becomes - // `sum(credits)`. + // semantics will become: explicit `Some(credits)` flows into + // its Orchard output; the (exactly one) `None` bucket receives + // the residual `asset_lock_value − sum(explicit) − fee`. if recipients.len() != 1 { return Err(PlatformWalletError::AddressOperation(format!( "shielded_fund_from_asset_lock currently supports exactly one recipient \ @@ -399,9 +450,15 @@ pub(super) fn validate_shielded_recipients( recipients.len() ))); } - if recipients[0].1 == 0 { - return Err(PlatformWalletError::ShieldedBuildError( - "shield amount must be > 0".to_string(), + if recipients[0].1.is_some() { + // TODO(multi-output): drop this when the bundle builder honors + // explicit `Some(_)` values per recipient. + return Err(PlatformWalletError::AddressOperation( + "shielded_fund_from_asset_lock currently ignores explicit recipient credits \ + (the single recipient receives lock_value - min_fee). Pass `None` for the \ + remainder semantics; explicit amounts will be honored once DPP grows \ + multi-output Orchard bundles for Type 18." + .to_string(), )); } Ok(()) @@ -419,14 +476,14 @@ mod tests { #[test] fn validate_rejects_empty_recipients() { - let v: Vec<(u8, Credits)> = Vec::new(); + let v: Vec<(u8, Option)> = Vec::new(); let err = validate_shielded_recipients(&v).expect_err("empty must reject"); assert!(format!("{err}").contains("at least one recipient")); } #[test] fn validate_rejects_multi_recipient_for_now() { - let v: Vec<(u8, Credits)> = vec![(1, 100), (2, 200)]; + let v: Vec<(u8, Option)> = vec![(1, None), (2, Some(100))]; let err = validate_shielded_recipients(&v).expect_err("multi-recipient must reject (TODO)"); let msg = format!("{err}"); assert!( @@ -436,18 +493,25 @@ mod tests { } #[test] - fn validate_rejects_zero_amount() { - let v: Vec<(u8, Credits)> = vec![(0, 0)]; - let err = validate_shielded_recipients(&v).expect_err("zero amount must reject"); + fn validate_rejects_explicit_some_for_now() { + // Until DPP grows multi-output Orchard bundles for Type 18, + // we ignore explicit amounts (the single recipient receives + // lock_value - min_fee). Reject explicit `Some(_)` so the + // caller's expectation matches the wallet's behaviour + // instead of silently dropping the value. + let v: Vec<(u8, Option)> = vec![(0, Some(500_000))]; + let err = validate_shielded_recipients(&v) + .expect_err("explicit Some must reject until multi-output is wired"); + let msg = format!("{err}"); assert!( - format!("{err}").contains("must be > 0"), - "unexpected error: {err}" + msg.contains("currently ignores explicit recipient credits"), + "unexpected error: {msg}" ); } #[test] - fn validate_accepts_single_recipient_with_amount() { - let v: Vec<(u8, Credits)> = vec![(0, 500_000)]; - validate_shielded_recipients(&v).expect("single recipient with amount must pass"); + fn validate_accepts_single_none_recipient() { + let v: Vec<(u8, Option)> = vec![(0, None)]; + validate_shielded_recipients(&v).expect("single recipient with None must pass"); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 723b724368..85e5d6cb8d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -3,23 +3,29 @@ import DashSDKFFI /// Recipient entry for `shieldedFundFromAssetLock(...)`. /// -/// The Rust-side API today enforces exactly one recipient — Type -/// 18's Orchard bundle builder is single-output; multi-recipient -/// lands when DPP grows multi-output bundles. The list shape is -/// exposed here so the call site doesn't have to change when that -/// happens. +/// The Rust-side API today enforces exactly one recipient with +/// `nil` credits (= "remainder" semantics — receives +/// `lock_value − protocol_min_fee`). Type 18's Orchard bundle +/// builder is single-output; multi-recipient with explicit per- +/// recipient amounts lands when DPP grows multi-output bundles. +/// The list shape is exposed here so the call site doesn't have +/// to change when that happens. public struct ShieldedFundFromAssetLockRecipient: Sendable { /// Raw 43-byte Orchard payment address (11-byte diversifier + - /// 32-byte pk_d). Same shape `platform_wallet_manager_shielded_default_address` - /// returns and `shieldedTransfer` consumes. + /// 32-byte pk_d). Same shape + /// `platform_wallet_manager_shielded_default_address` returns + /// and `shieldedTransfer` consumes. public let recipientRaw43: Data - /// Credit amount this recipient receives. Becomes the Orchard - /// `value_balance` baked into the Halo 2 proof at build time - /// (or its share, once multi-recipient lands). - public let credits: UInt64 + /// Explicit credit amount this recipient receives, or `nil` + /// for the "remainder" semantics (the recipient gets + /// `lock_value − min_fee`). Today the wallet enforces `nil` + /// for the single-recipient case; explicit `Some(_)` values + /// will be honored once DPP grows multi-output Orchard + /// bundles for Type 18. + public let credits: UInt64? - public init(recipientRaw43: Data, credits: UInt64) { + public init(recipientRaw43: Data, credits: UInt64? = nil) { self.recipientRaw43 = recipientRaw43 self.credits = credits } @@ -73,7 +79,6 @@ extension PlatformWalletManager { let handle = self.handle let recipientRaw43 = recipients[0].recipientRaw43 - let shieldAmountCredits = recipients[0].credits // Constructed on the calling actor so it lives for the // entire detached Task. Released after `withExtendedLifetime` // returns. See `ManagedPlatformAddressWallet.fundFromAssetLock` @@ -107,7 +112,6 @@ extension PlatformWalletManager { widPtr, fundingAccountIndex, amountDuffs, - shieldAmountCredits, recipientPtr, coreSigner.handle ) @@ -156,7 +160,6 @@ extension PlatformWalletManager { let handle = self.handle let recipientRaw43 = recipients[0].recipientRaw43 - let shieldAmountCredits = recipients[0].credits let coreSigner = MnemonicResolver() try await Task.detached(priority: .userInitiated) { @@ -199,7 +202,6 @@ extension PlatformWalletManager { handle, widPtr, &outPoint, - shieldAmountCredits, recipientPtr, coreSigner.handle ) @@ -249,9 +251,16 @@ extension PlatformWalletManager { "ShieldedFundFromAssetLockRecipient.recipientRaw43 must be exactly 43 bytes (got \(r.recipientRaw43.count))" ) } - guard r.credits > 0 else { + // TODO(multi-output): drop this once DPP grows multi-output + // Orchard bundles for Type 18 and honors explicit `Some(_)` + // recipient credits. Today the wallet rejects explicit + // amounts (the single recipient receives `lock_value - min_fee`), + // so we catch it here before paying for the FFI roundtrip. + guard r.credits == nil else { throw PlatformWalletError.invalidParameter( - "ShieldedFundFromAssetLockRecipient.credits must be > 0" + "ShieldedFundFromAssetLockRecipient.credits must be nil today " + + "(the single recipient receives lock_value - protocol min fee). " + + "Explicit amounts will be honored when DPP grows multi-output bundles for Type 18." ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift index d86e1ac692..d4e38dc3db 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift @@ -74,12 +74,6 @@ final class ShieldedFundFromAssetLockController: ObservableObject { /// collide. let recipientRaw43: Data - /// Caller-specified shielded credits — the Orchard - /// `value_balance` baked into the Halo 2 proof at build time. - /// Surfaced in the terminal banner so the user can confirm what - /// they shielded. - let shieldAmountCredits: UInt64 - /// Timestamp of the most recent `submit` call. Used by the /// coordinator's TTL-based retention policy (`.completed` rows /// purge ~30s after the success transition). @@ -90,10 +84,9 @@ final class ShieldedFundFromAssetLockController: ObservableObject { /// wired today (the FFI call doesn't yet support clean abort). private var task: Task? - init(walletId: Data, recipientRaw43: Data, shieldAmountCredits: UInt64) { + init(walletId: Data, recipientRaw43: Data) { self.walletId = walletId self.recipientRaw43 = recipientRaw43 - self.shieldAmountCredits = shieldAmountCredits } /// Submit the funding. Defensively rejects any phase that diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift index 8e7ea1e608..541f68a98e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift @@ -104,7 +104,6 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { func startFunding( walletId: Data, recipientRaw43: Data, - shieldAmountCredits: UInt64, body: @escaping () async throws -> Void ) -> StartFundingResult { let key = SlotKey(walletId: walletId, recipientRaw43: recipientRaw43) @@ -150,8 +149,7 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { } let controller = ShieldedFundFromAssetLockController( walletId: walletId, - recipientRaw43: recipientRaw43, - shieldAmountCredits: shieldAmountCredits + recipientRaw43: recipientRaw43 ) controllers[key] = controller controller.submit(body: body) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift index fe02bb7cf5..58d429bb83 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift @@ -312,13 +312,6 @@ struct ShieldedFundFromAssetLockProgressView: View { Label("Shielded", systemImage: "checkmark.seal.fill") .foregroundColor(.green) .font(.headline) - HStack { - Text("Amount shielded") - .foregroundColor(.secondary) - Spacer() - Text(formatCredits(controller.shieldAmountCredits)) - .font(.system(.body, design: .monospaced)) - } Text( "The shielded note will appear in your balance after the " + "next sync pass — broadcast already succeeded; the note is " @@ -369,10 +362,4 @@ struct ShieldedFundFromAssetLockProgressView: View { } } - private func formatCredits(_ credits: UInt64) -> String { - // 1e11 credits per DASH — same divisor as - // `AddressFundFromAssetLockProgressView` and `CreateIdentityView`. - let dash = Double(credits) / 100_000_000_000.0 - return String(format: "%.6f DASH (credits)", dash) - } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift index c55ccf7014..7dff964d16 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift @@ -25,12 +25,10 @@ // 43-byte raw Orchard address (display hex) to override. // * Only one number is exposed in the UI ("Amount" in DASH = L1 // lock size) — same shape as the address-funding sibling and the -// identity-funding flow. The Rust API needs both `amountDuffs` -// and `shieldAmountCredits` because Type 18's Orchard -// `value_balance` is baked into the Halo 2 proof at build time -// and can't be derived by Platform; the view computes the -// shielded credits internally as `lock_credits − conservative -// fee buffer` so the demo UX matches the sibling flows. +// identity-funding flow. The Rust wallet derives the shielded +// credit amount internally (`lock_value − protocol_min_fee`) +// because Type 18's Orchard `value_balance` is baked into the +// Halo 2 proof at build time and can't be derived by Platform. // * No post-success recipient back-fill — shielded asset-lock // rows don't carry a per-account recipient stamp (the recipient // is an external Orchard address, not allocated from the wallet). @@ -81,11 +79,8 @@ struct ShieldedFundFromAssetLockView: View { /// 1 DASH = 1e8 duffs (Core side). private static let duffsPerDash: UInt64 = 100_000_000 - /// 1 duff = 1e3 credits (Platform side). Same scale every - /// other duff→credits conversion in this app uses. - private static let creditsPerDuff: UInt64 = 1_000 - /// 1 DASH ≈ 100,000 duffs ≈ 100,000,000 credits. The asset-lock - /// floor mirrors `FundFromAssetLockPlatformAddressView` (1mDASH). + /// The asset-lock floor mirrors + /// `FundFromAssetLockPlatformAddressView` (1mDASH). private static let minDuffs: UInt64 = 100_000 var body: some View { @@ -229,11 +224,11 @@ struct ShieldedFundFromAssetLockView: View { } header: { Text("Amount") } footer: { - if let lockDuffs = parsedDuffs, let shield = computedShieldAmount() { + if let lockDuffs = parsedDuffs { Text( "\(formatDuffs(lockDuffs)) will be locked on L1. " - + "\(formatCredits(shield)) enter the shielded pool after the " - + "Platform fee. Minimum lock: \(formatDuffs(Self.minDuffs))." + + "The shielded pool receives lock value minus the Platform " + + "minimum fee. Minimum lock: \(formatDuffs(Self.minDuffs))." ) } else { Text("Minimum: \(formatDuffs(Self.minDuffs)).") @@ -304,13 +299,6 @@ struct ShieldedFundFromAssetLockView: View { Label("Shielded", systemImage: "checkmark.seal.fill") .foregroundColor(.green) .font(.headline) - HStack { - Text("Amount shielded") - .foregroundColor(.secondary) - Spacer() - Text(formatCredits(controller.shieldAmountCredits)) - .font(.system(.body, design: .monospaced)) - } Text( "The shielded note appears in your balance after the next " + "sync pass." @@ -364,7 +352,6 @@ struct ShieldedFundFromAssetLockView: View { private func submit() { guard let recipient = recipientRaw43 else { return } - guard let shieldAmount = computedShieldAmount(), shieldAmount > 0 else { return } let walletId = wallet.walletId let manager = walletManager @@ -383,10 +370,7 @@ struct ShieldedFundFromAssetLockView: View { outPointTxid: parsed.txid, outPointVout: parsed.vout, recipients: [ - ShieldedFundFromAssetLockRecipient( - recipientRaw43: recipient, - credits: shieldAmount - ) + ShieldedFundFromAssetLockRecipient(recipientRaw43: recipient) ] ) } @@ -401,10 +385,7 @@ struct ShieldedFundFromAssetLockView: View { fundingAccountIndex: fundingAccountIndex, amountDuffs: duffs, recipients: [ - ShieldedFundFromAssetLockRecipient( - recipientRaw43: recipient, - credits: shieldAmount - ) + ShieldedFundFromAssetLockRecipient(recipientRaw43: recipient) ] ) } @@ -422,7 +403,6 @@ struct ShieldedFundFromAssetLockView: View { switch coordinator.startFunding( walletId: walletId, recipientRaw43: recipient, - shieldAmountCredits: shieldAmount, body: body ) { case .started(let controller): @@ -495,47 +475,13 @@ struct ShieldedFundFromAssetLockView: View { return UInt64(duffsDouble.rounded(.toNearestOrAwayFromZero)) } - /// Shielded credits derived from whichever lock size applies — - /// the user's typed amount on the fresh path, or the persisted - /// lock's `amountDuffs` on the resume path. Always `lock - fee_buffer` - /// (the conservative `minFeeBufferDuffs` constant) so the - /// caller never has to think about Platform's fee math. - /// Returns `nil` when the lock is too small to even cover the - /// fee buffer. - private func computedShieldAmount() -> UInt64? { - let lockDuffs: UInt64? = { - if let lock = resumeFromLock { - return UInt64(bitPattern: Int64(lock.amountDuffs)) - } - return parsedDuffs - }() - guard let duffs = lockDuffs else { return nil } - let lockCredits = duffs.multipliedReportingOverflow(by: Self.creditsPerDuff) - guard !lockCredits.overflow else { return nil } - let feeBufferCredits = Self.minFeeBufferDuffs.multipliedReportingOverflow(by: Self.creditsPerDuff) - guard !feeBufferCredits.overflow else { return nil } - guard lockCredits.partialValue > feeBufferCredits.partialValue else { return nil } - return lockCredits.partialValue - feeBufferCredits.partialValue - } - - /// Conservative L1-side fee buffer in duffs. Wallet's Rust-side - /// preflight uses - /// `required_asset_lock_duff_balance_for_processing_start_for_address_funding` - /// as the floor; we use a slightly larger value here so the - /// demo doesn't trip the floor on rounding. 1,000,000 duffs = - /// 0.01 DASH, comfortably above the protocol minimum. - private static let minFeeBufferDuffs: UInt64 = 1_000_000 - private var canSubmit: Bool { if resumeFromLock != nil { - return recipientRaw43 != nil - && (computedShieldAmount() ?? 0) > 0 - && activeController == nil + return recipientRaw43 != nil && activeController == nil } let amount = parsedDuffs ?? 0 return fundingCoreAccountIndex != nil && recipientRaw43 != nil - && (computedShieldAmount() ?? 0) > 0 && amount >= Self.minDuffs && selectedCoreAccountBalanceDuffs >= amount && activeController == nil @@ -593,11 +539,6 @@ struct ShieldedFundFromAssetLockView: View { return String(format: "%.8f DASH", dash) } - private func formatCredits(_ credits: UInt64) -> String { - let dash = Double(credits) / 100_000_000_000.0 - return String(format: "%.6f DASH (credits)", dash) - } - private func hexShort(_ data: Data) -> String { let hex = data.map { String(format: "%02x", $0) }.joined() if hex.count <= 16 { return hex } From 6331681cbb2f51fb2de2ca22ecbc381c66ba9704 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 11:24:48 +0700 Subject: [PATCH 13/13] fix(shielded): pre-broadcast sizing guard + retention sweep on retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-driven fixes: 1. **Pre-broadcast sizing guard.** The revert in 2cf5e88996 removed the FromWalletBalance sizing check before resolve_funding_with_is_timeout_fallback. An undersized amount_duffs would now (a) broadcast a real L1 asset-lock, (b) wait through the IS path, then (c) underflow in Step 3's checked_sub — with the L1 outpoint stranded and a Resume on the orphaned lock deterministically hitting the same underflow. Restore the guard in Step 1: if amount_duffs * CREDITS_PER_DUFF <= protocol_min_fee, refuse to broadcast. The Step 3 check stays as the safety net for FromExistingAssetLock resumes (lock is already on-chain; sizing was decided by a prior caller). 2. **Retention sweep on .failed retry.** When a .failed controller is restarted via startFunding, the original scheduleRetentionSweep Task had already exited on the .failed arm. If the retry succeeded, the controller transitioned to .completed with no sweep watching, sticking in controllers indefinitely and locking that recipient out of new shielded fundings until app restart. Spawn a fresh sweep on the retry branch. Idempotent because the previous sweep already returned. --- .../wallet/shielded/fund_from_asset_lock.rs | 33 +++++++++++++++++++ ...ShieldedFundFromAssetLockCoordinator.swift | 17 ++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) 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 349a695b16..86f0964834 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 @@ -98,6 +98,39 @@ impl PlatformWallet { // build, ~30s, only to reject downstream). validate_shielded_recipients(&recipients)?; + // Pre-broadcast sizing guard for the `FromWalletBalance` path: + // refuse to build an L1 asset-lock that can't even cover the + // protocol min-fee for Type 18. Without this check the lock + // gets broadcast in Step 2, then Step 3's `checked_sub` + // underflows and we return an error with the L1 outpoint + // already on-chain — a Resume on the orphaned lock + // deterministically hits the same underflow, so the funds + // can't be recovered through this code path. + // + // The Step 3 check (after `resolve_funding_*`) is still the + // authoritative safety net for the `FromExistingAssetLock` + // resume path, where the lock is already on-chain and the + // sizing decision was made by a prior caller. Here we only + // protect the fresh-build path. + if let AssetLockFunding::FromWalletBalance { amount_duffs, .. } = &funding { + let lock_credits = (*amount_duffs) + .checked_mul(CREDITS_PER_DUFF) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock amount overflows credits conversion ({amount_duffs} duffs * \ + {CREDITS_PER_DUFF} credits/duff > u64::MAX)" + )) + })?; + let min_fee_credits = self.shield_from_asset_lock_min_fee()?; + if lock_credits <= min_fee_credits { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "asset lock ({lock_credits} credits, from {amount_duffs} duffs) is at or \ + below the protocol min fee ({min_fee_credits} credits) — refusing to \ + broadcast a single-use L1 outpoint that would be unrecoverable on resume" + ))); + } + } + // Single-flight: serialise shield-class operations on this // wallet so two concurrent calls can't race the asset-lock // tracker into a half-consumed state. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift index 541f68a98e..a8ad016b24 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift @@ -129,12 +129,17 @@ final class ShieldedFundFromAssetLockCoordinator: ObservableObject { return .blockedByOtherWalletFunding(blocker) } existing.submit(body: body) - // No retention sweep here — the slot is sticky on - // .failed (we want the user to see + dismiss the - // error) and a duplicate sweep on retry would - // spawn a second 30s poll Task against the same - // controller. Sweep was already scheduled when the - // controller was first created. + // Spawn a fresh retention sweep. The original sweep + // exited the moment the first attempt hit `.failed` + // (see `scheduleRetentionSweep`'s `.failed: return` + // arm) — without scheduling a new one, a retry that + // succeeds would transition to `.completed` with no + // Task watching for the 30s auto-purge, leaving the + // slot stuck in `controllers` indefinitely and + // locking that recipient out of new fundings until + // app restart. Idempotent once the previous sweep + // returned, so no risk of duplicate observers. + scheduleRetentionSweep(key: key, controller: existing) return .started(existing) } }