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/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", + ); +} 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-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 12c15a0c7a..c9898f693e 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,202 @@ 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. +/// 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 the full `lock_value − min_fee` credits. +/// +/// 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. +/// - `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 = unsafe { + 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 { + // 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( + 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 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..86f0964834 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -0,0 +1,550 @@ +//! 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, 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 +//! `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::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, + 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` — 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 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. + /// * `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)?; + + // 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. + let _shield_guard = self.shield_guard.lock().await; + + // 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, + path, + tracked_out_point, + } = match self + .asset_locks + .resolve_funding_with_is_timeout_fallback( + funding, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + /* 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: 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 + // 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| { + 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_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. +/// +/// 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 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, 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(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 \ + (multi-output Orchard bundles for Type 18 not yet wired through DPP); got {}", + recipients.len() + ))); + } + 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(()) +} + +#[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_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!( + msg.contains("currently ignores explicit recipient credits"), + "unexpected error: {msg}" + ); + } + + #[test] + 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/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) // ------------------------------------------------------------------------- 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..85e5d6cb8d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -0,0 +1,268 @@ +import Foundation +import DashSDKFFI + +/// Recipient entry for `shieldedFundFromAssetLock(...)`. +/// +/// 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. + public let recipientRaw43: Data + + /// 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? = 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: 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, + 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))" + ) + } + // 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 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/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..d4e38dc3db --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockController.swift @@ -0,0 +1,131 @@ +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 + + /// 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) { + self.walletId = walletId + self.recipientRaw43 = recipientRaw43 + } + + /// 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..a8ad016b24 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/ShieldedFundFromAssetLockCoordinator.swift @@ -0,0 +1,227 @@ +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`), 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). + 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 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 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, + body: @escaping () async throws -> Void + ) -> StartFundingResult { + 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 .started(existing) + case .idle, .failed: + // 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) + // 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) + } + } + // 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 + ) + controllers[key] = controller + controller.submit(body: body) + scheduleRetentionSweep(key: key, controller: 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 + /// "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..58d429bb83 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockProgressView.swift @@ -0,0 +1,365 @@ +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) + 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() + } + } + +} 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..7dff964d16 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift @@ -0,0 +1,556 @@ +// 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. +// * 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 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). + +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" + + // 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 + /// 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 + if canSubmit { + submitSection + } + } else { + walletSection + coreFundingSection + recipientSection + amountSection + 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) + } + } + + // 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("Amount") + } footer: { + if let lockDuffs = parsedDuffs { + Text( + "\(formatDuffs(lockDuffs)) will be locked on L1. " + + "The shielded pool receives lock value minus the Platform " + + "minimum fee. Minimum lock: \(formatDuffs(Self.minDuffs))." + ) + } else { + Text("Minimum: \(formatDuffs(Self.minDuffs)).") + } + } + } + + 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) + 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 } + + 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) + ] + ) + } + } 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) + ] + ) + } + } + + // 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 + switch coordinator.startFunding( + walletId: walletId, + recipientRaw43: recipient, + body: body + ) { + 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 + /// 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 + } + + /// 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) + guard duffsDouble.isFinite, duffsDouble <= Double(UInt64.max) else { return nil } + return UInt64(duffsDouble.rounded(.toNearestOrAwayFromZero)) + } + + private var canSubmit: Bool { + if resumeFromLock != nil { + return recipientRaw43 != nil && activeController == nil + } + let amount = parsedDuffs ?? 0 + return fundingCoreAccountIndex != nil + && recipientRaw43 != nil + && 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 + } + } + } + + /// 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.. String { + let dash = Double(duffs) / Double(Self.duffsPerDash) + return String(format: "%.8f DASH", 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 + } +}