diff --git a/Cargo.lock b/Cargo.lock index e03cdd61243..f094d150639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4821,6 +4821,7 @@ dependencies = [ "dash-spv", "dashcore", "dpp", + "drive-proof-verifier", "grovedb-commitment-tree", "hex", "image", diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/mod.rs index a8ccf95277d..bbc74d5f780 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/mod.rs @@ -27,7 +27,7 @@ use platform_version::version::PlatformVersion; impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetLockTransition { #[cfg(feature = "state-transition-signing")] - async fn try_from_asset_lock_with_signer>( + async fn try_from_asset_lock_with_signer_and_private_key>( asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], inputs: BTreeMap, @@ -43,7 +43,7 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL .address_funding_from_asset_lock_transition { 0 => Ok( - AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer::( + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer_and_private_key::( asset_lock_proof, asset_lock_proof_private_key, inputs, @@ -56,7 +56,52 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL .await?, ), version => Err(ProtocolError::UnknownVersionMismatch { - method: "AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signer" + method: + "AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signer_and_private_key" + .to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_asset_lock_with_signers( + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + inputs: BTreeMap, + outputs: BTreeMap>, + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + asset_lock_signer: &AS, + user_fee_increase: UserFeeIncrease, + platform_version: &PlatformVersion, + ) -> Result + where + S: Signer, + AS: ::key_wallet::signer::Signer, + { + match platform_version + .dpp + .state_transition_conversion_versions + .address_funding_from_asset_lock_transition + { + 0 => Ok( + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signers::( + asset_lock_proof, + asset_lock_proof_path, + inputs, + outputs, + fee_strategy, + signer, + asset_lock_signer, + user_fee_increase, + platform_version, + ) + .await?, + ), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signers" .to_string(), known_versions: vec![0], received: version, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/v0/mod.rs index 4be95e3be6b..30900ea993c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/methods/v0/mod.rs @@ -16,9 +16,24 @@ use crate::{prelude::UserFeeIncrease, state_transition::StateTransition, Protoco use platform_version::version::PlatformVersion; pub trait AddressFundingFromAssetLockTransitionMethodsV0 { + /// Build an `AddressFundingFromAssetLock` state transition where + /// the asset-lock-proof signature is produced from a raw private + /// key held in-process. + /// + /// `signer` signs each input's `AddressWitness` (one per + /// `inputs.keys()`) over the transition's signable bytes; the + /// outer state-transition signature is produced from + /// `asset_lock_proof_private_key` via + /// [`dashcore::signer::sign`]. + /// + /// Prefer [`Self::try_from_asset_lock_with_signers`] when the + /// asset-lock key lives outside Rust (Swift / hardware wallet / + /// HSM): the `_with_signers` variant routes asset-lock signing + /// through an external [`key_wallet::signer::Signer`] so the + /// private key never crosses the FFI boundary as raw bytes. #[cfg(feature = "state-transition-signing")] #[allow(clippy::too_many_arguments)] - async fn try_from_asset_lock_with_signer>( + async fn try_from_asset_lock_with_signer_and_private_key>( asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], inputs: BTreeMap, @@ -29,6 +44,36 @@ pub trait AddressFundingFromAssetLockTransitionMethodsV0 { platform_version: &PlatformVersion, ) -> Result; + /// Build an `AddressFundingFromAssetLock` state transition where + /// the asset-lock-proof signature is produced by an external + /// [`key_wallet::signer::Signer`]. + /// + /// `signer` (`S: Signer`) signs each input's + /// `AddressWitness` (same as the legacy + /// `try_from_asset_lock_with_signer_and_private_key` path), while + /// `asset_lock_signer` (`AS: ::key_wallet::signer::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). + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + #[allow(clippy::too_many_arguments)] + async fn try_from_asset_lock_with_signers( + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + inputs: BTreeMap, + outputs: BTreeMap>, + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + asset_lock_signer: &AS, + user_fee_increase: UserFeeIncrease, + platform_version: &PlatformVersion, + ) -> Result + where + S: Signer, + AS: ::key_wallet::signer::Signer; + /// Get State Transition Type fn get_type() -> StateTransitionType { StateTransitionType::AddressFundingFromAssetLock diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/v0/v0_methods.rs index 573f466aebc..9397493b787 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/v0/v0_methods.rs @@ -22,7 +22,7 @@ use platform_version::version::PlatformVersion; impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetLockTransitionV0 { #[cfg(feature = "state-transition-signing")] - async fn try_from_asset_lock_with_signer>( + async fn try_from_asset_lock_with_signer_and_private_key>( asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], inputs: BTreeMap, @@ -32,13 +32,6 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL user_fee_increase: UserFeeIncrease, _platform_version: &PlatformVersion, ) -> Result { - tracing::debug!("try_from_asset_lock_with_signer: Started"); - tracing::debug!( - input_count = inputs.len(), - output_count = outputs.len(), - "try_from_asset_lock_with_signer" - ); - // Create the unsigned transition let mut address_funding_transition = AddressFundingFromAssetLockTransitionV0 { asset_lock_proof, @@ -65,7 +58,63 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL } address_funding_transition.input_witnesses = input_witnesses; - tracing::debug!("try_from_asset_lock_with_signer: Successfully created transition"); Ok(address_funding_transition.into()) } + + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_asset_lock_with_signers( + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + inputs: BTreeMap, + outputs: BTreeMap>, + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + asset_lock_signer: &AS, + user_fee_increase: UserFeeIncrease, + _platform_version: &PlatformVersion, + ) -> Result + where + S: Signer, + AS: ::key_wallet::signer::Signer, + { + // Build the unsigned inner transition. The outer wrapper + // signature and the per-input witnesses are both + // `#[platform_signable(exclude_from_sig_hash)]`, so they + // don't affect the signable bytes the per-input signer + // produces — we can compute signable bytes once with both + // empty. + let mut address_funding_transition = AddressFundingFromAssetLockTransitionV0 { + asset_lock_proof, + inputs: inputs.clone(), + outputs, + fee_strategy, + user_fee_increase, + signature: Default::default(), + input_witnesses: Vec::new(), + }; + + let state_transition: StateTransition = address_funding_transition.clone().into(); + let signable_bytes = state_transition.signable_bytes()?; + + // Sign per-input witnesses up front so the input_witnesses + // field is populated before we hand the inner over to the + // outer ST for the asset-lock signature. + let mut input_witnesses: Vec = Vec::with_capacity(inputs.len()); + for address in inputs.keys() { + input_witnesses.push(signer.sign_create_witness(address, &signable_bytes).await?); + } + address_funding_transition.input_witnesses = input_witnesses; + + // Build the outer ST and route 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 = address_funding_transition.into(); + state_transition + .sign_with_core_signer(asset_lock_proof_path, asset_lock_signer) + .await?; + + Ok(state_transition) + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs index d87640b2381..37ef2e295fc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs @@ -386,7 +386,7 @@ mod tests { fee_strategy: Vec, user_fee_increase: u16, ) -> StateTransition { - AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer( + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer_and_private_key( asset_lock_proof, asset_lock_private_key, inputs, diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index 0bfee8c03b4..d7900326ce2 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -2609,7 +2609,7 @@ impl NetworkStrategy { tracing::debug!(?outputs, "Preparing funding transition"); let funding_transition = - AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer( + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer_and_private_key( asset_lock_proof, asset_lock_private_key.as_slice(), BTreeMap::new(), diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index 74875b35ed9..5815898af57 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -17,7 +17,7 @@ use dashcore::hashes::Hash; use dpp::identity::accessors::IdentityGettersV0; -use platform_wallet::wallet::identity::types::funding::IdentityFunding; +use platform_wallet::AssetLockFunding; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -105,7 +105,7 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( }; identity_wallet .register_identity_with_funding( - IdentityFunding::FromWalletBalance { + AssetLockFunding::FromWalletBalance { amount_duffs, account_index, }, @@ -141,7 +141,7 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( /// user now wants to consume the lock from the /// "Fund from unused Asset Lock" picker in `CreateIdentityView`. /// -/// The Rust side dispatches via [`IdentityFunding::FromExistingAssetLock`] +/// The Rust side dispatches via [`AssetLockFunding::FromExistingAssetLock`] /// inside the same `register_identity_with_funding` helper used by the /// wallet-balance path — the resume logic and IS→CL fallback live /// there, not here. This FFI is a thin marshaler. @@ -220,7 +220,7 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc }; identity_wallet .register_identity_with_funding( - IdentityFunding::FromExistingAssetLock { + AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, }, identity_index, diff --git a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs index bf0ada81a8c..3c7e6219623 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs @@ -204,7 +204,7 @@ pub unsafe fn parse_outputs( } // --------------------------------------------------------------------------- -// Funding address entry (for fund_from_asset_lock) +// Funding address entry (for top_up) // --------------------------------------------------------------------------- /// Address entry for asset lock funding. Exactly one must have `has_balance = false`. diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs deleted file mode 100644 index 979c29bf1d3..00000000000 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! FFI bindings for funding platform addresses from asset locks. - -use crate::check_ptr; -use crate::error::*; -use crate::handle::*; -use crate::platform_address_types::*; -use crate::{unwrap_option_or_return, unwrap_result_or_return}; -use dpp::address_funds::PlatformAddress; -use rs_sdk_ffi::{SignerHandle, VTableSigner}; - -use super::runtime; - -/// Fund platform addresses from a Core L1 asset lock. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock( - handle: Handle, - account_index: u32, - addresses: *const FundingAddressEntryFFI, - addresses_count: usize, - asset_lock_proof_bytes: *const u8, - asset_lock_proof_len: usize, - private_key_bytes: *const u8, - fee_strategy: *const FeeStrategyStepFFI, - fee_strategy_count: usize, - signer_address_handle: *mut SignerHandle, - out_changeset: *mut PlatformAddressChangeSetFFI, -) -> PlatformWalletFFIResult { - check_ptr!(out_changeset); - check_ptr!(addresses); - check_ptr!(asset_lock_proof_bytes); - check_ptr!(private_key_bytes); - check_ptr!(signer_address_handle); - - let mut address_map = std::collections::BTreeMap::new(); - for entry in std::slice::from_raw_parts(addresses, addresses_count) { - let addr = unwrap_result_or_return!(PlatformAddress::try_from(entry.address)); - let balance = if entry.has_balance { - Some(entry.balance) - } else { - None - }; - address_map.insert(addr, balance); - } - - let proof_bytes = std::slice::from_raw_parts(asset_lock_proof_bytes, asset_lock_proof_len); - let (asset_lock_proof, _): (dpp::prelude::AssetLockProof, usize) = unwrap_result_or_return!( - dpp::bincode::decode_from_slice(proof_bytes, dpp::bincode::config::standard(),) - ); - - let key_array = &*(private_key_bytes as *const [u8; 32]); - let private_key = unwrap_result_or_return!(dashcore::PrivateKey::from_byte_array( - key_array, - crate::types::Network::Mainnet, - )); - - let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); - - let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); - - let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { - runtime().block_on(wallet.fund_from_asset_lock( - account_index, - address_map, - asset_lock_proof, - private_key, - fee, - address_signer, - )) - }); - let result = unwrap_option_or_return!(option); - let changeset = unwrap_result_or_return!(result); - *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); - PlatformWalletFFIResult::ok() -} diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs index d0a3cd724fd..40d3809d59e 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs @@ -2,15 +2,15 @@ //! //! Mirrors the structure of `platform_wallet::wallet::platform_addresses`. -mod fund_from_asset_lock; mod sync; +mod top_up; mod transfer; mod wallet; mod withdrawal; // Re-export all FFI types and functions. -pub use fund_from_asset_lock::*; pub use sync::*; +pub use top_up::*; pub use transfer::*; pub use wallet::*; pub use withdrawal::*; diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs new file mode 100644 index 00000000000..6cf143fb50a --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs @@ -0,0 +1,260 @@ +//! Asset-lock-funded platform-address top-up driven by external signers. +//! +//! Two signer surfaces are deliberately distinct (mirrors the +//! identity-side `platform_wallet_register_identity_with_funding_signer`): +//! +//! - `signer_address_handle` (a `*mut rs_sdk_ffi::SignerHandle`) is +//! the platform-address per-input-witness signer (ECDSA over each +//! `AddressWitness`). +//! - `core_signer_handle` (a `*mut MnemonicResolverHandle`) is the +//! Core-side ECDSA signer used for the asset-lock's outer +//! state-transition signature, atomically deriving + signing + +//! zeroising inside the Keychain-resolver trust boundary. +//! +//! Two entry points: one for fresh wallet-balance funding, one for +//! resuming a tracked asset lock by outpoint (crash-recovery shape). + +use std::collections::BTreeMap; + +use dashcore::hashes::Hash; +use dpp::address_funds::PlatformAddress; +use platform_wallet::wallet::asset_lock::AssetLockFunding; +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::platform_address_types::*; +use crate::runtime::block_on_worker; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +/// Fund platform addresses from a Core L1 asset lock, orchestrated +/// through the wallet's `AssetLockManager` (build → IS-or-CL → submit +/// → consume), with the asset-lock signature produced by an external +/// `MnemonicResolverHandle`. +/// +/// `account_index` selects the BIP44 *standard* Core account whose +/// UTXOs fund the asset lock (only BIP44 standard accounts supported +/// today). `platform_account_index` selects which platform-payment +/// account the recipient addresses belong to. +/// +/// # Safety +/// - `signer_address_handle` must be a valid, non-destroyed +/// `*mut SignerHandle` produced by `dash_sdk_signer_create_with_ctx`. +/// The caller retains ownership. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains +/// ownership. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_address_wallet_top_up_signer( + handle: Handle, + amount_duffs: u64, + account_index: u32, + platform_account_index: u32, + addresses: *const FundingAddressEntryFFI, + addresses_count: usize, + fee_strategy: *const FeeStrategyStepFFI, + fee_strategy_count: usize, + signer_address_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, + out_changeset: *mut PlatformAddressChangeSetFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_changeset); + check_ptr!(addresses); + check_ptr!(signer_address_handle); + check_ptr!(core_signer_handle); + + let address_map = match decode_funding_addresses(addresses, addresses_count) { + Ok(m) => m, + Err(e) => return e, + }; + + let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); + + // Round-trip both handles through `usize` so the spawned future's + // capture is `Send + 'static` (raw pointers are `!Send`). + let signer_addr = signer_address_handle as usize; + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { + let wallet_clone = wallet.clone(); + let wallet_id = wallet.wallet_id(); + // Pull the network from the wallet rather than threading it + // as an extra FFI parameter — it would be ambiguous if the + // two disagreed. + let network = wallet.network(); + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — both handles are + // pinned alive for the duration of this FFI call. + let address_signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + wallet_clone + .top_up( + AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index, + }, + platform_account_index, + address_map, + fee, + address_signer, + &asset_lock_signer, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let changeset = unwrap_result_or_return!(result); + *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); + PlatformWalletFFIResult::ok() +} + +/// Resume a platform-address funding flow from an already-tracked +/// asset lock by outpoint. +/// +/// Sister to [`platform_address_wallet_top_up_signer`]: +/// instead of building a fresh asset-lock transaction, pick up an +/// existing tracked lock and drive whatever stages remain +/// (broadcast, IS/CL wait, Platform submission). Use case mirrors +/// the identity-side resume path — a prior attempt left the lock +/// in storage at `Broadcast` / `InstantSendLocked` / `ChainLocked` +/// but the address-funding ST never completed, and the user wants +/// to consume the lock from the "Unused Asset Locks" picker. +/// +/// # Safety +/// - `out_point` must be a valid, non-null pointer to an +/// `OutPointFFI` (32-byte raw txid + u32 vout). The caller retains +/// ownership; the FFI does not free it. +/// - `signer_address_handle` / `core_signer_handle` — see +/// [`platform_address_wallet_top_up_signer`]. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_address_wallet_resume_top_up_with_existing_asset_lock_signer( + handle: Handle, + out_point: *const OutPointFFI, + platform_account_index: u32, + addresses: *const FundingAddressEntryFFI, + addresses_count: usize, + fee_strategy: *const FeeStrategyStepFFI, + fee_strategy_count: usize, + signer_address_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, + out_changeset: *mut PlatformAddressChangeSetFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_changeset); + check_ptr!(addresses); + check_ptr!(out_point); + check_ptr!(signer_address_handle); + check_ptr!(core_signer_handle); + + let address_map = match decode_funding_addresses(addresses, addresses_count) { + Ok(m) => m, + Err(e) => return e, + }; + + let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); + + 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 signer_addr = signer_address_handle as usize; + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { + let wallet_clone = wallet.clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — both handles are + // pinned alive for the duration of this FFI call. + let address_signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + wallet_clone + .top_up( + AssetLockFunding::FromExistingAssetLock { + out_point: resume_outpoint, + }, + platform_account_index, + address_map, + fee, + address_signer, + &asset_lock_signer, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let changeset = unwrap_result_or_return!(result); + *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); + PlatformWalletFFIResult::ok() +} + +/// Decode an FFI array of `FundingAddressEntryFFI` into the +/// `BTreeMap>` shape that +/// `top_up` consumes. +/// +/// # Safety +/// - `addresses` must be a valid, non-null pointer to an array of +/// at least `addresses_count` `FundingAddressEntryFFI` entries +/// WHEN `addresses_count > 0`. A `0`-count call is handled +/// short-circuit and does not dereference `addresses`, so a +/// dangling non-null sentinel pointer in that case is sound. +pub(super) unsafe fn decode_funding_addresses( + addresses: *const FundingAddressEntryFFI, + addresses_count: usize, +) -> Result>, PlatformWalletFFIResult> { + // Short-circuit the empty case to dodge the + // `slice::from_raw_parts` safety contract entirely when no + // dereference is needed. Downstream `validate_recipient_addresses` + // rejects empty with a typed error. + if addresses_count == 0 { + return Ok(BTreeMap::new()); + } + let mut address_map = BTreeMap::new(); + for entry in std::slice::from_raw_parts(addresses, addresses_count) { + let addr = PlatformAddress::try_from(entry.address).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid platform address: {e}"), + ) + })?; + let balance = if entry.has_balance { + Some(entry.balance) + } else { + None + }; + // Reject duplicates rather than silently collapsing them. + // The Swift wrapper's `topUpFromCorePreflight` already + // dedupes client-side, but this is the FFI boundary — + // callers other than our Swift code (or a future Swift + // bug) could pass duplicates and we'd silently lose the + // earlier entry's amount. + if address_map.insert(addr, balance).is_some() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "duplicate platform address in funding request".to_string(), + )); + } + } + Ok(address_map) +} diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index f9e96c08c7e..56556fea9b1 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -10,6 +10,7 @@ description = "Platform wallet with identity management support" # Dash Platform packages dpp = { path = "../rs-dpp" } dash-sdk = { path = "../rs-sdk", default-features = false, features = ["dashpay-contract", "dpns-contract", "wallet"] } +drive-proof-verifier = { path = "../rs-drive-proof-verifier", default-features = false } platform-encryption = { path = "../rs-platform-encryption" } # Key wallet dependencies (from rust-dashcore) diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 7be90bd9de9..289a71378fd 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -48,6 +48,7 @@ pub use manager::PlatformWalletManager; pub use spv::SpvRuntime; pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +pub use wallet::asset_lock::AssetLockFunding; pub use wallet::core::CoreWallet; pub use wallet::core::WalletBalance; // DashPay types + crypto helpers re-exported through the identity @@ -59,9 +60,9 @@ pub use wallet::identity::network::{ pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, derive_contact_payment_addresses, derive_contact_xpub, BlockTime, ContactRequest, - ContactXpubData, DashPayProfile, DpnsNameInfo, EstablishedContact, IdentityFunding, - IdentityLocation, IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, - ProfileUpdate, RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, + ContactXpubData, DashPayProfile, DpnsNameInfo, EstablishedContact, IdentityLocation, + IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, + RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::platform_wallet::PlatformWalletInfo; pub use wallet::PlatformAddressTag; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index a39e1e52fcd..85bc5571c0a 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -82,7 +82,14 @@ impl AssetLockManager { /// Queue an `AssetLockChangeSet` onto the per-wallet persister. /// No-op when the changeset is empty. - pub(super) fn queue_asset_lock_changeset(&self, cs: AssetLockChangeSet) { + /// + /// `pub(crate)` so the orchestrated funding flows in + /// `wallet::platform_addresses` and `wallet::identity::network` + /// can pair an `advance_asset_lock_status` call with a flush + /// without going through the asset-lock module boundary. The + /// internal-only flag (no `pub`) keeps the API hidden from + /// crate consumers. + pub(crate) fn queue_asset_lock_changeset(&self, cs: AssetLockChangeSet) { if ::is_empty(&cs) { return; } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs index 6deb2dbd0c9..32f545ed42f 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs @@ -6,7 +6,9 @@ mod build; pub mod lock_notify_handler; pub mod manager; +pub mod orchestration; mod sync; pub mod tracked; pub use lock_notify_handler::LockNotifyHandler; +pub use orchestration::AssetLockFunding; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs new file mode 100644 index 00000000000..932efcc7113 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -0,0 +1,567 @@ +//! Submission-side orchestration shared across asset-lock-funded +//! flows (identity registration, identity top-up, platform-address +//! funding). +//! +//! The asset-lock acquisition pipeline (build tx → wait IS/CL) lives +//! in [`crate::wallet::asset_lock::build`] / +//! [`crate::wallet::asset_lock::sync`]. This module holds the *next* +//! layer: turning a funding-source choice into a usable +//! `(AssetLockProof, DerivationPath, OutPoint)` triple, and the +//! Platform-side retry policy applied to whatever ST consumes that +//! triple. +//! +//! Two pieces here: +//! +//! - [`submit_with_cl_height_retry`] — retry-on-10506 wrapper that +//! bumps `user_fee_increase` between attempts so Tenderdash's +//! invalid-tx hash cache (24h on mainnet/testnet) can't silently +//! drop resubmits. +//! - [`AssetLockManager::resolve_funding_with_is_timeout_fallback`] — +//! maps an [`AssetLockFunding`] choice to a [`FundingResolution`] +//! that the caller can drive into an IS→CL retry when the IS-lock +//! timed out. +//! +//! Both are funding-target-agnostic: the caller passes the +//! `AssetLockFundingType` + destination index (identity_index for +//! identity flows, address index for address-funding flows) into +//! the resolver, and supplies its own ST submission closure to the +//! retry helper. The constants here pin the same timeouts across +//! every flow so register / top-up / address-fund can't drift apart +//! on their CL fallback or retry-budget windows. + +use std::time::Duration; + +use dashcore::OutPoint; +use dpp::prelude::AssetLockProof; +use key_wallet::bip32::DerivationPath; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + +use dash_sdk::platform::transition::put_settings::PutSettings; + +use crate::broadcaster::TransactionBroadcaster; +use crate::error::{as_asset_lock_proof_cl_height_too_low, PlatformWalletError}; +use crate::wallet::asset_lock::manager::AssetLockManager; + +// --------------------------------------------------------------------------- +// Timeout policy +// --------------------------------------------------------------------------- + +/// Time we will wait for a ChainLock to materialise after an IS-lock +/// fallback is triggered. 180s mirrors the existing fallback shape and +/// is roughly the worst-case ChainLock latency we've observed in +/// testnet operation. Promoted to a constant so the registration, +/// top-up, and address-funding flows can't drift apart on this number. +pub(crate) const CL_FALLBACK_TIMEOUT: Duration = Duration::from_secs(180); + +/// Delay between retries when Platform rejected with CL-height-too-low. +/// Each retry bumps `PutSettings::user_fee_increase` so the ST hash +/// changes (Tenderdash caches rejected ST hashes for ~24h on +/// mainnet/testnet — `keep-invalid-txs-in-cache = true` in dashmate's +/// tenderdash template, hardcoded at +/// `packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot:355`). +pub(crate) const CL_HEIGHT_RETRY_DELAY: Duration = Duration::from_secs(15); + +/// Total time we'll keep retrying before surfacing the error. Sized to +/// cover Platform's `create-empty-blocks-interval` (3m on mainnet) +/// plus a 30s safety margin: if Platform hasn't observed the wallet's +/// ChainLock by then, the lag is no longer routine and we need +/// operator visibility instead of further silent retries. The +/// rejection error carries Platform's `current_core_chain_locked_height` +/// each round so logs name the laggard node's reported tip explicitly. +pub(crate) const CL_HEIGHT_RETRY_BUDGET: Duration = Duration::from_secs(210); + +// --------------------------------------------------------------------------- +// Funding choice +// --------------------------------------------------------------------------- + +/// How to source the asset lock funding for an asset-lock-funded +/// Platform operation (identity registration, identity top-up, +/// platform-address funding). +/// +/// Resolved by [`AssetLockManager::resolve_funding_with_is_timeout_fallback`] +/// into an `(AssetLockProof, DerivationPath, OutPoint)` triple that +/// the `_with_signer` SDK methods can consume. The `OutPoint` is +/// retained for cleanup (so the tracked-asset-lock row can be removed +/// on success) and for IS→CL fallback (so the consumed lock can be +/// looked up by outpoint when the IS proof times out or is rejected). +/// +/// Every variant produces a lock tracked by this wallet's +/// [`AssetLockManager`]. The IS→CL fallback paths (300s IS-timeout in +/// the resolver, Platform IS-rejection retry in the submission layer) +/// require the lock to be tracked so they can look it up by outpoint +/// and drive the wait. An earlier variant (`UseAssetLock`) accepted +/// an externally-built proof and skipped tracking — it broke the +/// IS→CL fallback unrecoverably because the lock was invisible to +/// `upgrade_to_chain_lock_proof` (which short-circuits with +/// `Asset lock {} is not tracked`). The variant was removed; future +/// callers that hold an external proof should register it through +/// `AssetLockManager` first, then use `FromExistingAssetLock`. +#[derive(Debug, Clone)] +pub enum AssetLockFunding { + /// Build an asset lock from wallet UTXOs for the given amount. + /// + /// The caller passes the `AssetLockFundingType` into the resolver + /// to select which BIP44 derivation family is used for the + /// credit-output key: + /// + /// - `IdentityRegistration` — for identity register flows + /// - `IdentityTopUp` — for identity top-up flows + /// - `AssetLockAddressTopUp` — for platform-address funding flows + /// - others — see [`AssetLockFundingType`] + /// + /// `account_index` selects which BIP44 *standard* account (by + /// BIP44 account index) supplies the UTXOs. Only BIP44 standard + /// accounts are supported today — CoinJoin / BIP32 funding for + /// any asset-lock-funded operation is out of scope and would + /// require additional plumbing in + /// [`AssetLockManager::create_funded_asset_lock_proof`]. + FromWalletBalance { + /// Amount to lock (in duffs). + amount_duffs: u64, + /// BIP44 standard-account index to draw the funding UTXOs from. + /// + /// Only BIP44 standard accounts (`AccountType::Standard` with + /// `StandardAccountTypeTag::Bip44`) are supported today; + /// CoinJoin / BIP32 are not. + account_index: u32, + }, + + /// Resume from a tracked asset lock identified by its outpoint + /// (txid + output index). + /// + /// The asset lock must already be tracked by the + /// [`AssetLockManager`]. The manager resumes from whatever stage + /// the lock is at (built, broadcast, IS-locked, or chain-locked) + /// and re-derives the credit-output derivation path; the + /// signer-driven submission path then passes that path back to + /// the same signer when constructing the consuming state + /// transition. + FromExistingAssetLock { + /// The outpoint identifying the tracked asset lock (txid + output index). + out_point: OutPoint, + }, +} + +// --------------------------------------------------------------------------- +// Funding resolution outcome +// --------------------------------------------------------------------------- + +/// Outcome of resolving an [`AssetLockFunding`] to a concrete +/// asset-lock proof + derivation path. +/// +/// `tracked_out_point` is always `Some` — every `AssetLockFunding` +/// variant produces a lock tracked by this wallet's `AssetLockManager` +/// (the now-removed `UseAssetLock` variant was the only one that set +/// it to `None`, and its absence broke both the IS-timeout and the +/// IS-rejection fallback paths because they need the tracked entry to +/// drive `upgrade_to_chain_lock_proof`). The outpoint drives IS→CL +/// fallback (look up the lock by outpoint) and cleanup (remove the +/// lock on Platform success). Kept as `Option` for now so +/// future variants without lifecycle tracking can be added without +/// reshaping `FundingResolution`; today every code path that +/// constructs it passes `Some`. +pub(crate) struct ResolvedFunding { + pub(crate) proof: AssetLockProof, + pub(crate) path: DerivationPath, + pub(crate) tracked_out_point: Option, +} + +/// Outcome of [`AssetLockManager::resolve_funding_with_is_timeout_fallback`]: +/// either a fully-resolved funding triple, or an IS-timeout that the +/// caller can convert to a ChainLock retry using the recovered +/// outpoint. +pub(crate) enum FundingResolution { + Resolved(ResolvedFunding), + /// IS-lock didn't propagate within the asset-lock manager's wait + /// window. The outpoint of the tracked-but-unproven lock is + /// surfaced so the caller can drive an `upgrade_to_chain_lock_proof` + /// retry without re-walking the tracked-asset-lock map. + IsTimeout { + out_point: OutPoint, + }, +} + +// --------------------------------------------------------------------------- +// Retry helper +// --------------------------------------------------------------------------- + +/// Submit a state transition with automatic retry on +/// `InvalidAssetLockProofCoreChainHeightError` (consensus code 10506). +/// +/// Each retry bumps `settings.user_fee_increase` so the resubmitted ST +/// hashes differently — Tenderdash caches rejected ST hashes for ~24h +/// on mainnet/testnet (`keep-invalid-txs-in-cache = true`), so an +/// identical-bytes resubmit would be silently dropped before reaching +/// Platform's CheckTx. +/// +/// **Retry scope.** This wrapper retries ONLY consensus code 10506 +/// (CL-height-too-low). Every other `dash_sdk::Error` — including +/// transient gRPC `UNAVAILABLE`, DAPI 502/503, RST_STREAM, TLS +/// resets, DNS hiccups, mempool-full bounces — falls through +/// immediately on the first attempt. The rationale: the DAPI client +/// layer (`rs-dapi-client`) below the SDK already implements its own +/// per-request retry + endpoint rotation for transport-level +/// failures, so a second layer of generic retries here would +/// over-retry (or worse, retry an ST submission that the lower +/// layer already retried, against a different validator, leaving +/// two in-flight copies). 10506 is uniquely retried at THIS layer +/// because the fix requires a different `user_fee_increase` value — +/// the lower layer can't know that. +/// +/// If the underlying SDK starts surfacing a transient error class +/// that the DAPI client doesn't already retry, widen this match +/// rather than wrapping `submit_with_cl_height_retry` in a second +/// generic-retry loop at the caller. +/// +/// We don't pre-flight Platform's chain-lock tip — that's an unproven +/// self-report and a malicious DAPI node could stall us indefinitely. +/// Submit optimistically and react to Platform's deterministic CheckTx +/// rejection. The cryptographic finality guarantee on the wallet side +/// comes from the SPV-verified ChainLock BLS signature +/// (`info.core_wallet.metadata.last_applied_chain_lock`) that promoted +/// the asset-lock tx's record context to `InChainLockedBlock` before +/// we constructed the proof. +/// +/// **Trust model.** This function treats the 10506 response as +/// authoritative — there's no client-side cryptographic proof or +/// DAPI-quorum check on the consensus error. That trust boundary +/// lives one layer up: a node that fabricates rejections is a +/// malicious DAPI node, and the right defense is to stop submitting +/// to it (DAPI client rotation / blacklisting), not to engineer +/// around fabricated responses here. Bumping `user_fee_increase` in +/// response to a forged 10506 can grief a user (wasted credits, +/// slowed registration) but cannot extract value — identity fees +/// flow to Platform validators, not DAPI nodes — so the attack is +/// unprofitable. The bounded retry budget further caps the grief +/// impact: at most `CL_HEIGHT_RETRY_BUDGET / CL_HEIGHT_RETRY_DELAY` +/// bumps (~14 with the current 210s/15s pair) before the loop +/// surfaces the error. A proper fix would require cryptographically +/// verifiable consensus errors (a quorum signature on rejection, or +/// validator attestation) and is tracked as future work; doing it +/// in-place here would either re-implement DAPI client trust or +/// require an SDK API change neither of which belong in this PR. +/// +/// Non-CL-height errors are passed through unchanged. Every rejection +/// is logged with both the proof's claimed height and Platform's +/// currently observed Core tip so persistent lag (>3.5min) attributes +/// to the specific DAPI node we hit and not to a generic timeout. +/// +/// **Cancellation:** not cancellation-safe. If the caller drops the +/// returned future mid-sleep, the bumped `user_fee_increase` is lost +/// and any in-flight submission whose response we never consume +/// remains queued in Tenderdash's mempool until it commits or expires. +/// Callers wrapping this in `tokio::select!` with a short timeout +/// should be prepared to either retry (settings reset to original) +/// or accept that Platform may still execute the dropped attempt. +pub(crate) async fn submit_with_cl_height_retry( + mut settings: Option, + submit: F, +) -> Result +where + F: Fn(Option) -> Fut, + Fut: std::future::Future>, +{ + let started = tokio::time::Instant::now(); + let deadline = started + CL_HEIGHT_RETRY_BUDGET; + let mut attempt: u32 = 0; + loop { + attempt += 1; + match submit(settings).await { + Ok(r) => return Ok(r), + Err(e) => { + let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else { + return Err(e); + }; + let elapsed = started.elapsed(); + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + tracing::error!( + "Platform rejected ChainLock proof: CL height too low \ + (proof claimed height={}, Platform tip={}, attempt {}, \ + elapsed {}s) — retry budget of {}s exhausted; surfacing \ + error. Platform's reported tip is stuck — likely a lagging \ + or misbehaving DAPI node.", + detail.proof_core_chain_locked_height(), + detail.current_core_chain_locked_height(), + attempt, + elapsed.as_secs(), + CL_HEIGHT_RETRY_BUDGET.as_secs(), + ); + return Err(e); + } + let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY); + tracing::warn!( + "Platform rejected ChainLock proof: CL height too low \ + (proof claimed height={}, Platform tip={}, attempt {}, \ + elapsed {}s); bumping user_fee_increase and waiting {}s \ + before retry", + detail.proof_core_chain_locked_height(), + detail.current_core_chain_locked_height(), + attempt, + elapsed.as_secs(), + sleep_for.as_secs(), + ); + settings = Some(bump_user_fee_increase(settings.unwrap_or_default())); + tokio::time::sleep(sleep_for).await; + } + } + } +} + +/// Bump `user_fee_increase` by 1 (saturating at `u16::MAX`). +fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings { + settings.user_fee_increase = Some(settings.user_fee_increase.unwrap_or(0).saturating_add(1)); + settings +} + +// --------------------------------------------------------------------------- +// Free helpers +// --------------------------------------------------------------------------- + +/// Extract the outpoint from an asset lock proof. Total over the +/// `AssetLockProof` enum — neither variant can fail to produce an +/// outpoint (Instant: derived from embedded tx + output index; +/// Chain: carried directly as `out_point`). +/// +/// Free function (not an `AssetLockManager` method) because it has +/// no dependency on the manager's state and the manager is generic +/// over its broadcaster `B`, which would force callers into explicit +/// turbofish. +pub(crate) fn out_point_from_proof(proof: &AssetLockProof) -> OutPoint { + match proof { + AssetLockProof::Instant(instant) => { + OutPoint::new(instant.transaction().txid(), instant.output_index()) + } + AssetLockProof::Chain(chain) => chain.out_point, + } +} + +// --------------------------------------------------------------------------- +// Resolver on AssetLockManager +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// Resolve an [`AssetLockFunding`] to a concrete proof + path + + /// (optional) tracked outpoint, capturing the IS-lock timeout case + /// as a structured outcome so the caller can drive a CL retry. + /// + /// `funding_type` selects the BIP44 account the `FromWalletBalance` + /// variant pulls UTXOs from (`IdentityRegistration` for register, + /// `IdentityTopUp` for top up, `AssetLockAddressTopUp` for + /// platform-address funding). The other variants ignore it — they + /// don't build new asset locks. + /// + /// `destination_index` is the within-family HD index — the + /// identity index for identity flows, the address index for + /// platform-address funding flows. Routed straight through to + /// [`Self::create_funded_asset_lock_proof`]. + /// + /// # IS-lock timeout handling + /// + /// For the two variants that internally invoke `wait_for_proof` + /// (`FromWalletBalance` and `FromExistingAssetLock`), an IS-lock + /// that never propagates within the 300s window surfaces as + /// `PlatformWalletError::FinalityTimeout(out_point)`. The variant + /// carries the *exact* outpoint that timed out (no + /// `find_tracked_unproven_lock` BTreeMap walk needed), so the + /// `IsTimeout` outcome is built directly from the error payload. + pub(crate) async fn resolve_funding_with_is_timeout_fallback( + &self, + funding: AssetLockFunding, + funding_type: AssetLockFundingType, + destination_index: u32, + asset_lock_signer: &AS, + ) -> Result + where + AS: ::key_wallet::signer::Signer + Send + Sync, + { + match funding { + AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index, + } => { + match self + .create_funded_asset_lock_proof( + amount_duffs, + account_index, + funding_type, + destination_index, + asset_lock_signer, + ) + .await + { + Ok((proof, path, out_point)) => { + Ok(FundingResolution::Resolved(ResolvedFunding { + proof, + path, + tracked_out_point: Some(out_point), + })) + } + Err(PlatformWalletError::FinalityTimeout(out_point)) => { + // The exact outpoint that timed out comes from + // the error payload — no `find_tracked_unproven_lock` + // walk needed (which would pick BTreeMap-first + // on multiple unproven locks for the same key). + Ok(FundingResolution::IsTimeout { out_point }) + } + Err(e) => Err(e), + } + } + AssetLockFunding::FromExistingAssetLock { out_point } => { + match self + .resume_asset_lock(&out_point, Duration::from_secs(300)) + .await + { + Ok((proof, path)) => Ok(FundingResolution::Resolved(ResolvedFunding { + proof, + path, + tracked_out_point: Some(out_point), + })), + Err(PlatformWalletError::FinalityTimeout(timed_out)) => { + // Outpoint from the error (which equals + // `out_point` from the variant in practice — + // but we use the error payload for parity + // with the FromWalletBalance arm). + Ok(FundingResolution::IsTimeout { + out_point: timed_out, + }) + } + Err(e) => Err(e), + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Fabricate the SDK-side 10506 error shape exactly as + /// `as_asset_lock_proof_cl_height_too_low` recognizes it + /// (`error.rs:223-242`). Both the matcher and the constructor are + /// pinned here so a future SDK refactor that changes the variant + /// path can't silently desynchronize the retry helper from its + /// test surface. + fn fabricate_cl_height_too_low_error() -> dash_sdk::Error { + use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + + let consensus = + ConsensusError::BasicError(BasicError::InvalidAssetLockProofCoreChainHeightError( + InvalidAssetLockProofCoreChainHeightError::new( + /* proof_core_chain_locked_height */ 100, + /* current_core_chain_locked_height */ 99, + ), + )); + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(consensus))) + } + + /// Pins two load-bearing invariants of `submit_with_cl_height_retry`: + /// + /// 1. Every retry under repeated `InvalidAssetLockProofCoreChainHeightError` + /// (consensus 10506) receives a `PutSettings::user_fee_increase` + /// strictly greater than the previous attempt. The retry's purpose + /// is to bypass Tenderdash's 24h invalid-tx hash cache by changing + /// the ST signable bytes; if `user_fee_increase` weren't bumped, + /// every resubmit would hash identically and be silently dropped. + /// This invariant regressed silently once in the earlier + /// swift-funding-with-asset-lock series — the test exists so it + /// can't regress quietly again. + /// + /// 2. After `CL_HEIGHT_RETRY_BUDGET` elapses without a non-10506 + /// outcome, the helper surfaces the original 10506 error rather + /// than looping forever or swallowing it. + /// + /// Driven by `#[tokio::test(start_paused = true)]` + manual + /// `tokio::time::advance` so the retry's `CL_HEIGHT_RETRY_DELAY` + /// sleeps fire instantly and total test wall time is sub-millisecond. + #[tokio::test(start_paused = true)] + async fn submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget() { + use dash_sdk::platform::transition::put_settings::PutSettings; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::sync::Mutex; + + // Capture each invocation's `user_fee_increase` (None on the + // first call, then Some(N) for each retry). Shared `Mutex` + // because the closure is `Fn` and each future is independent. + let captured: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let call_count = Arc::new(AtomicU32::new(0)); + let captured_clone = captured.clone(); + let call_count_clone = call_count.clone(); + + // Stub `submit` closure: always returns 10506 so the retry loop + // exhausts its budget. The helper's return type is generic over + // `R`; pin `R = ()` for this test (we never reach the success + // path). + let submit = move |settings: Option| { + let captured = captured_clone.clone(); + let call_count = call_count_clone.clone(); + async move { + call_count.fetch_add(1, Ordering::SeqCst); + captured + .lock() + .await + .push(settings.and_then(|s| s.user_fee_increase)); + Err::<(), _>(fabricate_cl_height_too_low_error()) + } + }; + + let result = submit_with_cl_height_retry(None, submit).await; + + // Surfaced error must be the original 10506 — not a wrapper, not + // a "timeout" type, not None. + assert!( + result.is_err(), + "retry must surface the underlying error on budget exhaust" + ); + let surfaced_err = result.unwrap_err(); + assert!( + as_asset_lock_proof_cl_height_too_low(&surfaced_err).is_some(), + "surfaced error must still be the InvalidAssetLockProofCoreChainHeightError" + ); + + let captured = captured.lock().await; + let call_n = call_count.load(Ordering::SeqCst); + + // At least 2 attempts (initial + at least one retry); upper + // bound is `budget / delay` + 1 with a small slack for the + // boundary check. + let max_expected = + (CL_HEIGHT_RETRY_BUDGET.as_secs() / CL_HEIGHT_RETRY_DELAY.as_secs()) as u32 + 2; + assert!( + call_n >= 2 && call_n <= max_expected, + "expected 2..={max_expected} attempts (initial + retries up to budget), got {call_n}" + ); + assert_eq!( + captured.len() as u32, + call_n, + "every closure invocation should have recorded a fee value" + ); + + // First attempt: caller-supplied `None` settings → user_fee_increase = None. + assert_eq!( + captured[0], None, + "first attempt must use the caller-supplied `None` settings (no bump yet)" + ); + + // Subsequent attempts: strictly increasing `user_fee_increase`, + // starting from Some(1) and bumping by 1 each retry. The exact + // values are load-bearing: Tenderdash hashes the full ST bytes + // including this field, so consecutive identical values would + // hit the 24h invalid-tx cache. + for (i, val) in captured.iter().enumerate().skip(1) { + let expected = Some(i as u16); + assert_eq!( + *val, expected, + "attempt #{i} (1-indexed retry) must carry user_fee_increase = {expected:?}, got {val:?}" + ); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index c2c567185da..66d1cbdfa90 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -34,6 +34,6 @@ pub use state::{BlockTime, IdentityLocation, IdentityManager, ManagedIdentity, R pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ ContactRequest, DashPayProfile, DashpayAddressMatch, DpnsNameInfo, EstablishedContact, - IdentityFunding, IdentityStatus, KeyStorage, PaymentDirection, PaymentEntry, PaymentStatus, - PrivateKeyData, ProfileUpdate, + IdentityStatus, KeyStorage, PaymentDirection, PaymentEntry, PaymentStatus, PrivateKeyData, + ProfileUpdate, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 83a94d30117..71ffaa6c149 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -25,7 +25,6 @@ use std::sync::Arc; use dashcore::secp256k1::PublicKey; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyType}; -use dpp::prelude::AssetLockProof; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType}; use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, @@ -466,17 +465,4 @@ impl IdentityWallet { )) }) } - - /// Extract the outpoint from an asset lock proof. Total over the - /// `AssetLockProof` enum — neither variant can fail to produce an - /// outpoint (Instant: derived from embedded tx + output index; - /// Chain: carried directly as `out_point`). - pub(super) fn out_point_from_proof(proof: &AssetLockProof) -> dashcore::OutPoint { - match proof { - AssetLockProof::Instant(instant) => { - dashcore::OutPoint::new(instant.transaction().txid(), instant.output_index()) - } - AssetLockProof::Chain(chain) => chain.out_point, - } - } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index 0943792fad5..0e9f76b8c38 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -49,9 +49,7 @@ //! — once Platform finally accepts the submission. use std::collections::BTreeMap; -use std::time::Duration; -use dashcore::OutPoint; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::v0::IdentityV0; @@ -60,289 +58,22 @@ use dpp::identity::IdentityPublicKey; use dpp::identity::KeyID; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; -use dpp::prelude::AssetLockProof; use dpp::prelude::Identifier; -use key_wallet::bip32::DerivationPath; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; -use crate::error::{ - as_asset_lock_proof_cl_height_too_low, is_instant_lock_proof_invalid, PlatformWalletError, +use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError}; +use crate::wallet::asset_lock::orchestration::{ + out_point_from_proof, submit_with_cl_height_retry, FundingResolution, ResolvedFunding, + CL_FALLBACK_TIMEOUT, }; -use crate::wallet::identity::types::funding::IdentityFunding; +use crate::wallet::asset_lock::AssetLockFunding; use super::*; -// --------------------------------------------------------------------------- -// Timeout policy -// --------------------------------------------------------------------------- - -/// Time we will wait for a ChainLock to materialise after an IS-lock -/// fallback is triggered. 180s mirrors the existing fallback shape and -/// is roughly the worst-case ChainLock latency we've observed in -/// testnet operation. Promoted to a constant so the registration and -/// top-up flows can't drift apart on this number. -const CL_FALLBACK_TIMEOUT: Duration = Duration::from_secs(180); - -/// Delay between retries when Platform rejected with CL-height-too-low. -/// Each retry bumps `PutSettings::user_fee_increase` so the ST hash -/// changes (Tenderdash caches rejected ST hashes for ~24h on -/// mainnet/testnet — `keep-invalid-txs-in-cache = true` in dashmate's -/// tenderdash template, hardcoded at -/// `packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot:355`). -const CL_HEIGHT_RETRY_DELAY: Duration = Duration::from_secs(15); - -/// Total time we'll keep retrying before surfacing the error. Sized to -/// cover Platform's `create-empty-blocks-interval` (3m on mainnet) -/// plus a 30s safety margin: if Platform hasn't observed the wallet's -/// ChainLock by then, the lag is no longer routine and we need -/// operator visibility instead of further silent retries. The -/// rejection error carries Platform's `current_core_chain_locked_height` -/// each round so logs name the laggard node's reported tip explicitly. -const CL_HEIGHT_RETRY_BUDGET: Duration = Duration::from_secs(210); - -/// Submit a state transition with automatic retry on -/// `InvalidAssetLockProofCoreChainHeightError` (consensus code 10506). -/// -/// Each retry bumps `settings.user_fee_increase` so the resubmitted ST -/// hashes differently — Tenderdash caches rejected ST hashes for ~24h -/// on mainnet/testnet (`keep-invalid-txs-in-cache = true`), so an -/// identical-bytes resubmit would be silently dropped before reaching -/// Platform's CheckTx. -/// -/// We don't pre-flight Platform's chain-lock tip — that's an unproven -/// self-report and a malicious DAPI node could stall us indefinitely. -/// Submit optimistically and react to Platform's deterministic CheckTx -/// rejection. The cryptographic finality guarantee on the wallet side -/// comes from the SPV-verified ChainLock BLS signature -/// (`info.core_wallet.metadata.last_applied_chain_lock`) that promoted -/// the asset-lock tx's record context to `InChainLockedBlock` before -/// we constructed the proof. -/// -/// **Trust model.** This function treats the 10506 response as -/// authoritative — there's no client-side cryptographic proof or -/// DAPI-quorum check on the consensus error. That trust boundary -/// lives one layer up: a node that fabricates rejections is a -/// malicious DAPI node, and the right defense is to stop submitting -/// to it (DAPI client rotation / blacklisting), not to engineer -/// around fabricated responses here. Bumping `user_fee_increase` in -/// response to a forged 10506 can grief a user (wasted credits, -/// slowed registration) but cannot extract value — identity fees -/// flow to Platform validators, not DAPI nodes — so the attack is -/// unprofitable. The bounded retry budget further caps the grief -/// impact: at most `CL_HEIGHT_RETRY_BUDGET / CL_HEIGHT_RETRY_DELAY` -/// bumps (~14 with the current 210s/15s pair) before the loop -/// surfaces the error. A proper fix would require cryptographically -/// verifiable consensus errors (a quorum signature on rejection, or -/// validator attestation) and is tracked as future work; doing it -/// in-place here would either re-implement DAPI client trust or -/// require an SDK API change neither of which belong in this PR. -/// -/// Non-CL-height errors are passed through unchanged. Every rejection -/// is logged with both the proof's claimed height and Platform's -/// currently observed Core tip so persistent lag (>3.5min) attributes -/// to the specific DAPI node we hit and not to a generic timeout. -/// -/// **Cancellation:** not cancellation-safe. If the caller drops the -/// returned future mid-sleep, the bumped `user_fee_increase` is lost -/// and any in-flight submission whose response we never consume -/// remains queued in Tenderdash's mempool until it commits or expires. -/// Callers wrapping this in `tokio::select!` with a short timeout -/// should be prepared to either retry (settings reset to original) -/// or accept that Platform may still execute the dropped attempt. -async fn submit_with_cl_height_retry( - mut settings: Option, - submit: F, -) -> Result -where - F: Fn(Option) -> Fut, - Fut: std::future::Future>, -{ - let started = tokio::time::Instant::now(); - let deadline = started + CL_HEIGHT_RETRY_BUDGET; - let mut attempt: u32 = 0; - loop { - attempt += 1; - match submit(settings).await { - Ok(r) => return Ok(r), - Err(e) => { - let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else { - return Err(e); - }; - let elapsed = started.elapsed(); - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - tracing::error!( - "Platform rejected ChainLock proof: CL height too low \ - (proof claimed height={}, Platform tip={}, attempt {}, \ - elapsed {}s) — retry budget of {}s exhausted; surfacing \ - error. Platform's reported tip is stuck — likely a lagging \ - or misbehaving DAPI node.", - detail.proof_core_chain_locked_height(), - detail.current_core_chain_locked_height(), - attempt, - elapsed.as_secs(), - CL_HEIGHT_RETRY_BUDGET.as_secs(), - ); - return Err(e); - } - let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY); - tracing::warn!( - "Platform rejected ChainLock proof: CL height too low \ - (proof claimed height={}, Platform tip={}, attempt {}, \ - elapsed {}s); bumping user_fee_increase and waiting {}s \ - before retry", - detail.proof_core_chain_locked_height(), - detail.current_core_chain_locked_height(), - attempt, - elapsed.as_secs(), - sleep_for.as_secs(), - ); - settings = Some(bump_user_fee_increase(settings.unwrap_or_default())); - tokio::time::sleep(sleep_for).await; - } - } - } -} - -/// Bump `user_fee_increase` by 1 (saturating at `u16::MAX`). -fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings { - settings.user_fee_increase = Some(settings.user_fee_increase.unwrap_or(0).saturating_add(1)); - settings -} - -// --------------------------------------------------------------------------- -// Funding resolution (shared between register and top-up) -// --------------------------------------------------------------------------- - -/// Outcome of resolving an [`IdentityFunding`] to a concrete asset-lock -/// proof + derivation path. -/// -/// `tracked_out_point` is always `Some` — every `IdentityFunding` -/// variant produces a lock tracked by this wallet's `AssetLockManager` -/// (the now-removed `UseAssetLock` variant was the only one that set -/// it to `None`, and its absence broke both the IS-timeout and the -/// IS-rejection fallback paths because they need the tracked entry to -/// drive `upgrade_to_chain_lock_proof`). The outpoint drives IS→CL -/// fallback (look up the lock by outpoint) and cleanup (remove the -/// lock on Platform success). Kept as `Option` for now so -/// future variants without lifecycle tracking can be added without -/// reshaping `FundingResolution`; today every code path that -/// constructs it passes `Some`. -struct ResolvedFunding { - proof: AssetLockProof, - path: DerivationPath, - tracked_out_point: Option, -} - -/// Outcome of [`IdentityWallet::resolve_funding_with_is_timeout_fallback`]: -/// either a fully-resolved funding triple, or an IS-timeout that the -/// caller can convert to a ChainLock retry using the recovered -/// outpoint. -enum FundingResolution { - Resolved(ResolvedFunding), - /// IS-lock didn't propagate within the asset-lock manager's wait - /// window. The outpoint of the tracked-but-unproven lock is - /// surfaced so the caller can drive an `upgrade_to_chain_lock_proof` - /// retry without re-walking the tracked-asset-lock map. - IsTimeout { - out_point: OutPoint, - }, -} - -impl IdentityWallet { - /// Resolve an [`IdentityFunding`] to a concrete proof + path + - /// (optional) tracked outpoint, capturing the IS-lock timeout case - /// as a structured outcome so the caller can drive a CL retry. - /// - /// `funding_type` selects the BIP44 account the - /// `FromWalletBalance` variant pulls UTXOs from - /// (`IdentityRegistration` for register, `IdentityTopUp` for top - /// up). The other variants ignore it — they don't build new - /// asset locks. - /// - /// # IS-lock timeout handling - /// - /// For the two variants that internally invoke `wait_for_proof` - /// (`FromWalletBalance` and `FromExistingAssetLock`), an IS-lock - /// that never propagates within the 300s window surfaces as - /// `PlatformWalletError::FinalityTimeout(out_point)`. The variant - /// carries the *exact* outpoint that timed out (no - /// `find_tracked_unproven_lock` BTreeMap walk needed), so the - /// `IsTimeout` outcome is built directly from the error payload. - async fn resolve_funding_with_is_timeout_fallback( - &self, - funding: IdentityFunding, - funding_type: AssetLockFundingType, - identity_index: u32, - asset_lock_signer: &AS, - ) -> Result - where - AS: ::key_wallet::signer::Signer + Send + Sync, - { - match funding { - IdentityFunding::FromWalletBalance { - amount_duffs, - account_index, - } => { - match self - .asset_locks - .create_funded_asset_lock_proof( - amount_duffs, - account_index, - funding_type, - identity_index, - asset_lock_signer, - ) - .await - { - Ok((proof, path, out_point)) => { - Ok(FundingResolution::Resolved(ResolvedFunding { - proof, - path, - tracked_out_point: Some(out_point), - })) - } - Err(PlatformWalletError::FinalityTimeout(out_point)) => { - // The exact outpoint that timed out comes from - // the error payload — no `find_tracked_unproven_lock` - // walk needed (which would pick BTreeMap-first - // on multiple unproven locks for the same key). - Ok(FundingResolution::IsTimeout { out_point }) - } - Err(e) => Err(e), - } - } - IdentityFunding::FromExistingAssetLock { out_point } => { - match self - .asset_locks - .resume_asset_lock(&out_point, Duration::from_secs(300)) - .await - { - Ok((proof, path)) => Ok(FundingResolution::Resolved(ResolvedFunding { - proof, - path, - tracked_out_point: Some(out_point), - })), - Err(PlatformWalletError::FinalityTimeout(timed_out)) => { - // Outpoint from the error (which equals - // `out_point` from the variant in practice — - // but we use the error payload for parity - // with the FromWalletBalance arm). - Ok(FundingResolution::IsTimeout { - out_point: timed_out, - }) - } - Err(e) => Err(e), - } - } - } - } -} - // --------------------------------------------------------------------------- // register // --------------------------------------------------------------------------- @@ -356,7 +87,7 @@ impl IdentityWallet { /// AUTHENTICATION key (the IdentityCreate transition itself /// must be signed by a MASTER-level identity key, and we pin /// that role on id=0 by convention). - /// 2. Resolve the [`IdentityFunding`] to an asset-lock proof + + /// 2. Resolve the [`AssetLockFunding`] to an asset-lock proof + /// derivation path. /// 3. Submit via /// `Identity::put_to_platform_and_wait_for_response_with_signer` @@ -388,7 +119,7 @@ impl IdentityWallet { /// attempts can resume via `FromExistingAssetLock`. pub async fn register_identity_with_funding( &self, - funding: IdentityFunding, + funding: AssetLockFunding, identity_index: u32, keys_map: BTreeMap, identity_signer: &S, @@ -435,6 +166,7 @@ impl IdentityWallet { path, tracked_out_point, } = match self + .asset_locks .resolve_funding_with_is_timeout_fallback( funding, AssetLockFundingType::IdentityRegistration, @@ -493,7 +225,7 @@ impl IdentityWallet { // Both retries share the original `placeholder` Identity; the // CL-height retry also iterates inside the IS→CL fallback branch // so a freshly-upgraded CL proof gets the same patience. - let proof_out_point = Self::out_point_from_proof(&proof); + let proof_out_point = out_point_from_proof(&proof); let identity = match submit_with_cl_height_retry(settings, |s| { placeholder.put_to_platform_and_wait_for_response_with_signer( &self.sdk, @@ -575,7 +307,7 @@ impl IdentityWallet { // Step 5: clean up the tracked asset lock — Platform has // accepted the registration and the credit output is now - // consumed. Both `IdentityFunding` variants produce a tracked + // consumed. Both `AssetLockFunding` variants produce a tracked // lock so `tracked_out_point` is always `Some` today; the // `Option` is retained for future variants that may not have // wallet-owned lifecycle. @@ -610,7 +342,7 @@ impl IdentityWallet { /// /// 1. Look up the identity by `identity_id` in the local /// `IdentityManager`. Return `IdentityNotFound` if missing. - /// 2. Resolve the [`IdentityFunding`] to an asset-lock proof. + /// 2. Resolve the [`AssetLockFunding`] to an asset-lock proof. /// 3. Submit via `Identity::top_up_identity_with_signer` inside /// `submit_with_cl_height_retry`, with IS→CL fallback on /// Core-side timeout and Platform-side rejection (same as @@ -620,7 +352,7 @@ impl IdentityWallet { pub async fn top_up_identity_with_funding( &self, identity_id: &Identifier, - funding: IdentityFunding, + funding: AssetLockFunding, asset_lock_signer: &AS, settings: Option, ) -> Result @@ -654,6 +386,7 @@ impl IdentityWallet { path, tracked_out_point, } = match self + .asset_locks .resolve_funding_with_is_timeout_fallback( funding, AssetLockFundingType::IdentityTopUp, @@ -690,7 +423,7 @@ impl IdentityWallet { // bump `user_fee_increase` to bypass Tenderdash's invalid-tx // cache, and IS-lock rejection triggers an IS→CL upgrade on the // same outpoint. - let proof_out_point = Self::out_point_from_proof(&proof); + let proof_out_point = out_point_from_proof(&proof); let new_balance = match submit_with_cl_height_retry(settings, |s| { identity.top_up_identity_with_signer( &self.sdk, @@ -826,127 +559,4 @@ mod tests { (wallet-state mismatch is a hard failure)" ); } - - /// Fabricate the SDK-side 10506 error shape exactly as - /// `as_asset_lock_proof_cl_height_too_low` recognizes it - /// (`error.rs:223-242`). Both the matcher and the constructor are - /// pinned here so a future SDK refactor that changes the variant - /// path can't silently desynchronize the retry helper from its - /// test surface. - fn fabricate_cl_height_too_low_error() -> dash_sdk::Error { - use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; - use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; - - let consensus = - ConsensusError::BasicError(BasicError::InvalidAssetLockProofCoreChainHeightError( - InvalidAssetLockProofCoreChainHeightError::new( - /* proof_core_chain_locked_height */ 100, - /* current_core_chain_locked_height */ 99, - ), - )); - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(consensus))) - } - - /// Pins two load-bearing invariants of `submit_with_cl_height_retry`: - /// - /// 1. Every retry under repeated `InvalidAssetLockProofCoreChainHeightError` - /// (consensus 10506) receives a `PutSettings::user_fee_increase` - /// strictly greater than the previous attempt. The retry's purpose - /// is to bypass Tenderdash's 24h invalid-tx hash cache by changing - /// the ST signable bytes; if `user_fee_increase` weren't bumped, - /// every resubmit would hash identically and be silently dropped. - /// This invariant regressed silently once in this PR series — the - /// test exists so it can't regress quietly again. - /// - /// 2. After `CL_HEIGHT_RETRY_BUDGET` elapses without a non-10506 - /// outcome, the helper surfaces the original 10506 error rather - /// than looping forever or swallowing it. - /// - /// Driven by `#[tokio::test(start_paused = true)]` + manual - /// `tokio::time::advance` so the retry's `CL_HEIGHT_RETRY_DELAY` - /// sleeps fire instantly and total test wall time is sub-millisecond. - #[tokio::test(start_paused = true)] - async fn submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget() { - use dash_sdk::platform::transition::put_settings::PutSettings; - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::Arc; - use tokio::sync::Mutex; - - // Capture each invocation's `user_fee_increase` (None on the - // first call, then Some(N) for each retry). Shared `Mutex` - // because the closure is `Fn` and each future is independent. - let captured: Arc>>> = Arc::new(Mutex::new(Vec::new())); - let call_count = Arc::new(AtomicU32::new(0)); - let captured_clone = captured.clone(); - let call_count_clone = call_count.clone(); - - // Stub `submit` closure: always returns 10506 so the retry loop - // exhausts its budget. The helper's return type is generic over - // `R`; pin `R = ()` for this test (we never reach the success - // path). - let submit = move |settings: Option| { - let captured = captured_clone.clone(); - let call_count = call_count_clone.clone(); - async move { - call_count.fetch_add(1, Ordering::SeqCst); - captured - .lock() - .await - .push(settings.and_then(|s| s.user_fee_increase)); - Err::<(), _>(fabricate_cl_height_too_low_error()) - } - }; - - let result = submit_with_cl_height_retry(None, submit).await; - - // Surfaced error must be the original 10506 — not a wrapper, not - // a "timeout" type, not None. - assert!( - result.is_err(), - "retry must surface the underlying error on budget exhaust" - ); - let surfaced_err = result.unwrap_err(); - assert!( - as_asset_lock_proof_cl_height_too_low(&surfaced_err).is_some(), - "surfaced error must still be the InvalidAssetLockProofCoreChainHeightError" - ); - - let captured = captured.lock().await; - let call_n = call_count.load(Ordering::SeqCst); - - // At least 2 attempts (initial + at least one retry); upper - // bound is `budget / delay` + 1 with a small slack for the - // boundary check. - let max_expected = - (CL_HEIGHT_RETRY_BUDGET.as_secs() / CL_HEIGHT_RETRY_DELAY.as_secs()) as u32 + 2; - assert!( - call_n >= 2 && call_n <= max_expected, - "expected 2..={max_expected} attempts (initial + retries up to budget), got {call_n}" - ); - assert_eq!( - captured.len() as u32, - call_n, - "every closure invocation should have recorded a fee value" - ); - - // First attempt: caller-supplied `None` settings → user_fee_increase = None. - assert_eq!( - captured[0], None, - "first attempt must use the caller-supplied `None` settings (no bump yet)" - ); - - // Subsequent attempts: strictly increasing `user_fee_increase`, - // starting from Some(1) and bumping by 1 each retry. The exact - // values are load-bearing: Tenderdash hashes the full ST bytes - // including this field, so consecutive identical values would - // hit the 24h invalid-tx cache. - for (i, val) in captured.iter().enumerate().skip(1) { - let expected = Some(i as u16); - assert_eq!( - *val, expected, - "attempt #{i} (1-indexed retry) must carry user_fee_increase = {expected:?}, got {val:?}" - ); - } - } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs index e78cd64d209..d0451ef7fb1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs @@ -14,7 +14,7 @@ use dpp::prelude::Identifier; use dash_sdk::platform::transition::put_settings::PutSettings; use crate::error::PlatformWalletError; -use crate::wallet::identity::types::funding::IdentityFunding; +use crate::wallet::asset_lock::AssetLockFunding; use super::*; @@ -24,7 +24,7 @@ impl IdentityWallet { /// /// Convenience wrapper around /// [`top_up_identity_with_funding`](Self::top_up_identity_with_funding) - /// for the common case (`IdentityFunding::FromWalletBalance`). + /// for the common case (`AssetLockFunding::FromWalletBalance`). /// /// # Arguments /// @@ -51,7 +51,7 @@ impl IdentityWallet { { self.top_up_identity_with_funding( identity_id, - IdentityFunding::FromWalletBalance { + AssetLockFunding::FromWalletBalance { amount_duffs, account_index, }, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs b/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs deleted file mode 100644 index 96f0c514561..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Funding source enum for identity registration and top-up. -//! -//! The single source of funding for any identity lifecycle operation -//! (register, top up) is [`IdentityFunding`]. The funded-but-not-yet- -//! consumed asset lock is the central concept — every variant ends up -//! resolved to `(AssetLockProof, DerivationPath)` before submission to -//! Platform. -//! -//! ## Historical note -//! -//! Earlier iterations carried two parallel funding enums -//! (`IdentityFundingMethod` / `TopUpFundingMethod`) consumed by -//! per-operation helpers. They were merged into [`IdentityFunding`] -//! once the registration and top-up high-level helpers grew identical -//! funding-resolution + IS→CL fallback shapes — at which point the -//! per-operation enums were dead weight. The merge happened in iter -//! 4 of the swift-funding-with-asset-lock series. - -use dashcore::OutPoint; - -/// How to fund an identity operation (registration, top-up). -/// -/// Resolved by the high-level `register_identity_with_funding` / -/// `top_up_identity_with_funding` helpers into an -/// `(AssetLockProof, DerivationPath, OutPoint)` triple that the -/// `_with_signer` SDK methods can consume. The `OutPoint` is retained -/// for cleanup (so the tracked-asset-lock row can be removed on -/// success) and for IS→CL fallback (so the consumed lock can be -/// looked up by outpoint when the IS proof times out or is rejected). -/// -/// Every variant produces a lock tracked by this wallet's -/// [`AssetLockManager`](crate::wallet::asset_lock::manager::AssetLockManager). -/// The IS→CL fallback paths (300s IS-timeout in the resolver, Platform -/// IS-rejection retry in the submission layer) require the lock to be -/// tracked so they can look it up by outpoint and drive the wait. An -/// earlier variant (`UseAssetLock`) accepted an externally-built proof -/// and skipped tracking — it broke the IS→CL fallback unrecoverably -/// because the lock was invisible to `upgrade_to_chain_lock_proof` -/// (which short-circuits with `Asset lock {} is not tracked`). The -/// variant was removed; future callers that hold an external proof -/// should register it through `AssetLockManager` first, then use -/// `FromExistingAssetLock`. -#[derive(Debug, Clone)] -pub enum IdentityFunding { - /// Build an asset lock from wallet UTXOs for the given amount. - /// - /// The helper picks the appropriate funding account - /// (`identity_registration` for register, `identity_topup` for top - /// up), builds the asset-lock tx, broadcasts it, waits for an - /// IS-lock proof, and falls back to ChainLock if the IS-lock times - /// out (300s) or is rejected at Platform. - /// - /// `account_index` selects which BIP44 *standard* account (by - /// BIP44 account index) supplies the UTXOs. Only BIP44 standard - /// accounts are supported today — CoinJoin / BIP32 funding for - /// identity registration is out of scope and would require - /// additional plumbing in `create_funded_asset_lock_proof`. - FromWalletBalance { - /// Amount to lock (in duffs). - amount_duffs: u64, - /// BIP44 standard-account index to draw the funding UTXOs from. - /// - /// Only BIP44 standard accounts (`AccountType::Standard` with - /// `StandardAccountTypeTag::Bip44`) are supported today; - /// CoinJoin / BIP32 are not. - account_index: u32, - }, - - /// Resume from a tracked asset lock identified by its outpoint - /// (txid + output index). - /// - /// The asset lock must already be tracked by the - /// [`AssetLockManager`](crate::wallet::asset_lock::manager::AssetLockManager). - /// The manager resumes from whatever stage the lock is at (built, - /// broadcast, IS-locked, or chain-locked) and re-derives the - /// credit-output derivation path; the signer-driven submission path - /// then passes that path back to the same signer when constructing - /// the IdentityCreate / IdentityTopUp transition. - FromExistingAssetLock { - /// The outpoint identifying the tracked asset lock (txid + output index). - out_point: OutPoint, - }, -} diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs index 05f652b6e51..826d80d0c09 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs @@ -7,7 +7,6 @@ pub mod block_time; pub mod dashpay; -pub mod funding; pub mod key_storage; pub use block_time::BlockTime; @@ -15,5 +14,4 @@ pub use dashpay::{ ContactRequest, DashPayProfile, DashpayAddressMatch, EstablishedContact, PaymentDirection, PaymentEntry, PaymentStatus, ProfileUpdate, }; -pub use funding::IdentityFunding; pub use key_storage::{DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData}; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs deleted file mode 100644 index 927b6d0d575..00000000000 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs +++ /dev/null @@ -1,154 +0,0 @@ -use crate::wallet::PlatformAddressWallet; -use crate::{PlatformAddressChangeSet, PlatformWalletError}; -use dash_sdk::platform::transition::top_up_address::TopUpAddress; -use dashcore::PrivateKey; -use dpp::address_funds::{AddressFundsFeeStrategy, PlatformAddress}; -use dpp::fee::Credits; -use dpp::identity::signer::Signer; -use dpp::prelude::AssetLockProof; -use key_wallet::PlatformP2PKHAddress; -use std::collections::BTreeMap; - -impl PlatformAddressWallet { - /// Fund platform addresses from a Core L1 asset lock. - /// - /// Broadcasts a top-up-address state transition that converts locked Dash - /// into platform credits on the specified addresses. - /// - /// # Arguments - /// - /// * `account_index` - Platform payment account index. - /// * `addresses` - Platform addresses to fund (with current balances for nonce lookup). - /// * `asset_lock_proof` - Proof of the asset lock transaction on Core chain. - /// * `asset_lock_private_key` - Private key corresponding to the asset lock. - /// * `fee_strategy` - How the fee should be deducted. - /// * `address_signer` - Signs each previously-funded input address's - /// contribution. The wallet struct itself carries no key material. - #[allow(clippy::too_many_arguments)] - pub async fn fund_from_asset_lock + Send + Sync>( - &self, - account_index: u32, - addresses: BTreeMap>, - asset_lock_proof: AssetLockProof, - asset_lock_private_key: PrivateKey, - fee_strategy: AddressFundsFeeStrategy, - address_signer: &S, - ) -> Result { - if addresses.is_empty() { - return Err(PlatformWalletError::AddressOperation( - "fund_from_asset_lock requires at least one address".to_string(), - )); - } - - // Exactly one address must have None balance (the funding recipient). - let none_count = addresses.values().filter(|v| v.is_none()).count(); - if none_count != 1 { - return Err(PlatformWalletError::AddressOperation(format!( - "Exactly one address must have None balance (the funding recipient), found {}", - none_count - ))); - } - - // Verify all addresses belong to the specified account. - { - let wm = self.wallet_manager.read().await; - let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( - "Wallet {:?} not found in wallet manager", - hex::encode(self.wallet_id) - )) - })?; - let account = info - .core_wallet - .platform_payment_managed_account_at_index(account_index) - .ok_or_else(|| { - PlatformWalletError::AddressSync(format!( - "No platform payment account at index {}", - account_index - )) - })?; - for addr in addresses.keys() { - let PlatformAddress::P2pkh(hash) = addr else { - return Err(PlatformWalletError::AddressOperation( - "Only P2PKH addresses are supported".to_string(), - )); - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - if !account.contains_platform_address(&p2pkh) { - return Err(PlatformWalletError::AddressNotFound(format!( - "Address {} does not belong to account index {}", - p2pkh, account_index - ))); - } - } - } - - let address_infos = addresses - .top_up( - &self.sdk, - asset_lock_proof, - asset_lock_private_key, - fee_strategy, - address_signer, - None, - ) - .await?; - - // Get the cached key source from the unified provider for gap - // limit maintenance. - let key_source = { - let guard = self.provider.read().await; - guard - .as_ref() - .and_then(|p| p.key_source(&self.wallet_id, account_index)) - }; - - // Update balances in the ManagedPlatformAccount. - let mut wm = self.wallet_manager.write().await; - let mut cs = PlatformAddressChangeSet::default(); - if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { - if let Some(account) = info - .core_wallet - .platform_payment_managed_account_at_index_mut(account_index) - { - for (addr, maybe_info) in address_infos.iter() { - let PlatformAddress::P2pkh(hash) = addr else { - continue; - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - let funds = match maybe_info { - Some(ai) => dash_sdk::platform::address_sync::AddressFunds { - balance: ai.balance, - nonce: ai.nonce, - }, - None => dash_sdk::platform::address_sync::AddressFunds { - balance: 0, - nonce: 0, - }, - }; - account.set_address_credit_balance(p2pkh, funds.balance, key_source.as_ref()); - let address_index = account - .addresses - .addresses - .iter() - .find_map(|(&idx, info)| { - PlatformP2PKHAddress::from_address(&info.address) - .ok() - .filter(|found| *found == p2pkh) - .map(|_| idx) - }) - .unwrap_or(0); - cs.addresses.push(crate::PlatformAddressBalanceEntry { - wallet_id: self.wallet_id, - account_index, - address_index, - address: p2pkh, - funds, - }); - } - } - } - - Ok(cs) - } -} diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs index d216228284a..523880df90d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -6,9 +6,9 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; pub use dpp::prelude::AddressNonce; -mod fund_from_asset_lock; pub(crate) mod provider; mod sync; +mod top_up; mod transfer; mod wallet; mod withdrawal; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs new file mode 100644 index 00000000000..b685facdf14 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs @@ -0,0 +1,466 @@ +//! Orchestrated platform-address funding from a Core asset lock. +//! +//! Mirrors `IdentityWallet::register_identity_with_funding` from the +//! identity-side flow but credits Platform addresses with the asset +//! lock's value via an `AddressFundingFromAssetLockTransition` instead +//! of creating an identity. +//! +//! ## Pipeline +//! +//! 1. **Pre-flight** — exactly-one-`None`-recipient invariant; each +//! address must belong to the supplied platform-payment account. +//! 2. **Resolve funding** — delegate to the shared +//! [`AssetLockManager::resolve_funding_with_is_timeout_fallback`]. +//! `FromWalletBalance` builds an asset-lock tx out of the +//! `AssetLockAddressTopUp` BIP44 family and waits for IS/CL; +//! `FromExistingAssetLock` resumes from a tracked outpoint. +//! 3. **Submit** — `addresses.top_up_with_signers(...)` inside the +//! shared `submit_with_cl_height_retry` wrapper. IS→CL fallback +//! fires both on Core-side timeout (resolver returns `IsTimeout`) +//! and on Platform-side IS rejection +//! (`is_instant_lock_proof_invalid`). +//! 4. **Bookkeeping + cleanup** — write each recipient's new credit +//! balance into `ManagedPlatformAccount` and emit a +//! `PlatformAddressChangeSet`; then `consume_asset_lock` the +//! tracked outpoint so the row is marked `Consumed` (terminal) +//! and dropped from the in-memory tracked-lock map. + +use crate::wallet::asset_lock::orchestration::{ + out_point_from_proof, submit_with_cl_height_retry, AssetLockFunding, FundingResolution, + ResolvedFunding, CL_FALLBACK_TIMEOUT, +}; +use crate::wallet::PlatformAddressWallet; +use crate::{error::is_instant_lock_proof_invalid, PlatformAddressChangeSet, PlatformWalletError}; +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::platform::transition::top_up_address::TopUpAddress; +use dpp::address_funds::{AddressFundsFeeStrategy, PlatformAddress}; +use dpp::fee::Credits; +use dpp::identity::signer::Signer; +use drive_proof_verifier::types::AddressInfos; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use key_wallet::PlatformP2PKHAddress; +use std::collections::BTreeMap; + +impl PlatformAddressWallet { + /// Fund platform addresses from a Core L1 asset lock, with the + /// asset-lock proof signed by an external `key_wallet::signer::Signer`. + /// + /// This is the orchestrated entry point: it covers the full + /// build → broadcast → wait-for-IS-or-CL → submit-with-CL-retry → + /// IS→CL-fallback → consume pipeline. The host never sees the + /// asset-lock private key — both Core-side derivation (inside the + /// asset-lock manager) and ST-side signing + /// (`StateTransition::sign_with_core_signer`) go through + /// `asset_lock_signer`, which atomically derives + signs + + /// zeroises inside its trust boundary. + /// + /// # Arguments + /// + /// * `funding` — How to source the funding asset lock. `FromWalletBalance` + /// builds a fresh asset lock from Core UTXOs; `FromExistingAssetLock` + /// resumes from a tracked outpoint (after app relaunch or a stuck + /// broadcast). + /// * `platform_account_index` — Platform payment account whose + /// addresses receive credits. Used for both the membership + /// pre-flight and the post-success balance write. + /// * `addresses` — Map from recipient `PlatformAddress` to optional + /// amount in credits. Exactly one entry must be `None` — the + /// remainder-after-fees-and-explicit-outputs recipient (the lock + /// is consumed in full, so a remainder bucket is mandatory). + /// * `fee_strategy` — Per-step fee-deduction strategy applied to + /// the address-funding transition. + /// * `address_signer` — Signs per-input `AddressWitness` for any + /// additional inputs from existing platform addresses (today + /// none — combining external inputs with an asset-lock proof is + /// not exercised here, but `AddressFundingFromAssetLockTransitionV0` + /// does allow it). + /// * `asset_lock_signer` — Signs the outer state-transition ECDSA + /// signature against the asset lock's credit-output key. The + /// wallet struct itself carries no key material; signing is + /// atomic + zeroising inside this signer. + /// * `settings` — `PutSettings::user_fee_increase` is threaded + /// through to the ST builder. The CL-height retry wrapper bumps + /// this value on consensus-10506 to bypass Tenderdash's + /// invalid-tx hash cache; the caller's initial value is the + /// starting point. + /// + /// # Latency budget + /// + /// Worst-case wall time stacks at ~690s on the IS-rejection + /// branch: + /// - 300s IS-wait inside the resolver's + /// `create_funded_asset_lock_proof` (`AssetLockManager`'s + /// fixed window before falling back to ChainLock). + /// - 180s CL fallback (`CL_FALLBACK_TIMEOUT`) per `upgrade_to_chain_lock_proof` call. + /// - 210s CL-height retry budget (`CL_HEIGHT_RETRY_BUDGET`) per + /// `submit_with_cl_height_retry` wrapper. + /// - Up to two passes through the submit wrapper on the + /// IS-rejection path: one for the IS proof, one for the + /// upgraded CL proof. + /// + /// Happy-path wall time on a healthy testnet is single-digit + /// seconds (IS-lock typically arrives within 3s of broadcast, + /// CL-height retry never fires). + /// + /// # Cancellation + /// + /// This function is NOT cancellation-safe. The two underlying + /// retry loops (`submit_with_cl_height_retry` and the + /// resolver's internal `wait_for_proof`) use + /// `tokio::time::sleep` / `tokio::sync::Notify` without + /// structured cancellation hooks. If the caller drops the + /// returned future: + /// - Any bumped `user_fee_increase` is lost; the next attempt + /// starts from the caller-supplied value, which may hit + /// Tenderdash's invalid-tx cache for the bumped variants. + /// - In-flight submitted state transitions remain in + /// Tenderdash's mempool until they commit or expire. + /// - The tracked asset lock stays at its last-observed status + /// (`Broadcast` / `InstantSendLocked` / `ChainLocked`) until + /// either `consume_asset_lock` completes or the next resume + /// advances it. + /// + /// The Swift `AddressTopUpController.task` field deliberately + /// does not call `.cancel()` to avoid these partial-state + /// outcomes — the FFI call always runs to completion. UI + /// dismissal hides the progress view without aborting the + /// work; resume picks the lock back up via + /// `FromExistingAssetLock`. + #[allow(clippy::too_many_arguments)] + pub async fn top_up( + &self, + funding: AssetLockFunding, + platform_account_index: u32, + addresses: BTreeMap>, + fee_strategy: AddressFundsFeeStrategy, + address_signer: &S, + asset_lock_signer: &AS, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + AS: ::key_wallet::signer::Signer + Send + Sync, + { + // Step 1: pre-flight. Failing fast here avoids broadcasting + // an unfundable asset-lock tx. + validate_recipient_addresses(self, platform_account_index, &addresses).await?; + + // Step 2: resolve funding. `AssetLockAddressTopUp` selects the + // BIP44 funding family for the Core asset-lock tx. The + // `destination_index = 0` argument is unused by this funding + // type (the resolver only consults it for `IdentityTopUp`), + // so any value is fine. + let ResolvedFunding { + proof, + path, + tracked_out_point, + } = match self + .asset_locks + .resolve_funding_with_is_timeout_fallback( + funding, + AssetLockFundingType::AssetLockAddressTopUp, + /* destination_index */ 0, + asset_lock_signer, + ) + .await? + { + FundingResolution::Resolved(rf) => rf, + FundingResolution::IsTimeout { out_point } => { + tracing::warn!( + "IS-lock did not propagate within 300s for funded platform-address top-up \ + (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?; + // Re-derive the credit-output path. The lock is now + // CL-attached; `resume_asset_lock` short-circuits to + // the existing-proof branch and just hands the path + // back. + 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: submit. Two Platform-side fallback layers (matches + // `register_identity_with_funding`): CL-height-too-low retries + // bump `user_fee_increase` to bypass Tenderdash's invalid-tx + // cache, and IS-lock rejection triggers an IS→CL upgrade on + // the same outpoint. + let proof_out_point = out_point_from_proof(&proof); + let address_infos = match submit_with_cl_height_retry(settings, |s| { + addresses.top_up_with_signers( + &self.sdk, + proof.clone(), + &path, + fee_strategy.clone(), + address_signer, + asset_lock_signer, + s, + ) + }) + .await + { + Ok(infos) => infos, + Err(e) if is_instant_lock_proof_invalid(&e) => { + let out_point = proof_out_point; + tracing::warn!( + "IS-lock proof rejected by Platform for platform-address top-up (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?; + // Advance the tracked status from `InstantSendLocked` + // to `ChainLocked` with the upgraded proof attached + // BEFORE the second submit. If the next call fails + // (transport blip, fresh CL-height race that + // exhausts the retry budget), the row accurately + // reflects the lock's CL-attached state instead of + // the stale IS proof Platform just rejected. The + // catch-up scanner / Resume path then has a + // truthful status to work from. + 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| { + addresses.top_up_with_signers( + &self.sdk, + chain_proof.clone(), + &path, + fee_strategy.clone(), + address_signer, + asset_lock_signer, + s, + ) + }) + .await + .map_err(PlatformWalletError::Sdk)? + } + Err(e) => return Err(PlatformWalletError::Sdk(e)), + }; + + // Step 4: bookkeeping + cleanup. Write the proof-attested + // balances back into ManagedPlatformAccount, then consume the + // tracked asset lock (terminal — marks the row `Consumed` and + // drops it from the in-memory map). + + // Post-condition: every requested recipient must appear in + // the proof-attested `address_infos`. Platform's proof + // verifier should never return an empty (or recipient- + // missing) result for a top-up call that returned `Ok` — + // an empty Ok here would be a DAPI / proof-verifier + // contract violation, NOT a successful zero-credit + // funding. We fail loud rather than silently consume the + // asset lock with no recorded credits. + let expected_recipient_count = addresses.len(); + if address_infos.is_empty() { + return Err(PlatformWalletError::AddressSync(format!( + "Address-funding ST succeeded but the proof returned no address infos \ + (expected {} recipient(s)); refusing to consume the asset lock with \ + no recorded credits", + expected_recipient_count + ))); + } + + let cs = self + .write_address_balances_changeset(platform_account_index, &address_infos) + .await; + + if let Some(out_point) = tracked_out_point { + // Platform DID accept the top-up — propagating an Err + // here would misreport the protocol outcome, since the + // caller's recipient(s) already have credits attested + // by the proof we just decoded. But: the lock row stays + // in non-Consumed status, which means it will surface + // in the Resumable Funding list and the user could try + // to fund it again — Platform would deterministically + // reject the duplicate ST with "lock already consumed". + // + // The expected failure mode is `WalletNotFound` (the + // wallet handle vanished between submit-success and + // this cleanup). Log that as a warn — the user-visible + // recovery path (Resume + Platform's deterministic + // rejection) is benign. Anything else is an unexpected + // invariant violation — log as `error` so it shows up + // in operational dashboards. + 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 Platform submit" + ); + } + _ => { + tracing::error!( + outpoint = %out_point, + error = %e, + "consume_asset_lock failed unexpectedly after successful Platform 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'." + ); + } + } + } + } + + Ok(cs) + } + + /// Apply proof-attested credit balances to the + /// `ManagedPlatformAccount` for each recipient address, emitting + /// a `PlatformAddressChangeSet` describing the new balances. + async fn write_address_balances_changeset( + &self, + platform_account_index: u32, + address_infos: &AddressInfos, + ) -> PlatformAddressChangeSet { + let key_source = { + let guard = self.provider.read().await; + guard + .as_ref() + .and_then(|p| p.key_source(&self.wallet_id, platform_account_index)) + }; + + let mut wm = self.wallet_manager.write().await; + let mut cs = PlatformAddressChangeSet::default(); + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + if let Some(account) = info + .core_wallet + .platform_payment_managed_account_at_index_mut(platform_account_index) + { + for (addr, maybe_info) in address_infos.iter() { + let PlatformAddress::P2pkh(hash) = *addr else { + continue; + }; + let p2pkh = PlatformP2PKHAddress::new(hash); + // Platform's proof must carry an `AddressInfo` + // for every recipient we asked to fund. A `None` + // is a protocol-contract violation, not a + // zero-credit funding — skip and log so a missed + // recipient is visible to operators instead of + // silently writing a "credited 0" row. + let Some(ai) = maybe_info else { + tracing::error!( + address = %p2pkh, + "Platform proof returned None AddressInfo for a recipient that should have been credited; skipping balance write to avoid recording 'credited 0'" + ); + continue; + }; + let funds = dash_sdk::platform::address_sync::AddressFunds { + balance: ai.balance, + nonce: ai.nonce, + }; + account.set_address_credit_balance(p2pkh, funds.balance, key_source.as_ref()); + // The recipient must exist in the account's + // address pool — `validate_recipient_addresses` + // verified that upstream. A miss here would + // mean the pool was mutated between pre-flight + // and now; skip and log rather than mis-attribute + // credits to whichever address lives at slot 0. + let Some(address_index) = + account + .addresses + .addresses + .iter() + .find_map(|(&idx, ainfo)| { + PlatformP2PKHAddress::from_address(&ainfo.address) + .ok() + .filter(|found| *found == p2pkh) + .map(|_| idx) + }) + else { + tracing::error!( + address = %p2pkh, + "Recipient address not found in account address pool; skipping balance write to avoid mis-attributing credits to slot 0" + ); + continue; + }; + cs.addresses.push(crate::PlatformAddressBalanceEntry { + wallet_id: self.wallet_id, + account_index: platform_account_index, + address_index, + address: p2pkh, + funds, + }); + } + } + } + cs + } +} + +/// Pre-flight check for the recipient address map: +/// - Non-empty +/// - Exactly one `None`-amount entry (the remainder recipient) +/// - All addresses are P2PKH and belong to the specified platform-payment account +async fn validate_recipient_addresses( + wallet: &PlatformAddressWallet, + platform_account_index: u32, + addresses: &BTreeMap>, +) -> Result<(), PlatformWalletError> { + if addresses.is_empty() { + return Err(PlatformWalletError::AddressOperation( + "top_up requires at least one recipient address".to_string(), + )); + } + + let none_count = addresses.values().filter(|v| v.is_none()).count(); + if none_count != 1 { + return Err(PlatformWalletError::AddressOperation(format!( + "Exactly one address must have None balance (the funding recipient), found {}", + none_count + ))); + } + + let wm = wallet.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "Wallet {:?} not found in wallet manager", + hex::encode(wallet.wallet_id) + )) + })?; + let account = info + .core_wallet + .platform_payment_managed_account_at_index(platform_account_index) + .ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "No platform payment account at index {}", + platform_account_index + )) + })?; + for addr in addresses.keys() { + let PlatformAddress::P2pkh(hash) = addr else { + return Err(PlatformWalletError::AddressOperation( + "Only P2PKH addresses are supported".to_string(), + )); + }; + let p2pkh = PlatformP2PKHAddress::new(*hash); + if !account.contains_platform_address(&p2pkh) { + return Err(PlatformWalletError::AddressNotFound(format!( + "Address {} does not belong to platform account index {}", + p2pkh, platform_account_index + ))); + } + } + Ok(()) +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index aec6d5b4f9d..1cdd9a01f5a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -6,7 +6,9 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use tokio::sync::RwLock; +use crate::broadcaster::SpvBroadcaster; use crate::error::PlatformWalletError; +use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use key_wallet_manager::WalletManager; @@ -27,6 +29,11 @@ pub struct PlatformAddressWallet { /// wallets don't allocate empty state. Sync takes a `write` lock; /// transfer/withdraw paths take `read` for key_source lookups. pub(crate) provider: Arc>>, + /// Shared asset-lock manager. Threaded in so the orchestrated + /// `top_up` path can drive + /// build → IS-or-CL wait → consume on the same tracked locks + /// every other sub-wallet sees. Cloned `Arc`, not owned. + pub(crate) asset_locks: Arc>, /// Per-wallet persistence handle for queuing changesets. pub(crate) persister: WalletPersister, } @@ -39,6 +46,7 @@ impl PlatformAddressWallet { sdk: Arc, wallet_manager: Arc>>, wallet_id: WalletId, + asset_locks: Arc>, persister: WalletPersister, ) -> Self { Self { @@ -46,6 +54,7 @@ impl PlatformAddressWallet { wallet_manager, wallet_id, provider: Arc::new(RwLock::new(None)), + asset_locks, persister, } } @@ -144,6 +153,14 @@ impl PlatformAddressWallet { self.sdk.network } + /// Wallet id this `PlatformAddressWallet` operates on. Exposed so + /// FFI callers that build a `MnemonicResolverCoreSigner` on demand + /// can thread the wallet id through to the resolver callback. + /// Mirrors [`AssetLockManager::wallet_id`]. + pub fn wallet_id(&self) -> WalletId { + self.wallet_id + } + /// Rebuild the provider so it covers a newly added account. /// /// Equivalent to [`initialize`]: the unified provider is rebuilt diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index dcd9486798e..73651070f0a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -273,6 +273,7 @@ impl PlatformWallet { Arc::clone(&sdk), Arc::clone(&wallet_manager), wallet_id, + Arc::clone(&asset_locks), wallet_persister.clone(), ); diff --git a/packages/rs-sdk/src/platform/transition/top_up_address.rs b/packages/rs-sdk/src/platform/transition/top_up_address.rs index 9317f1e65f5..126bb5857c8 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_address.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_address.rs @@ -21,9 +21,15 @@ use drive_proof_verifier::types::AddressInfos; /// Trait for topping up Platform addresses using various funding sources. #[async_trait::async_trait] pub trait TopUpAddress> { - /// Tops up addresses using the provided funding source and fee strategy. + /// Tops up addresses using a raw private key for the asset-lock proof. /// /// Returns proof-backed [`AddressInfos`] for the funded addresses. + /// + /// Prefer [`Self::top_up_with_signers`] when the asset-lock private + /// key lives outside Rust (Swift / hardware wallet / HSM): the + /// `_with_signers` variant routes asset-lock signing through an + /// external [`dpp::key_wallet::signer::Signer`] so no raw private + /// key crosses the FFI boundary. async fn top_up( &self, sdk: &Sdk, @@ -33,6 +39,38 @@ pub trait TopUpAddress> { signer: &S, settings: Option, ) -> Result; + + /// Top up addresses with an external asset-lock signer. + /// + /// `signer` (the trait's `S: Signer`) signs each + /// per-input `AddressWitness`; `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). + /// + /// `settings.user_fee_increase` is threaded straight through to + /// the transition builder. It both affects fee accounting AND + /// changes the ST's signable bytes, which the upstream CL-height + /// retry path in `platform-wallet` relies on to bypass + /// Tenderdash's invalid-tx hash cache + /// (`keep-invalid-txs-in-cache = true` in dashmate's + /// mainnet/testnet templates). `None` / unset = unaltered fees. + #[cfg(feature = "core_key_wallet")] + #[allow(clippy::too_many_arguments)] + async fn top_up_with_signers( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + asset_lock_signer: &AS, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync; } pub type AddressWithBalance = (PlatformAddress, Option); @@ -63,6 +101,34 @@ where ) .await } + + #[cfg(feature = "core_key_wallet")] + #[allow(clippy::too_many_arguments)] + async fn top_up_with_signers( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + asset_lock_signer: &AS, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + BTreeMap::from([(self.0, self.1)]) + .top_up_with_signers( + sdk, + asset_lock_proof, + asset_lock_proof_path, + fee_strategy, + signer, + asset_lock_signer, + settings, + ) + .await + } } #[async_trait::async_trait] @@ -97,21 +163,83 @@ impl> TopUpAddress for AddressesWithBalances { ) .await?; - ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let st_result = state_transition - .broadcast_and_wait::(sdk, settings) + broadcast_and_collect_address_infos(self, state_transition, sdk, settings).await + } + + #[cfg(feature = "core_key_wallet")] + #[allow(clippy::too_many_arguments)] + async fn top_up_with_signers( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + asset_lock_signer: &AS, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + if self.is_empty() { + return Err(Error::from(TransitionNoOutputsError::new())); + } + + // Pull `user_fee_increase` from settings *before* the + // broadcast call. The upstream CL-height retry path + // (`platform-wallet::wallet::asset_lock::orchestration::submit_with_cl_height_retry`) + // bumps this value between attempts to change the ST's + // signable bytes — if we silently dropped it here, retries + // would hash identically and get cached out by Tenderdash. + let user_fee_increase = settings + .as_ref() + .and_then(|settings| settings.user_fee_increase) + .unwrap_or_default(); + + let state_transition = + AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signers::( + asset_lock_proof, + asset_lock_proof_path, + BTreeMap::new(), + self.clone(), + fee_strategy, + signer, + asset_lock_signer, + user_fee_increase, + sdk.version(), + ) .await?; - match st_result { - StateTransitionProofResult::VerifiedAddressInfos(address_infos) => { - let expected_addresses = - self.keys().copied().collect::>(); - collect_address_infos_from_proof(address_infos, &expected_addresses) - } - other => Err(Error::InvalidProvedResponse(format!( - "address info proof was expected for {:?}, but received {:?}", - state_transition, other - ))), + + broadcast_and_collect_address_infos(self, state_transition, sdk, settings).await + } +} + +/// Broadcast the address-funding ST and convert the proof into the +/// `AddressInfos` map. Shared between the legacy private-key path and +/// the new signer-pair path — both flows want the same proof-shape +/// guarantee and the same expected-addresses cross-check. +async fn broadcast_and_collect_address_infos( + expected: &AddressesWithBalances, + state_transition: StateTransition, + sdk: &Sdk, + settings: Option, +) -> Result { + ensure_valid_state_transition_structure(&state_transition, sdk.version())?; + let st_result = state_transition + .broadcast_and_wait::(sdk, settings) + .await?; + match st_result { + StateTransitionProofResult::VerifiedAddressInfos(address_infos) => { + let expected_addresses = expected + .keys() + .copied() + .collect::>(); + collect_address_infos_from_proof(address_infos, &expected_addresses) } + other => Err(Error::InvalidProvedResponse(format!( + "address info proof was expected for {:?}, but received {:?}", + state_transition, other + ))), } } @@ -126,7 +254,7 @@ async fn create_address_funding_from_asset_lock_transition Result { - AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signer( + AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signer_and_private_key( asset_lock_proof, asset_lock_private_key, inputs, diff --git a/packages/strategy-tests/src/lib.rs b/packages/strategy-tests/src/lib.rs index d6318f55f0c..fb3f104c226 100644 --- a/packages/strategy-tests/src/lib.rs +++ b/packages/strategy-tests/src/lib.rs @@ -759,7 +759,7 @@ impl Strategy { outputs.insert(address, None); let funding_transition = - AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer( + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer_and_private_key( asset_lock_proof, private_key.inner.secret_bytes().as_slice(), BTreeMap::new(), // no additional inputs @@ -2183,7 +2183,7 @@ impl Strategy { outputs.insert(address, None); let funding_transition = - AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer( + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer_and_private_key( asset_lock_proof, private_key.inner.secret_bytes().as_slice(), BTreeMap::new(), // no additional inputs diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index fcc0bf2c241..4f886eb2298 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -18,6 +18,36 @@ import SwiftData /// Rust side from these rows so an in-flight registration that /// was interrupted by an app kill can resume from the latest /// status without rebroadcasting the asset-lock transaction. +/// +/// ## Destination conventions per funding type +/// +/// The destination of the asset lock — what was funded — is stored +/// in different fields depending on `fundingTypeRaw`. Each funding +/// type owns one typed field-family rather than sharing a +/// polymorphic `destinationBytes: Data?` blob; the typed shape +/// keeps SwiftData predicates working and makes per-type queries +/// readable. +/// +/// - **Identity** (`fundingTypeRaw ∈ {0, 1, 2, 3}` — +/// IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound / +/// IdentityInvitation): `identityIndexRaw` carries the HD slot of +/// the destination identity. The identity row itself can be +/// resolved via `PersistentIdentity` joined on +/// `(walletId, identityIndex)`. +/// +/// - **Platform address** (`fundingTypeRaw == 4` — +/// AssetLockAddressTopUp): +/// `recipientPlatformAddressHash` + `recipientPlatformAddressType` +/// identify the destination. Set by Swift on the controller's +/// `.completed` phase because the recipient is picked at +/// ST-submit time on the host side; Rust never sees it. +/// +/// - **Shielded address** (`fundingTypeRaw == 5` — +/// AssetLockShieldedAddressTopUp, not yet wired): will add a +/// dedicated `recipientShielded*` field family when the +/// shielded-funding flow lands. Keep this convention — one typed +/// field-family per funding type rather than a polymorphic +/// `destinationBytes` blob. @Model public final class PersistentAssetLock { /// Index `walletId` so per-wallet asset-lock scans (the progress @@ -96,6 +126,33 @@ public final class PersistentAssetLock { /// where Rust decodes them into the live proof. public var proofBytes: Data? + /// 20-byte hash of the recipient platform address for asset + /// locks consumed by an `AddressFundingFromAssetLockTransition` + /// (`fundingTypeRaw == 4`). Populated by Swift after a + /// successful `topUpFromCore` call — the recipient is + /// known on the caller side, not on the Rust side (which only + /// tracks the credit-output key, not the destination address). + /// + /// `nil` for: + /// - Identity-funding asset locks (the destination is the + /// newly-created identity, surfaced via the `identityIndex` + /// slot instead). + /// - Address-funding locks that haven't completed yet (status + /// < Consumed). + /// - Pre-this-commit address-funding locks that completed + /// before the field existed. + /// + /// Default `nil` on the column makes SwiftData's lightweight + /// migration safe for rows that pre-date this field. + public var recipientPlatformAddressHash: Data? + + /// `PlatformAddress` type byte (0 = P2PKH, 1 = P2SH) matching + /// `recipientPlatformAddressHash`. Stored alongside the hash so + /// the storage explorer can render a typed bech32m string + /// without joining against `PersistentPlatformAddress`. `nil` + /// whenever `recipientPlatformAddressHash` is `nil`. + public var recipientPlatformAddressType: UInt8? + /// Record timestamps. public var createdAt: Date public var updatedAt: Date diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 1240e4630d9..8ad06e2a630 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -369,4 +369,269 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { b[15], b[16], b[17], b[18], b[19] ) } + + // MARK: - Fund from Core asset lock + + /// Recipient entry for `topUpFromCore(...)`. + /// + /// Exactly one entry per call must have `credits = nil` — that + /// address receives the remainder after explicit outputs and fees + /// (the asset lock is consumed in full, so a remainder bucket is + /// mandatory). + public struct TopUpRecipient: Sendable { + /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. + public let addressType: UInt8 + /// 20-byte address hash. + public let hash: Data + /// Explicit credit amount, or `nil` to receive the remainder. + public let credits: UInt64? + + public init(addressType: UInt8, hash: Data, credits: UInt64?) { + self.addressType = addressType + self.hash = hash + self.credits = credits + } + } + + /// Fund this wallet's platform addresses 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 + /// `AddressFundingFromAssetLockTransition` → 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`. + /// + /// - Parameters: + /// - amountDuffs: Amount to lock in Core duffs. + /// - fundingAccountIndex: BIP44 standard Core account whose UTXOs + /// fund the asset lock. Today only BIP44 standard accounts are + /// supported (CoinJoin / BIP32 not yet wired through the + /// asset-lock builder). + /// - platformAccountIndex: Platform-payment account containing + /// `recipients`. Used for the membership pre-flight on the Rust + /// side and the post-success balance write. + /// - recipients: Destination addresses. Exactly one must carry + /// `credits = nil` (remainder recipient). The Rust side + /// enforces both invariants and returns a typed error if + /// either is violated. + /// - signer: `KeychainSigner` used for any input-witness + /// signatures on the address-funding transition. The same + /// wallet's `MnemonicResolver` (constructed internally) signs + /// the asset-lock proof's outer signature. + /// + /// Returns the list of `UpdatedBalance`s for each recipient, with + /// the new proof-attested credit balance. + @discardableResult + public func topUpFromCore( + amountDuffs: UInt64, + fundingAccountIndex: UInt32, + platformAccountIndex: UInt32, + recipients: [TopUpRecipient], + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + try topUpFromCorePreflight(recipients: recipients) + let handle = self.handle + let signerHandle = signer.handle + let recipientRows = recipients + // Constructed on the calling actor so it lives for the entire + // detached Task. Released after `withExtendedLifetime` returns. + // See `ManagedPlatformWallet.registerIdentityWithFunding` for the + // rationale on why `_ = signer` is NOT a substitute here — the + // -O optimizer can elide the discard and drop the resolver mid- + // FFI-call, leading to a use-after-free in the vtable callback. + let coreSigner = MnemonicResolver() + + return try await Task.detached(priority: .userInitiated) { + () -> [UpdatedBalance] in + let ffiAddresses = recipientRows.map { r -> FundingAddressEntryFFI in + FundingAddressEntryFFI( + address: PlatformAddressFFI( + address_type: r.addressType, + hash: Self.hashTuple(from: r.hash) + ), + has_balance: r.credits != nil, + balance: r.credits ?? 0 + ) + } + // Take the fee out of the remainder output. Today the + // `None` recipient is structurally the same as the + // "change" output on a transfer — it absorbs whatever's + // left after explicit outputs + fees. + let remainderIndex = UInt16( + ffiAddresses.firstIndex(where: { !$0.has_balance }) ?? 0 + ) + let feeRows: [FeeStrategyStepFFI] = [ + FeeStrategyStepFFI(step_type: 1, index: remainderIndex) // 1 = ReduceOutput + ] + var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0) + let result = withExtendedLifetime((signer, coreSigner)) { + ffiAddresses.withUnsafeBufferPointer { addrBp in + feeRows.withUnsafeBufferPointer { feeBp in + platform_address_wallet_top_up_signer( + handle, + amountDuffs, + fundingAccountIndex, + platformAccountIndex, + addrBp.baseAddress, + UInt(addrBp.count), + feeBp.baseAddress, + UInt(feeBp.count), + signerHandle, + coreSigner.handle, + &changeset + ) + } + } + } + try result.check() + + defer { platform_address_wallet_free_changeset(&changeset) } + return Self.decodeChangeset(&changeset) + }.value + } + + /// Resume a stuck platform-address funding flow from an already- + /// tracked asset lock by outpoint. + /// + /// Sibling to [`topUpFromCore`]: 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 identity-side `resumeIdentityWithAssetLock` — a prior + /// attempt left the lock in storage but the address-funding ST + /// never landed, and the user picks the lock from a "Resumable + /// Funding" surface. + /// + /// - Parameters: + /// - outPointTxid: 32-byte raw txid (little-endian wire order, + /// same as `OutPointFFI.txid`). The caller decodes + /// `PersistentAssetLock.outPointHex` back from display-order + /// hex before passing in. + /// - outPointVout: Funding output index (always 0 for asset + /// locks built by this wallet, but kept for generality). + @discardableResult + public func resumeTopUpFromAssetLock( + outPointTxid: Data, + outPointVout: UInt32, + platformAccountIndex: UInt32, + recipients: [TopUpRecipient], + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + guard outPointTxid.count == 32 else { + throw PlatformWalletError.invalidParameter( + "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" + ) + } + try topUpFromCorePreflight(recipients: recipients) + let handle = self.handle + let signerHandle = signer.handle + let recipientRows = recipients + let coreSigner = MnemonicResolver() + + return try await Task.detached(priority: .userInitiated) { + () -> [UpdatedBalance] in + 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) + let ffiAddresses = recipientRows.map { r -> FundingAddressEntryFFI in + FundingAddressEntryFFI( + address: PlatformAddressFFI( + address_type: r.addressType, + hash: Self.hashTuple(from: r.hash) + ), + has_balance: r.credits != nil, + balance: r.credits ?? 0 + ) + } + let remainderIndex = UInt16( + ffiAddresses.firstIndex(where: { !$0.has_balance }) ?? 0 + ) + let feeRows: [FeeStrategyStepFFI] = [ + FeeStrategyStepFFI(step_type: 1, index: remainderIndex) // 1 = ReduceOutput + ] + var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0) + let result = withExtendedLifetime((signer, coreSigner)) { + ffiAddresses.withUnsafeBufferPointer { addrBp in + feeRows.withUnsafeBufferPointer { feeBp in + platform_address_wallet_resume_top_up_with_existing_asset_lock_signer( + handle, + &outPoint, + platformAccountIndex, + addrBp.baseAddress, + UInt(addrBp.count), + feeBp.baseAddress, + UInt(feeBp.count), + signerHandle, + coreSigner.handle, + &changeset + ) + } + } + } + try result.check() + + defer { platform_address_wallet_free_changeset(&changeset) } + return Self.decodeChangeset(&changeset) + }.value + } + + /// Validate the recipient list before the FFI sees it. The Rust + /// side enforces the same invariants — we duplicate them here so + /// the user gets a synchronous error before paying for the Task + /// detach + handle marshaling. + private func topUpFromCorePreflight( + recipients: [TopUpRecipient] + ) throws { + guard !recipients.isEmpty else { + throw PlatformWalletError.invalidParameter("recipients is empty") + } + let noneCount = recipients.filter { $0.credits == nil }.count + guard noneCount == 1 else { + throw PlatformWalletError.invalidParameter( + "Exactly one recipient must have credits = nil (the remainder recipient), found \(noneCount)" + ) + } + for r in recipients { + guard r.hash.count == 20 else { + throw PlatformWalletError.invalidParameter( + "TopUpRecipient.hash must be exactly 20 bytes (got \(r.hash.count))" + ) + } + } + } + + /// Convert an FFI changeset into the Swift-facing `[UpdatedBalance]`. + /// Shared between the wallet-balance and resume entry points so + /// both produce identically-shaped results. + private static func decodeChangeset( + _ changeset: inout PlatformAddressChangeSetFFI + ) -> [UpdatedBalance] { + guard let updatedPtr = changeset.updated, changeset.updated_count > 0 else { + return [] + } + return (0.. Void)? @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @EnvironmentObject var shieldedService: ShieldedService @@ -665,8 +696,9 @@ struct BalanceCardView: View { @Query private var addressBalances: [PersistentPlatformAddress] @Query private var syncStates: [PersistentPlatformAddressesSyncState] - init(wallet: PersistentWallet) { + init(wallet: PersistentWallet, onFundPlatform: (() -> Void)? = nil) { self.wallet = wallet + self.onFundPlatform = onFundPlatform let walletId = wallet.walletId let walletNetworkRaw = (wallet.network ?? .testnet).rawValue _addressBalances = Query( @@ -727,13 +759,24 @@ struct BalanceCardView: View { unit: .duffs ) - // Platform Balance row + // Platform Balance row — when `onFundPlatform` is + // wired (i.e. on the editable Wallet Detail surface), + // a trailing `+` button opens the Core→Platform + // funding sheet. Read-only call sites pass `nil` and + // the affordance disappears. WalletBalanceRow( label: "Platform Balance", amount: platformBalance, color: .blue, unit: .credits, - showSyncIndicator: platformBalanceSyncService.isSyncing + showSyncIndicator: platformBalanceSyncService.isSyncing, + trailingAction: onFundPlatform.map { fund in + WalletBalanceRow.TrailingAction( + systemImage: "plus.circle.fill", + accessibilityLabel: "Top Up Platform Balance from Core", + action: fund + ) + } ) // Shielded Balance row @@ -759,12 +802,23 @@ private enum WalletBalanceUnit { } private struct WalletBalanceRow: View { + /// Tappable affordance shown at the trailing edge of the row. + /// Used today by the Platform Balance row to surface a "fund + /// from Core" entry point without crowding the action button + /// strip at the top of the wallet detail screen. + struct TrailingAction { + let systemImage: String + let accessibilityLabel: String + let action: () -> Void + } + let label: String var amount: UInt64 var incoming: UInt64 = 0 var color: Color var unit: WalletBalanceUnit = .duffs var showSyncIndicator: Bool = false + var trailingAction: TrailingAction? = nil var body: some View { HStack { @@ -797,6 +851,15 @@ private struct WalletBalanceRow: View { .foregroundColor(.orange) } } + if let trailing = trailingAction { + Button(action: trailing.action) { + Image(systemName: trailing.systemImage) + .font(.title3) + .foregroundColor(color) + } + .buttonStyle(.plain) + .accessibilityLabel(trailing.accessibilityLabel) + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift new file mode 100644 index 00000000000..1005a162b37 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftDashSDK + +/// Per-slot state owned by a single platform-address funding attempt. +/// +/// Mirrors [`IdentityRegistrationController`] for the +/// `AddressFundingFromAssetLockTransition` flow. One controller is +/// created per `(walletId, platformAccountIndex, recipientHash)` +/// slot when the user submits `TopUpPlatformAddressView`. The +/// controller owns the in-flight `Task`, exposes its current `phase` +/// via `@Published`, and survives view dismissal via +/// `AddressTopUpCoordinator` on `PlatformWalletManager`. +/// +/// The 4-step progress in `AddressTopUpProgressView` derives its +/// step from a combination of `phase` (Step 1, Step 4) and the live +/// `PersistentAssetLock` row queried via `@Query` filtered by +/// `walletId` + the asset-lock funding-type discriminant (Step 2/3, +/// driven by `statusRaw`). +/// +/// Address-funded asset locks differ from identity-registration asset +/// locks in one important way: there's no per-identity-index slot. A +/// wallet can fund many addresses from the same funding-type family, +/// so the slot here keys on the recipient address hash rather than a +/// numeric index. The Rust-side asset-lock builder still pulls fresh +/// credit-output keys from the `AssetLockAddressTopUp` BIP44 family; +/// the index advances naturally per call. +@MainActor +final class AddressTopUpController: 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 funding call is in flight. + /// Stage within this phase is read from the matching + /// `PersistentAssetLock.statusRaw` row. + case inFlight + /// Step 4: the address has been credited. `newBalance` is + /// the proof-attested credit balance the caller should + /// surface in the terminal banner. + case completed(newBalance: UInt64) + /// Failure terminal state. Message is shown inline in + /// `AddressTopUpProgressView`'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(balance) | .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 == AssetLockAddressTopUp)`. + let walletId: Data + + /// Platform-payment account index of the recipient. Stored for + /// the resume surface label and the live progress query. + let platformAccountIndex: UInt32 + + /// 20-byte hash of the recipient platform address. Composite-key + /// component so two concurrent funds to different addresses on + /// the same account don't collide. + let recipientHash: Data + + /// `PlatformAddress` type byte for the recipient (0 = P2PKH, + /// 1 = P2SH). Carried so the post-success back-fill onto + /// `PersistentAssetLock.recipientPlatformAddressType` records + /// the real value rather than a hardcoded P2PKH constant. The + /// wallet only generates P2PKH today, but the field exists + /// because the stored type drives the bech32m encoder's type + /// byte (`0xb0` vs `0x80`) and a hardcoded `0` would silently + /// mis-tag any future P2SH funding for the lifetime of the row. + let recipientType: UInt8 + + /// 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? + + /// Outpoints of `Consumed` address-funding locks observed on + /// this wallet **before** `submit()` fired. Captured by the + /// caller (`TopUpPlatformAddressView.submit`) immediately before + /// kicking off the FFI body and stored here so the post-success + /// back-fill can compute the delta against the new set and + /// deterministically match this funding's consumed lock — even + /// when two concurrent fundings on the same wallet land in close + /// succession. + /// + /// `nil` (default) means snapshot wasn't captured; the back-fill + /// falls back to its earlier "newest unrecipiented" heuristic. + var preSubmitConsumedOutpoints: Set? + + init( + walletId: Data, + platformAccountIndex: UInt32, + recipientHash: Data, + recipientType: UInt8 + ) { + self.walletId = walletId + self.platformAccountIndex = platformAccountIndex + self.recipientHash = recipientHash + self.recipientType = recipientType + } + + /// Submit the funding. Defensively rejects any phase that + /// shouldn't fire a fresh FFI call: + /// - `.inFlight`: a second FFI call would race the first. + /// - `.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 and reports the new credit balance on + /// success or rethrows on failure. The controller flips `phase` + /// to `.completed(balance)` / `.failed(message)` accordingly. + func submit(body: @escaping () async throws -> UInt64) { + switch phase { + case .idle, .failed: + break + case .inFlight, .completed: + return + } + phase = .inFlight + lastSubmittedAt = Date() + task = Task { [weak self] in + do { + let newBalance = try await body() + await MainActor.run { + self?.phase = .completed(newBalance: newBalance) + } + } catch { + await MainActor.run { + self?.phase = .failed(error.localizedDescription) + } + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift new file mode 100644 index 00000000000..cf353a09a46 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift @@ -0,0 +1,178 @@ +import Foundation +import SwiftDashSDK + +/// Singleton hub for in-flight platform-address funding attempts, +/// hosted on `PlatformWalletManager` so funds survive view +/// dismissal and network-toggle pressure. +/// +/// Mirrors [`RegistrationCoordinator`] for the +/// `AddressFundingFromAssetLockTransition` flow. Keyed by +/// `(walletId, platformAccountIndex, recipientHash)` — that's the +/// natural unit of work since a wallet can fund many addresses +/// concurrently and "next unused" address allocation happens +/// Rust-side per call. The single-flight invariant prevents a user +/// from double-tapping the same address-funding submission during +/// the asset-lock broadcast window. +@MainActor +final class AddressTopUpCoordinator: ObservableObject { + /// Composite key — needs `Hashable` so the map can index by it. + /// `walletId` is 32 raw bytes; `recipientHash` is 20 raw bytes; + /// `platformAccountIndex` is the DIP-17 account that owns the + /// recipient address. + struct SlotKey: Hashable { + let walletId: Data + let platformAccountIndex: UInt32 + let recipientHash: Data + } + + /// Active controllers keyed by slot. Stored as `@Published` so + /// the "Pending Platform Top Ups" row on the Wallet Detail + /// screen can observe map mutations via `objectWillChange`. + @Published private(set) var controllers: [SlotKey: AddressTopUpController] = [:] + + /// 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, + platformAccountIndex: UInt32, + recipientHash: Data + ) -> AddressTopUpController? { + controllers[ + SlotKey( + walletId: walletId, + platformAccountIndex: platformAccountIndex, + recipientHash: recipientHash + ) + ] + } + + /// Snapshot of every active controller, sorted by recency of + /// last submit (most recent first). Used by the "Pending + /// Platform Funding" row so dismissed-but-still-running flows + /// remain reachable. + func activeControllers() -> [AddressTopUpController] { + 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 for it. Returns the + /// controller for `TopUpPlatformAddressView` to bind a + /// `AddressTopUpProgressView` against. + /// + /// Single-flighting is enforced here at the coordinator level + /// because the controller's `submit()` only guards within its + /// own phase machine — without a phase check before fresh-slot + /// creation, a second tap during the FFI window would race two + /// FFI calls for the same asset lock. + func startFunding( + walletId: Data, + platformAccountIndex: UInt32, + recipientHash: Data, + recipientType: UInt8, + body: @escaping () async throws -> UInt64 + ) -> AddressTopUpController { + let key = SlotKey( + walletId: walletId, + platformAccountIndex: platformAccountIndex, + recipientHash: recipientHash + ) + if let existing = controllers[key] { + switch existing.phase { + case .inFlight, .completed: + // Active or just-completed — don't re-enter. Returning + // the existing controller lets the caller bind to its + // progress / terminal state without disrupting it. + return existing + case .idle, .failed: + // Legitimate restart paths. + existing.submit(body: body) + // No retention sweep here — the slot is sticky on + // .failed (we want the user to see + dismiss the + // error) and a duplicate sweep on retry would just + // spawn a second 30s poll Task against the same + // controller. Sweep was already scheduled when the + // controller was first created. + return existing + } + } + let controller = AddressTopUpController( + walletId: walletId, + platformAccountIndex: platformAccountIndex, + recipientHash: recipientHash, + recipientType: recipientType + ) + controllers[key] = controller + controller.submit(body: body) + scheduleRetentionSweep(key: key, controller: controller) + return controller + } + + /// Manually drop a controller from the map. Used by the UI's + /// "Dismiss" action on a `.failed` row (failures stay + /// indefinitely until acknowledged so the user can read the + /// error). + func dismiss( + walletId: Data, + platformAccountIndex: UInt32, + recipientHash: Data + ) { + let key = SlotKey( + walletId: walletId, + platformAccountIndex: platformAccountIndex, + recipientHash: recipientHash + ) + 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 + /// `RegistrationCoordinator`. + private func scheduleRetentionSweep( + key: SlotKey, + controller: AddressTopUpController + ) { + 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/Services/PlatformWalletManager+AddressTopUpCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift new file mode 100644 index 00000000000..cd0573d84c3 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift @@ -0,0 +1,37 @@ +import Foundation +import ObjectiveC +import SwiftDashSDK + +/// Per-manager `AddressTopUpCoordinator` accessor. Mirrors the +/// [`registrationCoordinator`](PlatformWalletManager.registrationCoordinator) +/// 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 `AddressTopUpController` 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 addressTopUpCoordinatorKey: UInt8 = 0 + + /// Per-manager address-funding coordinator. Created on first + /// access; subsequent reads return the same instance. + var addressTopUpCoordinator: AddressTopUpCoordinator { + if let existing = objc_getAssociatedObject( + self, + &PlatformWalletManager.addressTopUpCoordinatorKey + ) as? AddressTopUpCoordinator { + return existing + } + let fresh = AddressTopUpCoordinator() + objc_setAssociatedObject( + self, + &PlatformWalletManager.addressTopUpCoordinatorKey, + fresh, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return fresh + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift new file mode 100644 index 00000000000..448b6dff229 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift @@ -0,0 +1,419 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Embeddable 5-step progress section for an address-funding flow. +/// Mirrors [`RegistrationProgressSection`] but for +/// `AddressFundingFromAssetLockTransition`. +/// +/// 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. Funding platform address → activeLock `statusRaw ∈ {2, 3}` +/// AND controller still `.inFlight` +/// +/// Exactly one of steps 3/4 is `.skipped` on a successful resolution: +/// step 4 is skipped when IS came back first (statusRaw == 2), +/// step 3 is skipped when CL did (statusRaw == 3, whether via +/// IS-timeout fallback or the `metadata.last_applied_chain_lock` +/// direct path). The faded checkmark distinguishes "passed through" +/// from "engaged" so users can see which finality lane resolved +/// the lock. +/// +/// `.completed` is the *terminal* state and is not a separate step; +/// the parent `AddressTopUpProgressView` renders the "Address +/// funded" banner + the new balance below this section. `.failed` +/// marks the current step with the error icon + message. +struct AddressTopUpProgressSection: View { + @ObservedObject var controller: AddressTopUpController + + /// Asset-lock rows for this wallet, filtered to the + /// AssetLockAddressTopUp variant (discriminant `4`). 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 identity-side 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: AddressTopUpController) { + self.controller = controller + let walletId = controller.walletId + // `fundingTypeRaw == 4` is `AssetLockFundingType::AssetLockAddressTopUp` + // per the discriminant comment on `PersistentAssetLock`. We + // filter on it here so an interleaved identity registration's + // 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 == 4 + }, + sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)] + ) + } + + var body: some View { + // Same TimelineView pattern as RegistrationProgressSection + // so the elapsed-time heuristic distinguishing step 2 / 3 / 4 + // refreshes without an external timer. + 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("Top Up Progress") + } footer: { + Text(footerText(step: step, isFailed: isFailed)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Step computation + + /// 1...5, current active step. On `.completed` we report 6 (one + /// past the last visual step) so all rows render as `.done`; + /// the terminal "Address funded" banner is rendered by the + /// parent view, not by this section. + private func currentStep(now: Date) -> Int { + switch controller.phase { + case .idle: + return 1 + case .completed: + // No visible "funded" step — terminalSection on + // `AddressTopUpProgressView` 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). Step 4 done. + return 5 + default: + return 1 + } + } + } + + /// Resolve which of steps 2/3/4 is "active" while the lock is at + /// `statusRaw == 1`. Uses elapsed time since the row's last + /// update as the anchor: brief broadcasting window first, then + /// IS wait until the Rust-side timeout, then the CL fallback. + 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" rather than + /// "active" — i.e. 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 ("Waiting for InstantSend proof") should + /// appear "skipped" — i.e. no IS proof was observed during the + /// step-3 window. Symmetric to `step4WasSkipped`: + /// + /// - `statusRaw == 3` — CL-locked. Either IS timed out and the + /// CL fallback ran, OR `wait_for_proof`'s + /// `metadata.last_applied_chain_lock` fallback built a Chain + /// proof directly without ever attempting IS. + /// - `statusRaw == 1` + elapsed past the IS deadline. The lock + /// is still Broadcast but `broadcastSubStep` has advanced to + /// step 4 (CL wait) because IS didn't materialize within + /// `instantLockTimeout`. The guard on `idx < currentStep` in + /// `stepState` means this branch only matters when we're past + /// step 3 anyway, so a simple `statusRaw != 2` covers it + /// cleanly. + 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 "Funding platform address" + default: return "" + } + } + + /// Step-state classification. Drives the icon + tint on the + /// row. `.skipped` is a softer pending variant for the IS or + /// CL step the wallet didn't engage on the successful path — + /// visually distinguishable so users don't think the step + /// "didn't happen yet" once we've moved past it. + 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 { + // Steps 3 and 4 are the IS / CL halves of the proof + // round: exactly one of them is skipped on a successful + // resolution. Step 4 skipped when IS came back first + // (statusRaw == 2); step 3 skipped when CL did + // (statusRaw == 3, whether via IS-timeout fallback or + // the direct `last_applied_chain_lock` path). + 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: + // Lighter checkmark to communicate "we passed this step + // but didn't need it" — IS came back fast so CL was + // skipped, or CL resolved directly so IS was 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 "Submitting the AddressFundingFromAssetLock state transition to Platform." + default: return "" + } + } +} + +/// Standalone navigation destination for an address funding in +/// flight, completed, or failed. Pushed from `TopUpPlatformAddressView` +/// on submit and (later) from the "Resumable Top Up" surface. +struct AddressTopUpProgressView: View { + @ObservedObject var controller: AddressTopUpController + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var walletManager: PlatformWalletManager + + init(controller: AddressTopUpController) { + self.controller = controller + } + + var body: some View { + Form { + AddressTopUpProgressSection(controller: controller) + terminalSection + } + .navigationTitle("Top Up Platform Address") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var terminalSection: some View { + switch controller.phase { + case .completed(let newBalance): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Address funded", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + HStack { + Text("New balance") + .foregroundColor(.secondary) + Spacer() + Text(formatCredits(newBalance)) + .font(.system(.body, design: .monospaced)) + } + Button { + walletManager.addressTopUpCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Top Up failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + // Dismissal path mirroring the inline terminal + // section in `TopUpPlatformAddressView`. Without + // this the only way to clear a `.failed` + // controller from a pushed progress view was to + // relaunch the app — the `Pending Platform + // Funding` row's `.swipeActions` doesn't fire + // outside a List, so neither surface had a + // working dismissal. + Button { + walletManager.addressTopUpCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.top, 4) + } + } + default: + EmptyView() + } + } + + private func formatCredits(_ credits: UInt64) -> String { + // 1e11 credits per DASH — same divisor used by + // `CreateIdentityView` for Platform-side amounts. + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.6f DASH (credits)", dash) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift new file mode 100644 index 00000000000..2955611a23f --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift @@ -0,0 +1,283 @@ +// PendingPlatformTopUpsList.swift +// SwiftExampleApp +// +// Wallet-scoped "Pending Platform Top Ups" surface that mirrors the +// identity-side `PendingRegistrationsList` + `ResumableRegistrationsList` +// pair. Two distinct row sources are merged here: +// +// 1. In-flight controllers from `AddressTopUpCoordinator` — the +// live submit-still-running case. +// 2. Orphaned `PersistentAssetLock` rows with +// `fundingTypeRaw == AssetLockAddressTopUp` (4) and +// `statusRaw ∈ [1, 3]` — the crash-recovery case where the user +// killed the app between asset-lock broadcast and ST submission. +// +// Anti-join: an orphaned lock is hidden if its outpoint is already +// claimed by an in-flight controller. (We index by outpoint here +// rather than by the `(walletId, platformAccountIndex, recipientHash)` +// triple because the orphaned lock doesn't know its recipient — the +// recipient is picked at ST-submit time.) + +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Section view backing the Wallet Detail screen's "Pending Platform +/// Funding" surface for a single wallet. Observes +/// `AddressTopUpCoordinator` directly (`@ObservedObject`) so its +/// `@Published controllers` map mutations trigger SwiftUI re-renders +/// of the in-flight rows. +struct PendingPlatformTopUpsList: View { + @ObservedObject var coordinator: AddressTopUpCoordinator + /// Wallet to scope the section to. The Identities-tab equivalent + /// is cross-wallet because identities are a global concept; here + /// the wallet detail screen is already wallet-scoped so we + /// follow suit. + let walletId: Data + /// All asset-lock rows for the wallet. Pre-filtered by the + /// parent (`WalletDetailView`) so this section doesn't run + /// another `@Query`. + let assetLocks: [PersistentAssetLock] + /// Bound to the parent's "resume sheet" state. Setting non-nil + /// presents `TopUpPlatformAddressView` in resume mode. + @Binding var resumingAssetLock: PersistentAssetLock? + + var body: some View { + let inFlight = activeControllersForWallet + let orphans = resumableLocks(excludingControllerOutpoints: Set(inFlight.compactMap { _ in + // Controllers don't currently store the outpoint of the + // asset lock they're driving. The de-dupe set therefore + // never has entries today — but the SwiftData status + // filter (`>=1, <=3`) already excludes locks that have + // been Consumed, so an in-flight controller whose lock + // is mid-transition lands at status 2/3 and would only + // briefly co-render. The plumbing is here so a future + // tweak (controller exposes its outpoint after broadcast) + // can de-dupe by returning a non-nil here. + nil + })) + + if !inFlight.isEmpty || !orphans.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Pending Platform Top Ups (\(inFlight.count + orphans.count))") + .font(.headline) + Spacer() + } + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(Array(inFlight.enumerated()), id: \.element.platformTopUpRowID) { idx, controller in + PendingPlatformTopUpRow(controller: controller) + .padding(.horizontal) + .padding(.vertical, 10) + if idx < inFlight.count - 1 || !orphans.isEmpty { + Divider() + } + } + ForEach(Array(orphans.enumerated()), id: \.element.id) { idx, lock in + ResumablePlatformTopUpRow( + lock: lock, + onResume: { resumingAssetLock = lock } + ) + .padding(.horizontal) + .padding(.vertical, 10) + if idx < orphans.count - 1 { + Divider() + } + } + } + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(10) + .padding(.horizontal) + } + } + } + + /// In-flight controllers scoped to this wallet, newest-first. + private var activeControllersForWallet: [AddressTopUpController] { + coordinator.activeControllers().filter { $0.walletId == walletId } + } + + /// Resumable asset-lock rows for this wallet — fundingType 4 + /// (AssetLockAddressTopUp) and status in 1..3 (Broadcast through + /// ChainLocked, excluding Consumed). Excludes outpoints already + /// owned by an in-flight controller. + private func resumableLocks( + excludingControllerOutpoints excluded: Set + ) -> [PersistentAssetLock] { + assetLocks + .filter { $0.fundingTypeRaw == 4 } + .filter { $0.isVisibleAsResumable } + .filter { !excluded.contains($0.outPointHex) } + } +} + +private extension AddressTopUpController { + /// Composite ForEach id: `(walletId hex)-(platformAccountIndex)-(recipientHash hex)`. + /// The recipient hash is the within-account discriminator: two + /// concurrent fund calls to different addresses on the same + /// account otherwise collide on `(walletId, accountIndex)`. + var platformTopUpRowID: String { + let walletHex = walletId.map { String(format: "%02x", $0) }.joined() + let recipientHex = recipientHash.map { String(format: "%02x", $0) }.joined() + return "\(walletHex)-\(platformAccountIndex)-\(recipientHex)" + } +} + +/// Single row representing an in-flight `AddressTopUpController`. +/// Tappable navigation pushes to `AddressTopUpProgressView`. +struct PendingPlatformTopUpRow: View { + @ObservedObject var controller: AddressTopUpController + @EnvironmentObject var walletManager: PlatformWalletManager + + var body: some View { + HStack(spacing: 8) { + NavigationLink(destination: AddressTopUpProgressView(controller: controller)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: phaseIcon) + .foregroundColor(phaseTint) + Text("Platform Account #\(controller.platformAccountIndex)") + .font(.body) + Spacer() + Text(phaseLabel) + .font(.caption) + .foregroundColor(.secondary) + // Manual disclosure indicator — without a + // List ancestor SwiftUI doesn't auto-render + // the chevron, and the row would read as a + // static label. + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + Text(recipientLabel) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .padding(.vertical, 2) + } + .buttonStyle(.plain) + // Inline Dismiss for `.failed` controllers. The earlier + // `.swipeActions` modifier was dead — that modifier only + // takes effect when the row is inside a List/Form, and + // this row renders inside a VStack card on the wallet + // detail screen. Without an inline button the user had + // no way to clear a failed funding short of an app + // restart. + if case .failed = controller.phase { + Button { + walletManager.addressTopUpCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .accessibilityLabel("Dismiss failed funding") + } + } + } + + private var recipientLabel: String { + let prefix = controller.recipientHash + .prefix(6) + .map { String(format: "%02x", $0) } + .joined() + return "→ addr \(prefix)…" + } + + private var phaseIcon: String { + switch controller.phase { + case .idle: return "circle.dashed" + case .inFlight: return "arrow.triangle.2.circlepath" + case .completed: return "checkmark.seal.fill" + case .failed: return "xmark.octagon.fill" + } + } + + private var phaseTint: Color { + switch controller.phase { + case .idle, .inFlight: return .blue + case .completed: return .green + case .failed: return .red + } + } + + private var phaseLabel: String { + switch controller.phase { + case .idle: return "Idle" + case .inFlight: return "Topping up…" + case .completed: return "Done" + case .failed: return "Failed" + } + } +} + +/// Single row in the orphaned-asset-lock section. Renders the lock +/// summary (txid prefix, amount, status) plus a compact Resume button +/// that opens `TopUpPlatformAddressView` in resume mode pre-seeded with +/// the outpoint. +struct ResumablePlatformTopUpRow: View { + let lock: PersistentAssetLock + let onResume: () -> Void + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Asset Lock \(lock.shortOutPointDisplay)") + .font(.body) + .lineLimit(1) + HStack(spacing: 6) { + Text(formatDuffs(lock.amountDuffs)) + .font(.caption) + .foregroundColor(.secondary) + Text("·") + .font(.caption) + .foregroundColor(.secondary) + Text(lock.statusLabel) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer(minLength: 8) + trailingAffordance + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var trailingAffordance: some View { + if lock.canFundIdentity { + // `canFundIdentity` is identity-named but the predicate + // it encodes — `statusRaw ∈ {2, 3}` — is exactly the + // "lock has a usable IS or CL proof" gate the address- + // funding submit path needs. Naming carryover only. + Button(action: onResume) { + Label("Resume", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + .font(.callout) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Waiting for InstantSend / ChainLock…") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private func formatDuffs(_ amountDuffs: Int64) -> String { + let dash = Double(amountDuffs) / 1e8 + return String(format: "%g DASH", dash) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 72c9e448bd9..976c466cb42 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1801,6 +1801,13 @@ struct AssetLockStorageDetailView: View { /// don't yet have the `wallet` relationship populated. @Query private var candidateIdentities: [PersistentIdentity] + /// Wallet this asset lock belongs to. Filtered by walletId so + /// the bech32m HRP picker on the Recipient section reads the + /// correct network. `@Query` is reactive; if the wallet row + /// vanishes (e.g. wallet deletion), the helper falls back to + /// testnet HRP rather than crashing. + @Query private var owningWallets: [PersistentWallet] + init(record: PersistentAssetLock) { self.record = record // `PersistentAssetLock.identityIndexRaw` is `Int32` (the @@ -1816,6 +1823,12 @@ struct AssetLockStorageDetailView: View { identity.identityIndex == identityIndex } ) + let walletId = record.walletId + _owningWallets = Query( + filter: #Predicate { wallet in + wallet.walletId == walletId + } + ) } /// Resolve the identity row this asset lock points at. Strict @@ -1844,6 +1857,40 @@ struct AssetLockStorageDetailView: View { FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") FieldRow(label: "Wallet ID", value: hexString(record.walletId)) } + if isAddressFunding { + // Address-funding section: show the recipient + // platform address when Swift stamped it after a + // successful `topUpFromCore`. `nil` on rows + // that pre-date this column or whose funding hasn't + // completed yet — communicate either case + // explicitly so the explorer entry is self- + // describing. + Section("Recipient Platform Address") { + if let hash = record.recipientPlatformAddressHash { + FieldRow(label: "Hash", value: hexString(hash)) + FieldRow( + label: "Address Type", + value: addressTypeLabel(record.recipientPlatformAddressType) + ) + if let encoded = bech32mPlatformAddress( + hash: hash, + addressType: record.recipientPlatformAddressType + ) { + FieldRow(label: "Bech32m", value: encoded) + } + } else if record.statusRaw == 4 { + FieldRow( + label: "Recipient", + value: "— (pre-this-commit row)" + ) + } else { + FieldRow( + label: "Recipient", + value: "— (funding not yet completed)" + ) + } + } + } if isIdentityFunding { // Identity section is always shown for identity- // funding asset locks (Registration / TopUp). If the @@ -1919,6 +1966,168 @@ struct AssetLockStorageDetailView: View { record.fundingTypeRaw == 0 || record.fundingTypeRaw == 1 } + /// True when this asset lock funded a platform address via + /// `AddressFundingFromAssetLockTransition` (`fundingTypeRaw == 4`). + /// The Recipient Platform Address section shows the destination + /// hash + bech32m encoding when set. + private var isAddressFunding: Bool { + record.fundingTypeRaw == 4 + } + + /// Render the recipient address-type byte as a human label. + /// 0 = P2PKH (the only shape the wallet generates today), + /// 1 = P2SH (reserved). Defensive for future-shape support. + private func addressTypeLabel(_ raw: UInt8?) -> String { + switch raw { + case 0: return "P2PKH" + case 1: return "P2SH" + case .some(let v): return "Unknown(\(v))" + case .none: return "—" + } + } + + /// Encode the recipient hash as a DIP-0018 bech32m platform + /// address. Returns `nil` for unsupported shapes (so the row + /// silently falls back to the hex display) and on any encoder + /// failure. + /// + /// HRP selection follows DIP-0018 — `dash` on mainnet, `tdash` + /// on every other network. We pull the network from the + /// matching wallet row when available; absent that we default + /// to testnet which is the common case in this example app. + private func bech32mPlatformAddress( + hash: Data, + addressType: UInt8? + ) -> String? { + guard hash.count == 20 else { return nil } + // Bech32m type byte: 0xb0 for P2PKH, 0x80 for P2SH (per + // DIP-0018). Note these differ from the storage discriminant + // (0 / 1) — same conversion the Rust side does in + // `PlatformAddress::to_bech32m_string` / + // `from_bech32m_string`. + let typeByte: UInt8 + switch addressType { + case 0: typeByte = 0xb0 + case 1: typeByte = 0x80 + default: return nil + } + var payload5: [UInt8] = [] + let payload8: [UInt8] = [typeByte] + Array(hash) + // Convert 8-bit → 5-bit groups. Bech32m payloads carry + // 5-bit "data" symbols. + guard convertBits(payload8, fromBits: 8, toBits: 5, pad: true, out: &payload5) else { + return nil + } + let hrp = networkHRP() + return bech32mEncode(hrp: hrp, data: payload5) + } + + /// Determine the network HRP for the wallet that owns this + /// asset lock. Reads from the matching `PersistentWallet`'s + /// `network` field per DIP-0018 (`dash` on mainnet, `tdash` + /// everywhere else). Falls back to testnet only when the + /// owning wallet row can't be resolved (deleted wallet, legacy + /// row without the relationship populated) — that case is + /// already non-functional so the fallback string is + /// inconsequential. + private func networkHRP() -> String { + guard let wallet = owningWallets.first, let network = wallet.network else { + return "tdash" + } + switch network { + case .mainnet: return "dash" + default: return "tdash" + } + } +} + +// MARK: - Bech32m helpers + +/// Standard bech32 / bech32m bit-conversion. Inputs are unsigned +/// integers in `fromBits`-bit groups; outputs are unsigned +/// integers in `toBits`-bit groups. Returns false on overflow +/// (which never happens for the 8→5 case we use here). +private func convertBits( + _ data: [UInt8], + fromBits: Int, + toBits: Int, + pad: Bool, + out: inout [UInt8] +) -> Bool { + var acc: UInt32 = 0 + var bits: UInt32 = 0 + let maxv: UInt32 = (1 << toBits) - 1 + for value in data { + let v = UInt32(value) + if (v >> fromBits) != 0 { return false } + acc = (acc << fromBits) | v + bits += UInt32(fromBits) + while bits >= toBits { + bits -= UInt32(toBits) + out.append(UInt8((acc >> bits) & maxv)) + } + } + if pad { + if bits > 0 { + out.append(UInt8((acc << (UInt32(toBits) - bits)) & maxv)) + } + } else if bits >= fromBits || (acc << (UInt32(toBits) - bits)) & maxv != 0 { + return false + } + return true +} + +/// Encode a bech32m string (BIP-350). The checksum constant is the +/// BIP-350 0x2bc830a3 vs bech32's 1; everything else matches. +private func bech32mEncode(hrp: String, data: [UInt8]) -> String { + let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + var combined = data + combined.append(contentsOf: bech32mCreateChecksum(hrp: hrp, data: data)) + let charsetArr = Array(charset) + var result = hrp + "1" + for v in combined { + result.append(charsetArr[Int(v)]) + } + return result +} + +private func bech32mCreateChecksum(hrp: String, data: [UInt8]) -> [UInt8] { + var values: [UInt8] = bech32mHRPExpand(hrp) + values.append(contentsOf: data) + values.append(contentsOf: [0, 0, 0, 0, 0, 0]) + let mod = bech32mPolymod(values) ^ 0x2bc830a3 + var out: [UInt8] = [] + for i in 0..<6 { + out.append(UInt8((mod >> (5 * (5 - i))) & 31)) + } + return out +} + +private func bech32mHRPExpand(_ hrp: String) -> [UInt8] { + var ret: [UInt8] = [] + for c in hrp.utf8 { ret.append(UInt8(c >> 5)) } + ret.append(0) + for c in hrp.utf8 { ret.append(UInt8(c & 31)) } + return ret +} + +private func bech32mPolymod(_ values: [UInt8]) -> UInt32 { + let gen: [UInt32] = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + var chk: UInt32 = 1 + for v in values { + let top = chk >> 25 + chk = ((chk & 0x1ffffff) << 5) ^ UInt32(v) + for i in 0..<5 { + if (top >> i) & 1 != 0 { + chk ^= gen[i] + } + } + } + return chk +} + +private extension AssetLockStorageDetailView { + /// Label for the pending row shown when no identity row has /// been persisted for this slot yet. Communicates whether the /// lock is mid-flight (still on its way to finality) versus diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift new file mode 100644 index 00000000000..19e38f2ee08 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift @@ -0,0 +1,786 @@ +// TopUpPlatformAddressView.swift +// SwiftExampleApp +// +// Stepped UI for funding a Platform payment address from a Core +// (SPV) wallet balance. Drives the new `ManagedPlatformAddressWallet +// .topUpFromCore(...)` 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. Submit an `AddressFundingFromAssetLockTransition` against the +// proof to credit the destination platform address. +// 4. Mark the asset lock `Consumed` on success. +// +// No private keys cross the FFI boundary on this path — both +// Core-side derivation (inside the wallet's asset-lock manager) and +// the outer state-transition signature route through a local +// `MnemonicResolver`, atomic per call. + +import SwiftUI +import SwiftDashSDK +import SwiftData + +struct TopUpPlatformAddressView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var platformState: AppState + + /// Wallet to fund a platform address on. Drives both the picker + /// scope (Core BIP44 accounts and Platform addresses on this + /// wallet only) and the managed-wallet lookup at submit time. + 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 + /// `ManagedPlatformAddressWallet.resumeTopUpFromAssetLock` instead + /// of building a fresh lock. The user still picks the recipient + /// platform address because the orphan lock doesn't carry that + /// information — it's set at ST-submission time. + var resumeFromLock: PersistentAssetLock? = nil + + /// All persisted accounts. Filtered down to the wallet's Core + /// BIP44 accounts inside `coreAccountOptions` and to its DIP-17 + /// platform-payment accounts inside `platformAccountOptions`. + @Query private var allAccounts: [PersistentAccount] + + /// All persisted platform addresses. Filtered down to the + /// chosen platform-payment account inside + /// `recipientCandidates`. + @Query private var allPlatformAddresses: [PersistentPlatformAddress] + + // MARK: - Selection state + + @State private var fundingCoreAccountIndex: UInt32? = nil + @State private var platformAccountIndex: UInt32? = nil + @State private var selectedRecipientHash: Data? = nil + @State private var amountDash: String = "0.001" + + // MARK: - Submit state + + /// Pre-submit error (e.g. KeychainSigner / handle lookup failed + /// synchronously before the FFI call). In-flight failures land + /// on the controller's `.failed` phase and are rendered by + /// `AddressTopUpProgressView`'s terminal section instead. + @State private var submitError: SubmitError? = nil + + /// Controller for the in-flight funding attempt. Non-nil swaps + /// the form body for `AddressTopUpProgressSection` + a + /// terminal section that follows the controller's phase. + /// Lifetime-owned by `walletManager.addressTopUpCoordinator` + /// so view dismissal mid-flight doesn't lose the work. + @State private var activeController: AddressTopUpController? = nil + + /// 1 DASH = 1e8 duffs (Core side). The asset-lock builder takes + /// duffs; we convert here for display ergonomics only. + private static let duffsPerDash: UInt64 = 100_000_000 + + /// Conservative floor mirroring `CreateIdentityView` — the + /// platform-side fee strategy `ReduceOutput(remainder_index)` + /// also pays the on-chain fee out of the remainder, so the + /// remainder needs to cover at least the asset-lock fee plus the + /// platform-side fee. 1mDASH (~100k duffs) is well above both. + private static let minDuffs: UInt64 = 100_000 + + var body: some View { + NavigationStack { + Form { + if let controller = activeController { + // Form sections inside a Form render as siblings, + // not nested; the progress section + terminal + // section follow the same shape as + // `RegistrationProgressView`. + AddressTopUpProgressSection(controller: controller) + progressTerminalSection(controller: controller) + } else if resumeFromLock != nil { + // Resume mode: the asset lock + amount + Core + // funding account were all decided at original + // build time. The user only re-picks the + // recipient since the orphan lock doesn't + // carry that — it's set at ST-submit time. + walletSection + resumeFromAssetLockSection + platformAccountSection + recipientSection + if canSubmit { + submitSection + } + } else { + walletSection + coreFundingSection + platformAccountSection + recipientSection + amountSection + if canSubmit { + submitSection + } + } + } + .navigationTitle("Top Up Platform Address") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(activeController?.phase == .inFlight) + } + } + .alert(item: $submitError) { err in + Alert( + title: Text("Could not top up address"), + message: Text(err.message), + dismissButton: .default(Text("OK")) + ) + } + .onAppear(perform: autoSelectDefaults) + .onChange(of: activeController?.phase) { _, newPhase in + // On successful funding, stamp the recipient hash + // onto the matching consumed asset-lock row so the + // storage explorer can show which address received + // the credits. The PersistentAssetLock row is + // written by the persister callback in response to + // Rust's changeset — Rust doesn't know the + // recipient (it's chosen at ST-submit time), so we + // back-fill on the Swift side after the FFI returns. + if case .completed = newPhase { + backfillRecipientOnConsumedLock() + } + } + } + } + + // 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 Platform credits on the destination address.") + } + } + + @ViewBuilder + private var platformAccountSection: some View { + let options = platformAccountOptions + Section { + if options.isEmpty { + Text("No DIP-17 Platform Payment accounts on this wallet yet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Platform Account", selection: $platformAccountIndex) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.accountIndex) { opt in + Text("Account #\(opt.accountIndex) — \(formatCredits(opt.totalCredits))") + .tag(Optional(opt.accountIndex)) + } + } + .onChange(of: platformAccountIndex) { _, _ in + selectedRecipientHash = nil + autoSelectRecipient() + } + } + } header: { + Text("Destination Account") + } footer: { + Text("Platform Payment account that owns the destination address. Picker shows current credit balance.") + } + } + + @ViewBuilder + private var recipientSection: some View { + let options = recipientCandidates + Section { + if options.isEmpty { + Text("No unused addresses available on this platform account. Sync first.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Recipient", selection: $selectedRecipientHash) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.addressHash) { row in + Text("Addr #\(row.addressIndex) — \(row.address.prefix(12))…") + .tag(Optional(row.addressHash)) + } + } + } + } header: { + Text("Destination Address") + } footer: { + Text("Defaults to the lowest-index unused address on the selected platform account.") + } + } + + @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 amount = parsedDuffs { + Text("\(formatDuffs(amount)) duffs will be locked. Minimum: \(formatDuffs(Self.minDuffs)).") + } else { + Text("Minimum: \(formatDuffs(Self.minDuffs)) duffs.") + } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + Text(resumeFromLock == nil ? "Top Up" : "Resume Top Up") + Spacer() + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.accentColor) + } + } + + /// Read-only summary of the asset lock the user is resuming. + /// Replaces both `coreFundingSection` (the lock already exists + /// against a specific account) and `amountSection` (the locked + /// amount is whatever the original build chose). + @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 destination address to complete the funding.") + } + } + } + + /// Inline terminal section that follows the controller's + /// `.completed` / `.failed` phase. Mirrors the + /// `terminalSection` shape on `AddressTopUpProgressView`, + /// but embedded directly in this view's `Form` so the user + /// gets the full result without a separate navigation push. + @ViewBuilder + private func progressTerminalSection( + controller: AddressTopUpController + ) -> some View { + switch controller.phase { + case .completed(let newBalance): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Address funded", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + HStack { + Text("New balance") + .foregroundColor(.secondary) + Spacer() + Text(formatCredits(newBalance)) + .font(.system(.body, design: .monospaced)) + } + Button { + walletManager.addressTopUpCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Top Up failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + Button("Dismiss") { + walletManager.addressTopUpCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } + } + } + default: + EmptyView() + } + } + + // MARK: - Derived + + private struct CoreAccountOption { + let accountIndex: UInt32 + let balanceDuffs: UInt64 + } + + private struct PlatformAccountOption { + let accountIndex: UInt32 + let totalCredits: UInt64 + } + + private var coreAccountOptions: [CoreAccountOption] { + // Surface Core BIP44 standard accounts with spendable + // balance only. The compound filter + // `typeTag == 0 && standardTag == 0` matches BIP44 + // (Standard, BIP44-tagged) — `standardTag` alone would + // include PlatformPayment / CoinJoin / Identity* accounts + // because those leave `standardTag` at its `0` default, + // surfacing duplicate "Account #0" rows. + // + // Balance reads from the live FFI (`accountBalances(for:)`) + // not `PersistentAccount.balanceConfirmed` — the SwiftData + // field is populated by the persister callback and lags + // the in-memory Rust state, so a freshly-synced wallet + // would show zero here even with spendable Core funds. + // + // Zero-balance accounts are excluded so the picker can't + // present a submit path that's guaranteed to fail at the + // Rust-side UTXO selection stage. + walletManager.accountBalances(for: wallet.walletId) + .filter { $0.typeTag == 0 && $0.standardTag == 0 && $0.confirmed > 0 } + .sorted { $0.index < $1.index } + .map { + CoreAccountOption( + accountIndex: $0.index, + balanceDuffs: $0.confirmed + ) + } + } + + /// Confirmed balance of the currently-selected Core funding + /// account, or `0` if no account is selected. Used by + /// `canSubmit` to gate submission on `balance >= parsedDuffs`. + private var selectedCoreAccountBalanceDuffs: UInt64 { + guard let idx = fundingCoreAccountIndex else { return 0 } + return coreAccountOptions.first(where: { $0.accountIndex == idx })?.balanceDuffs ?? 0 + } + + private var platformAccountOptions: [PlatformAccountOption] { + // DIP-17 platform payment accounts. `accountType == 14` is + // the PlatformPayment discriminant on PersistentAccount. + let accounts = allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.accountType == 14 } + .sorted { $0.accountIndex < $1.accountIndex } + return accounts.map { acct in + let total = allPlatformAddresses + .filter { + $0.walletId == wallet.walletId && $0.accountIndex == acct.accountIndex + } + .reduce(into: UInt64(0)) { acc, addr in acc &+= addr.balance } + return PlatformAccountOption(accountIndex: acct.accountIndex, totalCredits: total) + } + } + + private var recipientCandidates: [PersistentPlatformAddress] { + guard let acctIdx = platformAccountIndex else { return [] } + return allPlatformAddresses + .filter { $0.walletId == wallet.walletId && $0.accountIndex == acctIdx && !$0.isUsed && $0.balance == 0 } + .sorted { $0.addressIndex < $1.addressIndex } + } + + private var parsedDuffs: UInt64? { + let raw = amountDash.trimmingCharacters(in: .whitespacesAndNewlines) + guard let dash = Double(raw), dash > 0 else { return nil } + let duffsDouble = dash * Double(Self.duffsPerDash) + guard duffsDouble.isFinite, duffsDouble <= Double(UInt64.max) else { return nil } + return UInt64(duffsDouble.rounded(.toNearestOrAwayFromZero)) + } + + private var canSubmit: Bool { + if resumeFromLock != nil { + // Resume only needs a recipient. The lock + amount + + // funding account are fixed by the original build. + return platformAccountIndex != nil + && selectedRecipientHash != nil + && activeController == nil + } + let amount = parsedDuffs ?? 0 + return fundingCoreAccountIndex != nil + && platformAccountIndex != nil + && selectedRecipientHash != 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 platformAccountIndex == nil { + platformAccountIndex = platformAccountOptions.first?.accountIndex + } + autoSelectRecipient() + } + + private func autoSelectRecipient() { + if selectedRecipientHash == nil { + selectedRecipientHash = recipientCandidates.first?.addressHash + } + } + + private func submit() { + guard + let platformAcct = platformAccountIndex, + let hash = selectedRecipientHash + else { return } + // Recipient resolution can race with SwiftData: between the + // user tapping Fund Address and this body running, the + // selected address may have flipped to `isUsed = true` (a + // concurrent flow consumed it). Surface that as a fail-fast + // error so the button isn't dead on tap. + guard let recipient = recipientCandidates.first(where: { $0.addressHash == hash }) else { + submitError = SubmitError( + message: "The selected recipient address is no longer available (it may have been used by another funding). Pick a fresh address and try again." + ) + return + } + + let managedHolder = walletManager.wallet(for: wallet.walletId) + guard let managedHolder else { + submitError = SubmitError(message: "Wallet handle not found in the wallet manager.") + return + } + let addressWallet: ManagedPlatformAddressWallet + do { + addressWallet = try managedHolder.platformAddressWallet() + } catch { + submitError = SubmitError(message: "Couldn't acquire platform-address wallet: \(error.localizedDescription)") + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + let walletId = wallet.walletId + let recipientHash = recipient.addressHash + let recipientType = recipient.addressType + + // FFI closure — captured into the coordinator so the same + // controller-lifetime guarantees apply to both fresh and + // resume flows. Returning the proof-attested credit + // balance of the recipient so the terminal section can + // surface a meaningful number. + let body: () async throws -> UInt64 + if let lock = resumeFromLock { + // Resume path: outpoint is decoded from the persisted + // `outPointHex` (canonical `:` + // shape produced by `PersistentAssetLock.encodeOutPoint`). + guard let parsed = parseOutPoint(lock.outPointHex) else { + submitError = SubmitError( + message: "Could not parse asset lock outpoint: \(lock.outPointHex)" + ) + return + } + body = { + let updates = try await addressWallet.resumeTopUpFromAssetLock( + outPointTxid: parsed.txid, + outPointVout: parsed.vout, + platformAccountIndex: platformAcct, + recipients: [ + ManagedPlatformAddressWallet.TopUpRecipient( + addressType: recipientType, + hash: recipientHash, + credits: nil + ) + ], + signer: signer + ) + return updates + .first(where: { $0.hash == recipientHash })?.balance ?? 0 + } + } else { + // Fresh build path: needs the funding account + amount + // gates that the resume path skips. + guard + let fundingAccountIndex = fundingCoreAccountIndex, + let duffs = parsedDuffs + else { return } + body = { + let updates = try await addressWallet.topUpFromCore( + amountDuffs: duffs, + fundingAccountIndex: fundingAccountIndex, + platformAccountIndex: platformAcct, + recipients: [ + ManagedPlatformAddressWallet.TopUpRecipient( + addressType: recipientType, + hash: recipientHash, + credits: nil + ) + ], + signer: signer + ) + return updates + .first(where: { $0.hash == recipientHash })?.balance ?? 0 + } + } + + // Capture the set of currently-Consumed address-funding + // outpoints on this wallet BEFORE the FFI fires. The + // post-success back-fill uses the set-difference against + // the new state to deterministically match this funding's + // consumed lock — even when two concurrent fundings on the + // same wallet land in close succession (the previous + // newest-`updatedAt` heuristic mis-stamped in that race). + let preSubmitOutpoints = capturePreSubmitConsumedOutpoints() + + // Single-flight gate via the coordinator. The same slot + // re-presents the existing controller on a duplicate tap + // so two FFI calls never race for the same asset lock. + let coordinator = walletManager.addressTopUpCoordinator + let controller = coordinator.startFunding( + walletId: walletId, + platformAccountIndex: platformAcct, + recipientHash: recipientHash, + recipientType: recipientType, + body: body + ) + controller.preSubmitConsumedOutpoints = preSubmitOutpoints + + // Stash the controller; setting it flips the body to the + // progress section in place of the form. The controller's + // canonical lifetime owner is the coordinator — if the user + // dismisses the sheet mid-flight, the same controller is + // reachable via the "Pending Platform Top Ups" section on + // the wallet detail screen. + activeController = controller + } + + /// Snapshot every outpoint currently marked Consumed for this + /// wallet's address-funding asset locks. Used by the post- + /// success back-fill to compute the "new since submission" + /// delta. Pure read; no writes. + private func capturePreSubmitConsumedOutpoints() -> Set { + let walletId = wallet.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.walletId == walletId + && entry.fundingTypeRaw == 4 + && entry.statusRaw == 4 + } + ) + let rows = (try? modelContext.fetch(descriptor)) ?? [] + return Set(rows.map { $0.outPointHex }) + } + + /// 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 } + // The display hex is reverse-of-wire order; flip to get the + // raw 32-byte little-endian txid the FFI expects. + 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..( + predicate: #Predicate { entry in + entry.walletId == walletId + && entry.fundingTypeRaw == 4 + && entry.statusRaw == 4 + && entry.recipientPlatformAddressHash == nil + }, + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + let matches: [PersistentAssetLock] + do { + matches = try modelContext.fetch(descriptor) + } catch { + // The funding succeeded; this fetch only feeds the + // storage-explorer recipient column. Match the + // surrounding app's `print`-with-emoji logging idiom + // rather than introducing an OSLog dependency just for + // this one call site. + print("⚠️ backfillRecipient: fetch failed: \(error)") + return + } + + let target: PersistentAssetLock? + if let preSubmit = preSubmitSet { + // Deterministic snapshot-delta path. Filter the + // unrecipiented Consumed rows down to those NOT in the + // pre-submit set — those are the genuinely new rows. + let newRows = matches.filter { !preSubmit.contains($0.outPointHex) } + switch newRows.count { + case 1: + target = newRows.first + case 0: + // No new Consumed row visible yet (persister lag); + // skip rather than stamp the wrong unrecipiented + // row. The next funding's delta will pick this row + // up via its own pre-submit snapshot. + print("⚠️ backfillRecipient: no new Consumed outpoint since submission; skipping stamp") + return + default: + // Multi-match: two address-funding flows resolved + // Consumed in the same window. Refuse rather than + // mis-attribute — better both rows show "—" in the + // storage explorer than a wrong attribution. + print("⚠️ backfillRecipient: \(newRows.count) new Consumed outpoints in delta; refusing to stamp ambiguous row") + return + } + } else { + // Snapshot wasn't captured (e.g. a future caller that + // wires up the coordinator directly). Pick the + // newest-unrecipiented row — has a race window when + // multiple flows complete concurrently; see the doc + // comment above. + target = matches.first + } + + guard let lock = target else { return } + lock.recipientPlatformAddressHash = recipientHash + lock.recipientPlatformAddressType = recipientType + do { + try modelContext.save() + } catch { + // SwiftData save failure is rare (typically only on + // disk-full / store-corruption) but worth visible + // surfacing. The funding itself succeeded so we don't + // alert the user — just log. + print("⚠️ backfillRecipient: save failed: \(error)") + } + } + + private func formatDuffs(_ duffs: UInt64) -> String { + let dash = Double(duffs) / Double(Self.duffsPerDash) + return String(format: "%.8f DASH", dash) + } + + private func formatCredits(_ credits: UInt64) -> String { + // Credit divisor matches CreateIdentityView (1e11 credits/DASH). + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.6f DASH (credits)", dash) + } + + private func hexShort(_ data: Data) -> String { + let hex = data.map { String(format: "%02x", $0) }.joined() + return hex.count > 12 ? "\(hex.prefix(6))…\(hex.suffix(6))" : hex + } +} + +// MARK: - Submit error wrapper + +private struct SubmitError: Identifiable { + let id = UUID() + let message: String +}