From 7fdeaa13e76bb1b700f9a0d11754deb1cb8231e5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 19:36:44 +0700 Subject: [PATCH 01/35] refactor(platform-wallet): lift CL-retry + funding resolver to asset_lock/orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the submission-side machinery that's been identity-specific in `wallet/identity/network/registration.rs` into a new sibling module `wallet/asset_lock/orchestration.rs`. The three pieces lifted — `submit_with_cl_height_retry`, `resolve_funding_with_is_timeout_fallback`, and `out_point_from_proof` — were already funding-target-agnostic (generic over the submit closure / parameterized by `funding_type`) but lived inside `IdentityWallet`'s `impl` blocks. The platform-address funding flow needs the same three pieces verbatim, so consolidating them avoids ~150 lines of would-be duplication and locks the CL-retry policy + IS→CL fallback shape to one source of truth. `IdentityFunding` is renamed to `AssetLockFunding` in its new location (funding source is target-agnostic; the resolver's `funding_type` argument picks the BIP44 derivation family). A `pub use ... as IdentityFunding` alias stays at the old path so existing callers and the FFI surface keep compiling. `out_point_from_proof` is now a free `pub(crate) fn` rather than an associated function — it has no manager state dependency, and the manager's generic over `B: TransactionBroadcaster` would force the turbofish on every call site otherwise. The `submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget` test moved with its subject so the regression pin stays beside the code it protects. Pure mechanical move — no behavior change. 124/124 platform-wallet lib tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/asset_lock/mod.rs | 2 + .../src/wallet/asset_lock/orchestration.rs | 559 ++++++++++++++++++ .../identity/network/identity_handle.rs | 16 +- .../wallet/identity/network/registration.rs | 410 +------------ .../src/wallet/identity/types/funding.rs | 89 +-- 5 files changed, 586 insertions(+), 490 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs 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..98e3a9a0f0f --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -0,0 +1,559 @@ +//! 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`. +/// +/// ## Historical note +/// +/// This type used to be named `IdentityFunding` and lived under +/// `wallet/identity/types/funding.rs`. It was renamed and moved to +/// the asset-lock module when the platform-address funding flow +/// needed the same shape — funding source is funding-target-agnostic; +/// the `funding_type` argument to the resolver picks the BIP44 +/// derivation family. A `pub use ... as IdentityFunding` alias is +/// retained at the old path so existing external callers keep +/// compiling. +#[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. +/// +/// 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/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 83a94d30117..b4b467364a9 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, @@ -467,16 +466,7 @@ 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, - } - } + // `out_point_from_proof` moved to `wallet::asset_lock::orchestration` + // as a free `pub(crate) fn` — used by every asset-lock-funded flow + // (identity register/top-up, platform-address funding). } 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..92d0bb95881 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,288 +58,26 @@ 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 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), - } - } - } - } -} +// Timeout policy, the CL-height retry helper, and the IS-timeout-aware +// funding resolver moved to `wallet::asset_lock::orchestration` so the +// identity register/top-up flows and the platform-address funding +// flow share one source of truth for these timeouts and retry shapes. // --------------------------------------------------------------------------- // register @@ -435,6 +171,7 @@ impl IdentityWallet { path, tracked_out_point, } = match self + .asset_locks .resolve_funding_with_is_timeout_fallback( funding, AssetLockFundingType::IdentityRegistration, @@ -493,7 +230,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, @@ -654,6 +391,7 @@ impl IdentityWallet { path, tracked_out_point, } = match self + .asset_locks .resolve_funding_with_is_timeout_fallback( funding, AssetLockFundingType::IdentityTopUp, @@ -690,7 +428,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, @@ -827,126 +565,6 @@ mod tests { ); } - /// 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:?}" - ); - } - } + // The `submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget` + // test moved with its subject to `wallet::asset_lock::orchestration`. } diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs b/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs index 96f0c514561..348bd260920 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs @@ -1,83 +1,10 @@ -//! Funding source enum for identity registration and top-up. +//! Funding source 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, - }, +//! Re-exports [`AssetLockFunding`](crate::wallet::asset_lock::AssetLockFunding) +//! under the original `IdentityFunding` name so existing callers and +//! the FFI surface keep compiling. The type's body moved to the +//! asset-lock module when platform-address funding adopted the same +//! shape — funding source is funding-target-agnostic; the resolver's +//! `funding_type` parameter picks the BIP44 derivation family. - /// 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, - }, -} +pub use crate::wallet::asset_lock::AssetLockFunding as IdentityFunding; From b6e307d707d9e96ec7b136dfcb2614b567f1ef5f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 19:45:12 +0700 Subject: [PATCH 02/35] feat(dpp): add try_from_asset_lock_with_signers for AddressFundingFromAssetLock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the identity-create pattern from PR #3634: routes the outer asset-lock-proof signature on `AddressFundingFromAssetLockTransition` through an external `key_wallet::signer::Signer` rather than a raw `&[u8]` private key. The atomic derive + sign + zeroise sequence happens inside the signer's trust boundary — Rust never sees the asset-lock private key on this path. - Renamed the existing `try_from_asset_lock_with_signer` to `try_from_asset_lock_with_signer_and_private_key` for parity with the identity-create rename. Four downstream call sites (`rs-sdk/top_up_address`, two `strategy-tests`, drive-abci tests) updated in the same commit. - Added `try_from_asset_lock_with_signers` on the V0 inner + outer-enum dispatcher. `S: Signer` signs each per-input `AddressWitness`; `AS: key_wallet::signer::Signer` signs the outer state-transition wrapper signature via `StateTransition::sign_with_core_signer`. - Both `signature` and `input_witnesses` carry `#[platform_signable(exclude_from_sig_hash)]`, so the signable bytes are computed once over the unsigned shape and used for both signing phases. Compiles workspace-clean; 90/90 address_funds tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../methods/mod.rs | 51 ++++++++++++- .../methods/v0/mod.rs | 47 +++++++++++- .../v0/v0_methods.rs | 75 ++++++++++++++++++- .../address_funding_from_asset_lock/tests.rs | 2 +- .../tests/strategy_tests/strategy.rs | 2 +- .../src/platform/transition/top_up_address.rs | 2 +- packages/strategy-tests/src/lib.rs | 4 +- 7 files changed, 170 insertions(+), 13 deletions(-) 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..b91153b8935 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,11 +32,11 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL user_fee_increase: UserFeeIncrease, _platform_version: &PlatformVersion, ) -> Result { - tracing::debug!("try_from_asset_lock_with_signer: Started"); + tracing::debug!("try_from_asset_lock_with_signer_and_private_key: Started"); tracing::debug!( input_count = inputs.len(), output_count = outputs.len(), - "try_from_asset_lock_with_signer" + "try_from_asset_lock_with_signer_and_private_key" ); // Create the unsigned transition @@ -65,7 +65,74 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL } address_funding_transition.input_witnesses = input_witnesses; - tracing::debug!("try_from_asset_lock_with_signer: Successfully created transition"); + tracing::debug!( + "try_from_asset_lock_with_signer_and_private_key: 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, + { + tracing::debug!("try_from_asset_lock_with_signers: Started"); + tracing::debug!( + input_count = inputs.len(), + output_count = outputs.len(), + "try_from_asset_lock_with_signers" + ); + + // 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?; + + tracing::debug!("try_from_asset_lock_with_signers: Successfully created transition"); + 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-sdk/src/platform/transition/top_up_address.rs b/packages/rs-sdk/src/platform/transition/top_up_address.rs index 9317f1e65f5..d2766cf5e0f 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_address.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_address.rs @@ -126,7 +126,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 From ea8e0004c041899568f6b588a70bf597b35f8993 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 19:48:32 +0700 Subject: [PATCH 03/35] feat(rs-sdk): add TopUpAddress::top_up_with_signers signer-pair variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the identity-side `broadcast_request_for_new_identity_with_signer` from PR #3634: routes address-funding asset-lock signing through an external `key_wallet::signer::Signer` instead of a raw `PrivateKey`, so Swift (and any other host holding keys outside Rust) can fund platform addresses without exposing the asset-lock private key across the FFI boundary. The new method threads `settings.user_fee_increase` straight through to the DPP `try_from_asset_lock_with_signers` builder. This is load-bearing for the upstream CL-height retry path in `platform-wallet::wallet::asset_lock::orchestration::submit_with_cl_height_retry`, which bumps `user_fee_increase` between attempts to change the ST's signable bytes — without this plumbing, retries would hash identically and Tenderdash's 24h invalid-tx cache would silently drop them. Existing `top_up` (raw private key) kept for callers that still hold the asset-lock key in Rust. Both impls share a new `broadcast_and_collect_address_infos` helper that owns the proof-shape verification + `AddressInfos` extraction so the two flows can't drift apart on the post-broadcast contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/platform/transition/top_up_address.rs | 153 ++++++++++++++++-- 1 file changed, 139 insertions(+), 14 deletions(-) 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 d2766cf5e0f..a697e34605b 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,37 @@ 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")] + 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 +100,33 @@ where ) .await } + + #[cfg(feature = "core_key_wallet")] + 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 +161,82 @@ 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")] + 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 + ))), } } From 043b01ca210780f5d3dbda7e02f61ab6db67b51c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 20:18:10 +0700 Subject: [PATCH 04/35] feat(platform-wallet): fund_addresses_with_funding orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of `IdentityWallet::register_identity_with_funding` for the platform-address side. Drives the full build → broadcast → wait-IS-or-CL → submit-with-CL-retry → IS→CL-fallback → consume pipeline, with no asset-lock private key crossing the FFI boundary — the asset-lock signer's atomic derive + sign + zeroise sequence handles both Core-side key derivation (inside `AssetLockManager`) and the outer ST signature (`StateTransition::sign_with_core_signer`). Threaded `Arc>` into `PlatformAddressWallet` so the new method can drive the same tracked locks every other sub-wallet sees. `PlatformAddressWallet::new` gains an `asset_locks` parameter; the only caller (`PlatformWallet::new`) clones the existing handle. The pre-existing raw-private-key `fund_from_asset_lock` is kept for callers that hold the asset-lock key in Rust, and now delegates its post-success balance-write bookkeeping to a shared `write_address_balances_changeset` helper that both paths use — no drift between the two flows on the changeset shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + packages/rs-platform-wallet/Cargo.toml | 1 + .../fund_from_asset_lock.rs | 62 +--- .../platform_addresses/fund_with_funding.rs | 350 ++++++++++++++++++ .../src/wallet/platform_addresses/mod.rs | 1 + .../src/wallet/platform_addresses/wallet.rs | 17 + .../src/wallet/platform_wallet.rs | 1 + 7 files changed, 377 insertions(+), 56 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs 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-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index f9e96c08c7e..be070e15156 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" } platform-encryption = { path = "../rs-platform-encryption" } # Key wallet dependencies (from rust-dashcore) 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 index 927b6d0d575..642e679f01e 100644 --- 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 @@ -94,61 +94,11 @@ impl PlatformAddressWallet { ) .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) + // Bookkeeping shared with the orchestrated + // `fund_addresses_with_funding` path so the two flows can't + // drift apart on the post-success balance-write shape. + Ok(self + .write_address_balances_changeset(account_index, &address_infos) + .await) } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs new file mode 100644 index 00000000000..2d54223a16c --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs @@ -0,0 +1,350 @@ +//! 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 +//! (matches [`PlatformAddressWallet::fund_from_asset_lock`]); 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. + #[allow(clippy::too_many_arguments)] + pub async fn fund_addresses_with_funding( + &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. Identical invariants to the existing + // `fund_from_asset_lock` raw-key path; 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?; + 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). + let cs = self + .write_address_balances_changeset(platform_account_index, &address_infos) + .await; + + if let Some(out_point) = tracked_out_point { + // Cleanup failure can only mean WalletNotFound (the wallet + // handle that just funded the addresses vanished). Surface + // as a warn — Platform DID accept the top-up, so + // propagating the error to the caller would be misleading. + if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await { + tracing::warn!( + outpoint = %out_point, + error = %e, + "consume_asset_lock failed after successful Platform submit" + ); + } + } + + Ok(cs) + } + + /// Apply proof-attested credit balances to the + /// `ManagedPlatformAccount` for each recipient address, emitting + /// a `PlatformAddressChangeSet` describing the new balances. + /// + /// Shared between + /// [`fund_addresses_with_funding`](Self::fund_addresses_with_funding) + /// and the existing raw-key + /// [`fund_from_asset_lock`](Self::fund_from_asset_lock) so the + /// two paths can't drift apart on the post-success bookkeeping + /// shape. + pub(super) 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); + 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, ainfo)| { + PlatformP2PKHAddress::from_address(&ainfo.address) + .ok() + .filter(|found| *found == p2pkh) + .map(|_| idx) + }) + .unwrap_or(0); + 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( + "fund_addresses_with_funding 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/mod.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs index d216228284a..0112e3b2e7f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -7,6 +7,7 @@ use dpp::fee::Credits; pub use dpp::prelude::AddressNonce; mod fund_from_asset_lock; +mod fund_with_funding; pub(crate) mod provider; mod sync; mod transfer; 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..615514c2276 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 + /// `fund_addresses_with_funding` 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(), ); From 39f6d1541d1a692dcd75d9fbf70f919a34d12d33 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 20:21:17 +0700 Subject: [PATCH 05/35] feat(platform-wallet-ffi): platform_address_wallet_fund_with_funding_signer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New entry point that drives the wallet's orchestrated platform-address funding pipeline (build → IS-or-CL → submit → consume) using the same `(SignerHandle, MnemonicResolverHandle)` pair shape as the identity-side `platform_wallet_register_identity_with_funding_signer`. The asset-lock private key never crosses the FFI boundary as raw bytes — both Core-side derivation and the outer ST signature go through `MnemonicResolverCoreSigner`. Sister `platform_address_wallet_resume_fund_with_existing_asset_lock_signer` for the crash-recovery shape (resume from a tracked outpoint instead of building a fresh asset lock). The pre-existing raw-private-key `platform_address_wallet_fund_from_asset_lock` is kept; its hardcoded `Network::Mainnet` `PrivateKey` construction is now replaced with the wallet's own `network()`, fixing a latent bug that would produce a mismatched-network `PrivateKey` on testnet/devnet/regtest. ECDSA signing itself is network-agnostic so the bug never bit, but the value would have been wrong if ever re-exported. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fund_from_asset_lock.rs | 19 +- .../platform_addresses/fund_with_funding.rs | 241 ++++++++++++++++++ .../src/platform_addresses/mod.rs | 2 + 3 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs 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 index 979c29bf1d3..9bbfaa1042e 100644 --- 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 @@ -49,16 +49,27 @@ pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock( ); 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| { + // Pull the network from the wallet so `PrivateKey::network` + // matches the SPV chain we're signing for. The earlier + // hardcoded `Network::Mainnet` quietly produced a testnet/ + // devnet PrivateKey wearing a mainnet label — harmless for + // ECDSA signing itself (the `network` field is purely a + // serialization tag) but a footgun if the value was ever + // exposed back over an FFI. + let private_key = match dashcore::PrivateKey::from_byte_array(key_array, wallet.network()) { + Ok(pk) => pk, + Err(e) => { + return Err(platform_wallet::PlatformWalletError::AddressOperation( + format!("invalid asset-lock private key bytes: {e}"), + )); + } + }; runtime().block_on(wallet.fund_from_asset_lock( account_index, address_map, diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs new file mode 100644 index 00000000000..14fc6eb1678 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs @@ -0,0 +1,241 @@ +//! 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_fund_with_funding_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 + .fund_addresses_with_funding( + 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_fund_with_funding_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_fund_with_funding_signer`]. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_address_wallet_resume_fund_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 { + 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 + .fund_addresses_with_funding( + 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 +/// `fund_addresses_with_funding` consumes. +/// +/// Shared with the legacy raw-private-key +/// [`crate::platform_addresses::fund_from_asset_lock`] FFI so both +/// entry points agree on the wire shape. +/// +/// # Safety +/// - `addresses` must be a valid, non-null pointer to an array of at +/// least `addresses_count` `FundingAddressEntryFFI` entries. +pub(super) unsafe fn decode_funding_addresses( + addresses: *const FundingAddressEntryFFI, + addresses_count: usize, +) -> Result>, PlatformWalletFFIResult> { + 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 + }; + address_map.insert(addr, balance); + } + Ok(address_map) +} 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..7294a346208 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs @@ -3,6 +3,7 @@ //! Mirrors the structure of `platform_wallet::wallet::platform_addresses`. mod fund_from_asset_lock; +mod fund_with_funding; mod sync; mod transfer; mod wallet; @@ -10,6 +11,7 @@ mod withdrawal; // Re-export all FFI types and functions. pub use fund_from_asset_lock::*; +pub use fund_with_funding::*; pub use sync::*; pub use transfer::*; pub use wallet::*; From 2165bb54e8acaffd6ccebf8ac4771b216bf2d51b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 20:30:40 +0700 Subject: [PATCH 06/35] feat(swift-sdk): fundFromCoreAssetLock + resumeFundFromAssetLock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin Swift wrappers over the new Rust FFI entry points. Each is a single Task.detached → withExtendedLifetime((signer, coreSigner)) → withUnsafeBufferPointer → FFI call → decode-changeset shape, with a synchronous pre-flight that mirrors the Rust-side invariant checks (non-empty recipients, exactly one nil-credits remainder, 20-byte hashes) so the user sees a fail-fast error before paying for the Task spin-up. The internal `MnemonicResolver()` is held by the calling actor through the FFI call via `withExtendedLifetime` — never `_ = signer`, which the optimizer can elide in -O builds and drop the resolver mid-call → use-after-free in the vtable callback. Same lifetime discipline as `ManagedPlatformWallet.registerIdentityWithFunding` / `resumeIdentityWithAssetLock` from PR #3634. Per the swift-sdk CLAUDE.md "thin marshaler" rule: no decisions in Swift. All orchestration (build asset-lock tx, wait IS/CL, submit ST, consume on success) lives in Rust's `PlatformAddressWallet::fund_addresses_with_funding`. Swift just marshals recipients + fee strategy + signer handles in and decodes the proof-attested balances out. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ManagedPlatformAddressWallet.swift | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 1240e4630d9..91bc30123c0 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 `fundFromCoreAssetLock(...)`. + /// + /// 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 FundingRecipient: 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 fundFromCoreAssetLock( + amountDuffs: UInt64, + fundingAccountIndex: UInt32, + platformAccountIndex: UInt32, + recipients: [FundingRecipient], + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + try fundFromCoreAssetLockPreflight(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_fund_with_funding_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 [`fundFromCoreAssetLock`]: 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 resumeFundFromAssetLock( + outPointTxid: Data, + outPointVout: UInt32, + platformAccountIndex: UInt32, + recipients: [FundingRecipient], + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + guard outPointTxid.count == 32 else { + throw PlatformWalletError.invalidParameter( + "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" + ) + } + try fundFromCoreAssetLockPreflight(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_fund_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 fundFromCoreAssetLockPreflight( + recipients: [FundingRecipient] + ) 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( + "FundingRecipient.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.. Date: Tue, 19 May 2026 20:39:53 +0700 Subject: [PATCH 07/35] =?UTF-8?q?feat(SwiftExampleApp):=20FundPlatformAddr?= =?UTF-8?q?essView=20for=20Core=E2=86=92Platform=20funding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end UI surface for the new fundFromCoreAssetLock flow. Presented as a sheet from `WalletDetailView` (third action button next to Send / Receive), the view drives the full pipeline from a single screen: Core BIP44 funding account picker (filters to standardTag = 0) → DIP-17 platform-payment account picker (accountType = 14) → unused-address picker (auto-selects lowest-index unused row) → amount-in-DASH input → Fund Address button Submit calls `addressWallet.fundFromCoreAssetLock(...)` against the selected wallet's managed handle. The Rust side owns every non-marshaling decision per the swift-sdk CLAUDE.md "thin marshaller" rule — Swift only collects user input, looks up the managed wallet via `walletManager.wallet(for:)`, and renders the proof-attested credit balance returned from the FFI. Single-recipient remainder pattern: the auto-picked unused address gets the entire asset-lock value (`credits = nil`); the on-chain fee is deducted from the same output via the `ReduceOutput(0)` fee strategy. Multi-recipient flows can be added later if needed; the SDK method already supports them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/WalletDetailView.swift | 13 + .../Views/FundPlatformAddressView.swift | 433 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 7fbb2f1450e..44a52ebdffa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -27,6 +27,7 @@ struct WalletDetailView: View { @State private var showReceiveAddress = false @State private var showSendTransaction = false @State private var showWalletInfo = false + @State private var showFundPlatformAddress = false // Badge count for "View All Transactions". Transactions are no // longer wallet-scoped (the same on-chain tx can land in @@ -93,6 +94,15 @@ struct WalletDetailView: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) + + Button { + showFundPlatformAddress = true + } label: { + Label("Fund L2", systemImage: "arrow.right.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityIdentifier("walletDetail.fundPlatformAddressButton") } .padding(.horizontal) @@ -176,6 +186,9 @@ struct WalletDetailView: View { dismiss() } } + .sheet(isPresented: $showFundPlatformAddress) { + FundPlatformAddressView(wallet: wallet) + } .onAppear { appUIState.showWalletsSyncDetails = false } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift new file mode 100644 index 00000000000..7ebad0255f4 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -0,0 +1,433 @@ +// FundPlatformAddressView.swift +// SwiftExampleApp +// +// Stepped UI for funding a Platform payment address from a Core +// (SPV) wallet balance. Drives the new `ManagedPlatformAddressWallet +// .fundFromCoreAssetLock(...)` 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 FundPlatformAddressView: 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 + + /// 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 + + @State private var isSubmitting: Bool = false + @State private var submitError: SubmitError? = nil + @State private var newBalance: UInt64? = 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 newBalance != nil { + successSection + } else { + walletSection + coreFundingSection + platformAccountSection + recipientSection + amountSection + if canSubmit { + submitSection + } + } + } + .navigationTitle("Fund Platform Address") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + } + .alert(item: $submitError) { err in + Alert( + title: Text("Could not fund address"), + message: Text(err.message), + dismissButton: .default(Text("OK")) + ) + } + .onAppear(perform: autoSelectDefaults) + } + } + + // MARK: - Sections + + private var walletSection: some View { + Section { + HStack { + Label("Wallet", systemImage: "wallet.pass") + Spacer() + Text(wallet.name ?? hexShort(wallet.walletId)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } header: { + Text("Source") + } + } + + @ViewBuilder + private var coreFundingSection: some View { + let options = coreAccountOptions + Section { + if options.isEmpty { + Text("No funded 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 Funding 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(isSubmitting) + 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 { + if isSubmitting { + ProgressView().controlSize(.small).tint(.white) + Text("Funding…") + } else { + Text("Fund Address") + } + Spacer() + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.accentColor) + .disabled(isSubmitting) + } + } + + @ViewBuilder + private var successSection: some View { + Section { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Address funded") + .fontWeight(.semibold) + } + if let bal = newBalance { + HStack { + Text("New balance") + Spacer() + Text(formatCredits(bal)) + .foregroundColor(.secondary) + } + } + Button("Done") { dismiss() } + } header: { + Text("Success") + } + } + + // 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 (`standardTag == 0`). + // Balance shown from `balanceConfirmed` so the user sees + // what's spendable. UTXO selection itself is owned by the + // Rust-side asset-lock builder. + allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.standardTag == 0 } + .sorted { $0.accountIndex < $1.accountIndex } + .map { + CoreAccountOption( + accountIndex: $0.accountIndex, + balanceDuffs: $0.balanceConfirmed + ) + } + } + + 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 { + fundingCoreAccountIndex != nil + && platformAccountIndex != nil + && selectedRecipientHash != nil + && (parsedDuffs ?? 0) >= Self.minDuffs + && !isSubmitting + } + + // 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 fundingAccountIndex = fundingCoreAccountIndex, + let platformAcct = platformAccountIndex, + let hash = selectedRecipientHash, + let recipient = recipientCandidates.first(where: { $0.addressHash == hash }), + let duffs = parsedDuffs + else { 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) + + isSubmitting = true + Task { + defer { isSubmitting = false } + do { + let updates = try await addressWallet.fundFromCoreAssetLock( + amountDuffs: duffs, + fundingAccountIndex: fundingAccountIndex, + platformAccountIndex: platformAcct, + recipients: [ + // Single recipient — the auto-picked unused + // address — gets the entire remainder after + // the on-chain fee. `credits = nil` is the + // canonical "receive remainder" marker. + ManagedPlatformAddressWallet.FundingRecipient( + addressType: recipient.addressType, + hash: recipient.addressHash, + credits: nil + ) + ], + signer: signer + ) + // The remainder recipient's balance reflects the + // proof-attested credit total. Surface it so the + // success row is meaningful. + let credited = updates.first(where: { $0.hash == recipient.addressHash })?.balance ?? 0 + newBalance = credited + } catch let err as PlatformWalletError { + submitError = SubmitError(message: err.localizedDescription) + } catch { + submitError = SubmitError(message: "\(error)") + } + } + } + + // MARK: - Helpers + + 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 +} From ff31d50126423dc5c7131e0da541ba4fedcb3e39 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 21:33:28 +0700 Subject: [PATCH 08/35] refactor(SwiftExampleApp): inline "+" affordance on Platform Balance row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the top-level "Fund L2" action button next to Send / Receive in favor of a small `plus.circle.fill` icon button at the trailing edge of the Platform Balance row in `BalanceCardView`. The button still opens the same `FundPlatformAddressView` sheet — only the entry point moved. Rationale: the action-button strip is reserved for Core (L1) operations; a third button there was visually heavy and conceptually mismatched. The "+" lives directly next to the balance it tops up, which reads as the natural affordance. Implementation: - `WalletBalanceRow` gains an optional `TrailingAction` (system image + accessibility label + closure). The struct nests inside the row so call sites name it as `WalletBalanceRow.TrailingAction`. - `BalanceCardView` takes an optional `onFundPlatform: (() -> Void)?` callback and wires it onto the Platform row only. Read-only callers pass `nil` and the affordance disappears entirely. - `WalletDetailView` passes its `showFundPlatformAddress = true` closure through. The sheet presentation stays where it was. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/WalletDetailView.swift | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 44a52ebdffa..89c80a54127 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -74,7 +74,9 @@ struct WalletDetailView: View { .padding(.top, 8) // Balance Card - BalanceCardView(wallet: wallet) + BalanceCardView(wallet: wallet) { + showFundPlatformAddress = true + } .padding() // Action Buttons @@ -94,15 +96,6 @@ struct WalletDetailView: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) - - Button { - showFundPlatformAddress = true - } label: { - Label("Fund L2", systemImage: "arrow.right.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityIdentifier("walletDetail.fundPlatformAddressButton") } .padding(.horizontal) @@ -670,6 +663,12 @@ struct WalletInfoView: View { struct BalanceCardView: View { let wallet: PersistentWallet + /// Invoked when the user taps the "+" affordance next to the + /// Platform Balance row. The parent owns the sheet presentation + /// state, so we surface the intent rather than presenting here. + /// `nil` hides the affordance entirely (e.g. for read-only + /// surfaces). + var onFundPlatform: (() -> Void)? @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @EnvironmentObject var shieldedService: ShieldedService @@ -678,8 +677,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( @@ -740,13 +740,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: "Fund Platform Balance from Core", + action: fund + ) + } ) // Shielded Balance row @@ -772,12 +783,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 { @@ -810,6 +832,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) + } } } From 9889023d99e3396c484239f794d15e0fa644a2c8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 21:48:57 +0700 Subject: [PATCH 09/35] fix(SwiftExampleApp): correct Core account filter + live balance in FundPlatformAddressView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caught during testnet manual testing: 1. **Duplicate "Account #0" rows.** The Core-funding-account picker filtered by `standardTag == 0` alone, which meant every PlatformPayment / CoinJoin / Identity account also matched (they all leave `standardTag` at its `0` default — meaningless for non-Standard variants). Result: the picker showed one "Account #0" row per AccountType, all looking identical, and SwiftUI logged `ForEach<…>: the ID 0 occurs multiple times` warnings. Fix: the compound predicate is `typeTag == 0 && standardTag == 0` — BIP44 Standard only. 2. **Balance always read as zero.** The picker read `PersistentAccount.balanceConfirmed`, which is populated by the persister callback and lags the live Rust state. A freshly-synced wallet with spendable Core funds would still show zero here. Fix: route through `walletManager.accountBalances(for:)` — same source `BalanceCardView` already uses for the Core Balance row, so the user sees a consistent number. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/FundPlatformAddressView.swift | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index 7ebad0255f4..ce24680e218 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -273,18 +273,26 @@ struct FundPlatformAddressView: View { } private var coreAccountOptions: [CoreAccountOption] { - // Surface Core BIP44 standard accounts (`standardTag == 0`). - // Balance shown from `balanceConfirmed` so the user sees - // what's spendable. UTXO selection itself is owned by the - // Rust-side asset-lock builder. - allAccounts - .filter { $0.wallet.walletId == wallet.walletId } - .filter { $0.standardTag == 0 } - .sorted { $0.accountIndex < $1.accountIndex } + // Surface Core BIP44 standard accounts 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 + // (meaningless for non-Standard variants), surfacing + // duplicate "Account #0" rows in the picker. + // + // 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. + walletManager.accountBalances(for: wallet.walletId) + .filter { $0.typeTag == 0 && $0.standardTag == 0 } + .sorted { $0.index < $1.index } .map { CoreAccountOption( - accountIndex: $0.accountIndex, - balanceDuffs: $0.balanceConfirmed + accountIndex: $0.index, + balanceDuffs: $0.confirmed ) } } From 9efcf6e25d422795b892c4b69642974271fffbeb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 22:21:00 +0700 Subject: [PATCH 10/35] feat(SwiftExampleApp): live progress UI for platform-address funding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the identity-side `RegistrationProgressView` for the address-funding flow. Adds an `AddressFundingController` + `AddressFundingCoordinator` + `AddressFundingProgressView` trio mirroring the identity-side shape, and wires `FundPlatformAddressView` to swap its form body for the 4-step progress section once Submit fires. Step mapping (driven by `PersistentAssetLock.statusRaw` + elapsed time since `updatedAt`, same heuristic the identity side uses): 1. Building asset-lock transaction — statusRaw == 0 2. Broadcasting — statusRaw == 1, <2s since update 3. Waiting for InstantSend proof — statusRaw == 1, 2s..300s (rendered as "Waiting for ChainLock proof" past the 300s IS timeout to communicate that the wallet has fallen back to CL finality — the step count stays at 4) 4. Funding platform address — statusRaw ∈ {2, 3} Single-flight gate at the coordinator level (`(walletId, platformAccountIndex, recipientHash)`) prevents a double-tap on Submit from racing two FFI calls for the same asset lock. Controllers survive view dismissal via the coordinator's `@Published` map; the (forthcoming) "Pending Platform Funding" row on the wallet detail screen will surface them after this commit. Inline terminal section ("Address funded" / "Funding failed") embedded directly in `FundPlatformAddressView`'s form so the user sees the result without a separate navigation push. Standalone `AddressFundingProgressView` is also exposed for the resumable-funding surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/AddressFundingController.swift | 136 +++++++ .../Services/AddressFundingCoordinator.swift | 171 +++++++++ ...letManager+AddressFundingCoordinator.swift | 37 ++ .../Views/AddressFundingProgressView.swift | 348 ++++++++++++++++++ .../Views/FundPlatformAddressView.swift | 159 +++++--- 5 files changed, 802 insertions(+), 49 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundingCoordinator.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift new file mode 100644 index 00000000000..b581c456020 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift @@ -0,0 +1,136 @@ +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 `FundPlatformAddressView`. The +/// controller owns the in-flight `Task`, exposes its current `phase` +/// via `@Published`, and survives view dismissal via +/// `AddressFundingCoordinator` on `PlatformWalletManager`. +/// +/// The 4-step progress in `AddressFundingProgressView` 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 AddressFundingController: 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 + /// `AddressFundingProgressView`'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 + + /// Timestamp of the most recent `submit` call. Used by the + /// coordinator's TTL-based retention policy (`.completed` rows + /// purge ~30s after the success transition). + private(set) var lastSubmittedAt: Date? + + /// Active funding task. Holds a reference so the coordinator's + /// stash retains the work until completion; cancellation isn't + /// wired today (the FFI call doesn't yet support clean abort). + private var task: Task? + + init(walletId: Data, platformAccountIndex: UInt32, recipientHash: Data) { + self.walletId = walletId + self.platformAccountIndex = platformAccountIndex + self.recipientHash = recipientHash + } + + /// 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/AddressFundingCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift new file mode 100644 index 00000000000..f16633311e1 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift @@ -0,0 +1,171 @@ +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 AddressFundingCoordinator: 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 Funding" row on the Wallet Detail + /// screen can observe map mutations via `objectWillChange`. + @Published private(set) var controllers: [SlotKey: AddressFundingController] = [:] + + /// 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 + ) -> AddressFundingController? { + 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() -> [AddressFundingController] { + 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 `FundPlatformAddressView` to bind a + /// `AddressFundingProgressView` 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, + body: @escaping () async throws -> UInt64 + ) -> AddressFundingController { + 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) + scheduleRetentionSweep(key: key, controller: existing) + return existing + } + } + let controller = AddressFundingController( + walletId: walletId, + platformAccountIndex: platformAccountIndex, + recipientHash: recipientHash + ) + 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: AddressFundingController + ) { + 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+AddressFundingCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundingCoordinator.swift new file mode 100644 index 00000000000..88d9305dc4c --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundingCoordinator.swift @@ -0,0 +1,37 @@ +import Foundation +import ObjectiveC +import SwiftDashSDK + +/// Per-manager `AddressFundingCoordinator` 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 `AddressFundingController` 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 addressFundingCoordinatorKey: UInt8 = 0 + + /// Per-manager address-funding coordinator. Created on first + /// access; subsequent reads return the same instance. + var addressFundingCoordinator: AddressFundingCoordinator { + if let existing = objc_getAssociatedObject( + self, + &PlatformWalletManager.addressFundingCoordinatorKey + ) as? AddressFundingCoordinator { + return existing + } + let fresh = AddressFundingCoordinator() + objc_setAssociatedObject( + self, + &PlatformWalletManager.addressFundingCoordinatorKey, + fresh, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return fresh + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift new file mode 100644 index 00000000000..7750b19fcc5 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift @@ -0,0 +1,348 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Embeddable 4-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` +/// 3a. Waiting for ChainLock proof → activeLock `statusRaw == 1` and +/// >= `instantLockTimeout`; or +/// `statusRaw == 3` (CL-locked). +/// Rendered as "step 3" in the UI +/// with a CL-specific footer so +/// step count stays at 4. +/// 4. Funding platform address → activeLock `statusRaw ∈ {2, 3}` +/// AND controller still `.inFlight` +/// +/// `.completed` is the *terminal* state and is not a separate step; +/// the parent `AddressFundingProgressView` renders the "Address +/// funded" banner + the new balance below this section. `.failed` +/// marks the current step with the error icon + message. +struct AddressFundingProgressSection: View { + @ObservedObject var controller: AddressFundingController + + /// 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: AddressFundingController) { + 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 + // 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 + let chainLockFallbackEngaged = isInChainLockFallback(now: now) + + Section { + ForEach(1...4, id: \.self) { idx in + stepRow( + index: idx, + title: stepTitle(idx, chainLockFallback: chainLockFallbackEngaged), + state: stepState(idx, currentStep: step, isFailed: isFailed) + ) + if idx == 4, let message = errorMessage { + Text(message) + .font(.caption) + .foregroundColor(.red) + .padding(.leading, 32) + } + } + } header: { + Text("Funding Progress") + } footer: { + Text(footerText(step: step, isFailed: isFailed, chainLockFallback: chainLockFallbackEngaged)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Step computation + + /// 1...4, current active step. On `.completed` we report 5 (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: + return 5 + 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 4 + default: return 1 + } + } + return 4 + 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, 3: + // IS-locked or CL-locked → submitting the ST. + return 4 + default: + return 1 + } + } + } + + /// Resolve which of steps 2/3 is "active" while the lock is at + /// `statusRaw == 1`. Brief broadcasting window first, then IS + /// wait, then CL fallback (which still renders as step 3 with a + /// CL-specific footer so the step count stays at 4). + private func broadcastSubStep(for lock: PersistentAssetLock, now: Date) -> Int { + let elapsed = now.timeIntervalSince(lock.updatedAt) + if elapsed < Self.broadcastingWindow { return 2 } + return 3 + } + + /// Returns true once the IS wait has rolled over to the CL + /// fallback window. Drives the step-3 title swap and the + /// footer text so the user knows the IS branch was abandoned. + private func isInChainLockFallback(now: Date) -> Bool { + guard let lock = activeLocks.first else { return false } + switch lock.statusRaw { + case 1: + return now.timeIntervalSince(lock.updatedAt) >= Self.instantLockTimeout + case 3: + return true + default: + return false + } + } + + 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, chainLockFallback: Bool) -> String { + switch idx { + case 1: return "Building asset-lock transaction" + case 2: return "Broadcasting" + case 3: + return chainLockFallback + ? "Waiting for ChainLock proof" + : "Waiting for InstantSend proof" + case 4: return "Funding platform address" + default: return "" + } + } + + enum StepState { case done, active, pending, failed } + + private func stepState(_ idx: Int, currentStep: Int, isFailed: Bool) -> StepState { + if isFailed && idx == currentStep { + return .failed + } + if idx < currentStep { + 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 .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 .failed: return .red + } + } + + private func footerText(step: Int, isFailed: Bool, chainLockFallback: 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 chainLockFallback + ? "InstantSend timed out; falling back to ChainLock finality (~2 min)." + : "Waiting for the InstantSend lock so the asset-lock proof is final." + case 4: return "Submitting the AddressFundingFromAssetLock state transition to Platform." + case 5: return "Address funded." + default: return "" + } + } +} + +/// Standalone navigation destination for an address funding in +/// flight, completed, or failed. Pushed from `FundPlatformAddressView` +/// on submit and (later) from the "Resumable Funding" surface. +struct AddressFundingProgressView: View { + @ObservedObject var controller: AddressFundingController + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var walletManager: PlatformWalletManager + + init(controller: AddressFundingController) { + self.controller = controller + } + + var body: some View { + Form { + AddressFundingProgressSection(controller: controller) + terminalSection + } + .navigationTitle("Fund 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.addressFundingCoordinator.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("Funding failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + 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/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index ce24680e218..14479e40ef9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -50,9 +50,18 @@ struct FundPlatformAddressView: View { // MARK: - Submit state - @State private var isSubmitting: Bool = false + /// 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 + /// `AddressFundingProgressView`'s terminal section instead. @State private var submitError: SubmitError? = nil - @State private var newBalance: UInt64? = nil + + /// Controller for the in-flight funding attempt. Non-nil swaps + /// the form body for `AddressFundingProgressSection` + a + /// terminal section that follows the controller's phase. + /// Lifetime-owned by `walletManager.addressFundingCoordinator` + /// so view dismissal mid-flight doesn't lose the work. + @State private var activeController: AddressFundingController? = nil /// 1 DASH = 1e8 duffs (Core side). The asset-lock builder takes /// duffs; we convert here for display ergonomics only. @@ -68,8 +77,13 @@ struct FundPlatformAddressView: View { var body: some View { NavigationStack { Form { - if newBalance != nil { - successSection + 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`. + AddressFundingProgressSection(controller: controller) + progressTerminalSection(controller: controller) } else { walletSection coreFundingSection @@ -86,7 +100,7 @@ struct FundPlatformAddressView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } - .disabled(isSubmitting) + .disabled(activeController?.phase == .inFlight) } } .alert(item: $submitError) { err in @@ -200,7 +214,7 @@ struct FundPlatformAddressView: View { TextField("Amount", text: $amountDash) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) - .disabled(isSubmitting) + .disabled(activeController != nil) Text("DASH") .foregroundColor(.secondary) } @@ -221,42 +235,76 @@ struct FundPlatformAddressView: View { submit() } label: { HStack { - if isSubmitting { - ProgressView().controlSize(.small).tint(.white) - Text("Funding…") - } else { - Text("Fund Address") - } + Text("Fund Address") Spacer() } .foregroundColor(.white) } .frame(maxWidth: .infinity) .listRowBackground(Color.accentColor) - .disabled(isSubmitting) } } + /// Inline terminal section that follows the controller's + /// `.completed` / `.failed` phase. Mirrors the + /// `terminalSection` shape on `AddressFundingProgressView`, + /// but embedded directly in this view's `Form` so the user + /// gets the full result without a separate navigation push. @ViewBuilder - private var successSection: some View { - Section { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("Address funded") - .fontWeight(.semibold) + private func progressTerminalSection( + controller: AddressFundingController + ) -> 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.addressFundingCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } } - if let bal = newBalance { - HStack { - Text("New balance") - Spacer() - Text(formatCredits(bal)) - .foregroundColor(.secondary) + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Funding failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + Button("Dismiss") { + walletManager.addressFundingCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } } } - Button("Done") { dismiss() } - } header: { - Text("Success") + default: + EmptyView() } } @@ -334,7 +382,7 @@ struct FundPlatformAddressView: View { && platformAccountIndex != nil && selectedRecipientHash != nil && (parsedDuffs ?? 0) >= Self.minDuffs - && !isSubmitting + && activeController == nil } // MARK: - Actions @@ -379,39 +427,52 @@ struct FundPlatformAddressView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - - isSubmitting = true - Task { - defer { isSubmitting = false } - do { + let walletId = wallet.walletId + let recipientHash = recipient.addressHash + let recipientType = recipient.addressType + + // 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.addressFundingCoordinator + let controller = coordinator.startFunding( + walletId: walletId, + platformAccountIndex: platformAcct, + recipientHash: recipientHash, + body: { + // FFI body — runs on a background priority detached + // Task owned by the controller. Returns the proof- + // attested credit balance of the recipient address + // so the terminal section can surface a meaningful + // number. let updates = try await addressWallet.fundFromCoreAssetLock( amountDuffs: duffs, fundingAccountIndex: fundingAccountIndex, platformAccountIndex: platformAcct, recipients: [ - // Single recipient — the auto-picked unused - // address — gets the entire remainder after + // Single recipient — gets the remainder after // the on-chain fee. `credits = nil` is the // canonical "receive remainder" marker. ManagedPlatformAddressWallet.FundingRecipient( - addressType: recipient.addressType, - hash: recipient.addressHash, + addressType: recipientType, + hash: recipientHash, credits: nil ) ], signer: signer ) - // The remainder recipient's balance reflects the - // proof-attested credit total. Surface it so the - // success row is meaningful. - let credited = updates.first(where: { $0.hash == recipient.addressHash })?.balance ?? 0 - newBalance = credited - } catch let err as PlatformWalletError { - submitError = SubmitError(message: err.localizedDescription) - } catch { - submitError = SubmitError(message: "\(error)") + return updates + .first(where: { $0.hash == recipientHash })?.balance ?? 0 } - } + ) + + // 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 (forthcoming) "Pending Platform Funding" + // surface on the wallet detail screen. + activeController = controller } // MARK: - Helpers From ff7b54f4977da35dde6f0fd7d9d531f594f314ae Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 22:27:57 +0700 Subject: [PATCH 11/35] feat(SwiftExampleApp): resume surface for stuck platform-address fundings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Pending Platform Funding" card to `WalletDetailView`, between the action buttons and the Transactions section. Two row sources merge into the card: 1. In-flight `AddressFundingController`s from the coordinator's `@Published controllers` map — the live submit-still-running case. Tap drills into `AddressFundingProgressView`. 2. Orphaned `PersistentAssetLock` rows with `fundingTypeRaw == AssetLockAddressTopUp` (4) and `statusRaw ∈ [1, 3]` — crash-recovery case where the user killed the app between asset-lock broadcast and ST submission. Tap → "Resume" opens `FundPlatformAddressView` in resume mode pre-seeded with the lock's outpoint. `FundPlatformAddressView` gains an optional `resumeFromLock: PersistentAssetLock?` parameter. When set: - The form hides the Core-funding-account picker and amount field (both were decided at original build time and live in the persisted lock row). - Submit routes to `resumeFundFromAssetLock` instead of `fundFromCoreAssetLock`. The outpoint is parsed from the persisted `outPointHex` (canonical `:` shape) and reversed back to wire order for the FFI. - Submit-button label flips to "Resume Funding". The orphan-lock anti-join machinery is wired with an empty controller-side outpoint set today (the controller doesn't yet expose its lock's outpoint). The SwiftData `statusRaw` upper bound (<=3, excludes Consumed) already prevents stale rows from surfacing once the ST has landed; the de-dupe set lives for a future tweak where the controller can surface its outpoint after the broadcast step. The card collapses to nothing when neither source has rows, so a freshly-synced wallet with nothing in flight doesn't see an empty header. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/WalletDetailView.swift | 32 +++ .../Views/FundPlatformAddressView.swift | 171 +++++++++-- .../Views/PendingPlatformFundingsList.swift | 266 ++++++++++++++++++ 3 files changed, 445 insertions(+), 24 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 89c80a54127..ca87ba10c50 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -28,6 +28,16 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false @State private var showFundPlatformAddress = false + /// Bound by `PendingPlatformFundingsList`'s Resume row. Setting + /// non-nil presents `FundPlatformAddressView` in resume mode + /// against this lock's outpoint. + @State private var resumingAssetLock: PersistentAssetLock? + + /// Asset-lock rows for this wallet. Drives both the "Pending + /// Platform Funding" section's resumable-row enumeration and + /// (cheap) reactivity to status transitions across the wallet + /// detail screen. + @Query private var walletAssetLocks: [PersistentAssetLock] // Badge count for "View All Transactions". Transactions are no // longer wallet-scoped (the same on-chain tx can land in @@ -46,6 +56,10 @@ struct WalletDetailView: View { ) descriptor.propertiesToFetch = [\.walletId] _walletTxos = Query(descriptor) + _walletAssetLocks = Query( + filter: PersistentAssetLock.predicate(walletId: walletId), + sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)] + ) } private var transactionCount: Int { @@ -99,6 +113,18 @@ struct WalletDetailView: View { } .padding(.horizontal) + // Pending / Resumable platform funding — collapses to + // nothing when there's no in-flight controller and no + // orphan asset-lock row, so a freshly-synced wallet + // with nothing pending doesn't see an empty card. + PendingPlatformFundingsList( + coordinator: walletManager.addressFundingCoordinator, + walletId: wallet.walletId, + assetLocks: walletAssetLocks, + resumingAssetLock: $resumingAssetLock + ) + .padding(.top, 8) + Divider() .padding(.vertical, 8) @@ -182,6 +208,12 @@ struct WalletDetailView: View { .sheet(isPresented: $showFundPlatformAddress) { FundPlatformAddressView(wallet: wallet) } + .sheet(item: $resumingAssetLock) { lock in + // Resume mode: the Fund view branches on `resumeFromLock` + // and routes Submit to `resumeFundFromAssetLock` against + // this lock's outpoint instead of building a fresh one. + FundPlatformAddressView(wallet: wallet, resumeFromLock: lock) + } .onAppear { appUIState.showWalletsSyncDetails = false } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index 14479e40ef9..41f28fd01bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -31,6 +31,16 @@ struct FundPlatformAddressView: View { /// 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.resumeFundFromAssetLock` 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`. @@ -84,6 +94,19 @@ struct FundPlatformAddressView: View { // `RegistrationProgressView`. AddressFundingProgressSection(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 @@ -235,7 +258,7 @@ struct FundPlatformAddressView: View { submit() } label: { HStack { - Text("Fund Address") + Text(resumeFromLock == nil ? "Fund Address" : "Resume Funding") Spacer() } .foregroundColor(.white) @@ -245,6 +268,41 @@ struct FundPlatformAddressView: View { } } + /// 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 `AddressFundingProgressView`, @@ -378,7 +436,14 @@ struct FundPlatformAddressView: View { } private var canSubmit: Bool { - fundingCoreAccountIndex != nil + 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 + } + return fundingCoreAccountIndex != nil && platformAccountIndex != nil && selectedRecipientHash != nil && (parsedDuffs ?? 0) >= Self.minDuffs @@ -407,11 +472,9 @@ struct FundPlatformAddressView: View { private func submit() { guard - let fundingAccountIndex = fundingCoreAccountIndex, let platformAcct = platformAccountIndex, let hash = selectedRecipientHash, - let recipient = recipientCandidates.first(where: { $0.addressHash == hash }), - let duffs = parsedDuffs + let recipient = recipientCandidates.first(where: { $0.addressHash == hash }) else { return } let managedHolder = walletManager.wallet(for: wallet.walletId) @@ -431,28 +494,52 @@ struct FundPlatformAddressView: View { let recipientHash = recipient.addressHash let recipientType = recipient.addressType - // 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.addressFundingCoordinator - let controller = coordinator.startFunding( - walletId: walletId, - platformAccountIndex: platformAcct, - recipientHash: recipientHash, - body: { - // FFI body — runs on a background priority detached - // Task owned by the controller. Returns the proof- - // attested credit balance of the recipient address - // so the terminal section can surface a meaningful - // number. + // 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.resumeFundFromAssetLock( + outPointTxid: parsed.txid, + outPointVout: parsed.vout, + platformAccountIndex: platformAcct, + recipients: [ + ManagedPlatformAddressWallet.FundingRecipient( + 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.fundFromCoreAssetLock( amountDuffs: duffs, fundingAccountIndex: fundingAccountIndex, platformAccountIndex: platformAcct, recipients: [ - // Single recipient — gets the remainder after - // the on-chain fee. `credits = nil` is the - // canonical "receive remainder" marker. ManagedPlatformAddressWallet.FundingRecipient( addressType: recipientType, hash: recipientHash, @@ -464,17 +551,53 @@ struct FundPlatformAddressView: View { return updates .first(where: { $0.hash == recipientHash })?.balance ?? 0 } + } + + // 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.addressFundingCoordinator + let controller = coordinator.startFunding( + walletId: walletId, + platformAccountIndex: platformAcct, + recipientHash: recipientHash, + body: body ) // 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 (forthcoming) "Pending Platform Funding" - // surface on the wallet detail screen. + // reachable via the "Pending Platform Funding" section on + // the wallet detail screen. activeController = controller } + /// Parse `:` back into (32-byte raw + /// little-endian txid, vout). Inverse of + /// `PersistentAssetLock.encodeOutPoint(rawBytes:)`'s display + /// formatting. Returns `nil` on any malformed input. + private func parseOutPoint(_ hex: String) -> (txid: Data, vout: UInt32)? { + let parts = hex.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let txidDisplay = String(parts[0]) + guard let vout = UInt32(parts[1]) else { return nil } + // 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.. String { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift new file mode 100644 index 00000000000..6c731808c73 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift @@ -0,0 +1,266 @@ +// PendingPlatformFundingsList.swift +// SwiftExampleApp +// +// Wallet-scoped "Pending Platform Funding" surface that mirrors the +// identity-side `PendingRegistrationsList` + `ResumableRegistrationsList` +// pair. Two distinct row sources are merged here: +// +// 1. In-flight controllers from `AddressFundingCoordinator` — 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 +/// `AddressFundingCoordinator` directly (`@ObservedObject`) so its +/// `@Published controllers` map mutations trigger SwiftUI re-renders +/// of the in-flight rows. +struct PendingPlatformFundingsList: View { + @ObservedObject var coordinator: AddressFundingCoordinator + /// 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 `FundPlatformAddressView` 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 Funding (\(inFlight.count + orphans.count))") + .font(.headline) + Spacer() + } + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(Array(inFlight.enumerated()), id: \.element.platformFundingRowID) { idx, controller in + PendingPlatformFundingRow(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 + ResumablePlatformFundingRow( + 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: [AddressFundingController] { + 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 AddressFundingController { + /// 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 platformFundingRowID: 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 `AddressFundingController`. +/// Tappable navigation pushes to `AddressFundingProgressView`. +struct PendingPlatformFundingRow: View { + @ObservedObject var controller: AddressFundingController + @EnvironmentObject var walletManager: PlatformWalletManager + + var body: some View { + NavigationLink(destination: AddressFundingProgressView(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) + } + Text(recipientLabel) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .padding(.vertical, 2) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if case .failed = controller.phase { + Button { + walletManager.addressFundingCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + } label: { + Label("Dismiss", systemImage: "trash") + } + .tint(.red) + } + } + } + + 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 "Funding…" + 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 `FundPlatformAddressView` in resume mode pre-seeded with +/// the outpoint. +struct ResumablePlatformFundingRow: 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) + } +} From 06c19307080a28316a2b7fbf5c07b06dbf752da2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 22:37:44 +0700 Subject: [PATCH 12/35] feat(SwiftExampleApp): show recipient platform address on asset-lock rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two optional fields to `PersistentAssetLock` (`recipientPlatformAddressHash: Data?` and `recipientPlatformAddressType: UInt8?`) populated by Swift after a successful `fundFromCoreAssetLock` call. Rust doesn't know the recipient — it's chosen at ST-submit time, not at asset-lock build time — so the back-fill is Swift-side. Back-fill mechanism: on `.completed` phase, `FundPlatformAddressView` fetches the newest matching consumed lock (`walletId, fundingTypeRaw==4, statusRaw==4, recipientHash==nil`) and stamps the recipient hash + type byte. Defaults to nil so SwiftData lightweight migration is safe; pre-this-commit rows render as "Recipient — (pre-this-commit row)" in the storage explorer rather than collapsing the section. `AssetLockStorageDetailView` gains a "Recipient Platform Address" section for `fundingTypeRaw == 4` rows. When the hash is set: - Hex hash - Address type label (P2PKH / P2SH) - DIP-0018 bech32m encoding (e.g. `tdash1k…`) Bech32m encoding is implemented inline as private file-scope helpers (convertBits + bech32mEncode + bech32mPolymod + bech32mHRPExpand + bech32mCreateChecksum) so the explorer doesn't take a new dependency. Mirrors the Rust-side `PlatformAddress::to_bech32m_string` byte-for-byte. The HRP defaults to testnet (`tdash`) — the common case in this example app; mainnet wallets can wire through later if needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/PersistentAssetLock.swift | 27 +++ .../Views/FundPlatformAddressView.swift | 64 ++++++ .../Views/StorageRecordDetailViews.swift | 189 ++++++++++++++++++ 3 files changed, 280 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index fcc0bf2c241..d3fb929cc15 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -96,6 +96,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 `fundFromCoreAssetLock` 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/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index 41f28fd01bb..74555c03ec5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -134,6 +134,19 @@ struct FundPlatformAddressView: View { ) } .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() + } + } } } @@ -600,6 +613,57 @@ struct FundPlatformAddressView: View { // MARK: - Helpers + /// Stamp the recipient hash onto the most recent `Consumed` + /// asset-lock row on this wallet whose recipient isn't set yet. + /// Called from `.onChange` after the controller flips to + /// `.completed`. + /// + /// The match is `(walletId, fundingTypeRaw==4, statusRaw==4, + /// recipientPlatformAddressHash==nil)` newest-first. In the + /// happy path exactly one row matches: the lock the FFI just + /// consumed. If multiple match (two back-to-back fundings on + /// the same wallet where the first stamp hasn't run yet), we + /// stamp the newest — the older one will get picked up on its + /// own `.completed` since the back-fill is FIFO by completion + /// time anyway. + private func backfillRecipientOnConsumedLock() { + guard let controller = activeController else { return } + let walletId = wallet.walletId + // Capture the recipient before the closure body — Swift + // strict-concurrency wants the value local, not the + // controller reference. + let recipientHash = controller.recipientHash + // Type byte is fixed at P2PKH today (the only platform- + // address shape the wallet generates) but capture it + // alongside so the storage explorer can render correctly + // if P2SH is added later. + let recipientType: UInt8 = 0 // P2PKH discriminant. + var descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.walletId == walletId + && entry.fundingTypeRaw == 4 + && entry.statusRaw == 4 + && entry.recipientPlatformAddressHash == nil + }, + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + descriptor.fetchLimit = 1 + do { + let matches = try modelContext.fetch(descriptor) + if let lock = matches.first { + lock.recipientPlatformAddressHash = recipientHash + lock.recipientPlatformAddressType = recipientType + try? modelContext.save() + } + } catch { + // Surface as a log rather than alerting — the funding + // already succeeded, the only downside of a missed + // back-fill is a "Recipient — unknown" line in the + // storage explorer for this row. + print("backfillRecipient: fetch failed: \(error)") + } + } + private func formatDuffs(_ duffs: UInt64) -> String { let dash = Double(duffs) / Double(Self.duffsPerDash) return String(format: "%.8f DASH", dash) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 72c9e448bd9..39e1a0d8ee8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1844,6 +1844,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 `fundFromCoreAssetLock`. `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 +1953,161 @@ 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. Falls back to testnet — the common case in + /// this example app. + private func networkHRP() -> String { + // Wallet row lookup is best-effort; we keep the explorer + // self-contained here rather than threading the wallet + // through every storage detail view's init. + // Default: testnet HRP. + "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 From 0753138fffcaffaf02ada11275ff1d9bba5d8b59 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 23:11:04 +0700 Subject: [PATCH 13/35] fix(SwiftExampleApp): show ChainLock as its own step in funding progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous shape collapsed "Waiting for InstantSend proof" and "Waiting for ChainLock proof" into a single dynamic step 3 that renamed itself once the IS deadline passed. Two practical problems: - The CL fallback was visually invisible. A user watching the progress screen had no way to tell whether the lock resolved via IS or CL — both lanes produced the same "step 3 done ✓". - The total step count was 4, asymmetric with the identity-side progress view (5 steps, same shape, IS and CL on their own rows). The asymmetry was confusing for users who had already learned the identity flow. Restored the 5-step shape: 1. Building asset-lock transaction 2. Broadcasting 3. Waiting for InstantSend proof (skipped if CL resolved first) 4. Waiting for ChainLock proof (skipped if IS resolved first) 5. Funding platform address `.skipped` rendering (faded checkmark, secondary text) distinguishes "we passed this step but didn't need it" from "didn't happen yet" and from "still active". Exactly one of step 3 / 4 is skipped on every successful resolution; the discriminator is the lock's terminal `statusRaw`: - statusRaw == 2 (InstantSendLocked) → step 4 skipped - statusRaw == 3 (ChainLocked) → step 3 skipped (either via IS-timeout fallback or the direct `last_applied_chain_lock` path) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/AddressFundingProgressView.swift | 152 ++++++++++++------ 1 file changed, 101 insertions(+), 51 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift index 7750b19fcc5..9e11f50ab16 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift @@ -2,7 +2,7 @@ import SwiftUI import SwiftData import SwiftDashSDK -/// Embeddable 4-step progress section for an address-funding flow. +/// Embeddable 5-step progress section for an address-funding flow. /// Mirrors [`RegistrationProgressSection`] but for /// `AddressFundingFromAssetLockTransition`. /// @@ -15,15 +15,22 @@ import SwiftDashSDK /// 3. Waiting for InstantSend proof → activeLock `statusRaw == 1` and /// between `broadcastingWindow` /// and `instantLockTimeout` -/// 3a. Waiting for ChainLock proof → activeLock `statusRaw == 1` and -/// >= `instantLockTimeout`; or +/// 4. Waiting for ChainLock proof → activeLock `statusRaw == 1` and +/// >= `instantLockTimeout` (Rust +/// side has fallen back to CL); +/// also done when /// `statusRaw == 3` (CL-locked). -/// Rendered as "step 3" in the UI -/// with a CL-specific footer so -/// step count stays at 4. -/// 4. Funding platform address → activeLock `statusRaw ∈ {2, 3}` +/// 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 `AddressFundingProgressView` renders the "Address /// funded" banner + the new balance below this section. `.failed` @@ -67,23 +74,22 @@ struct AddressFundingProgressSection: View { var body: some View { // Same TimelineView pattern as RegistrationProgressSection - // so the elapsed-time heuristic distinguishing step 2 / 3 + // 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 - let chainLockFallbackEngaged = isInChainLockFallback(now: now) Section { - ForEach(1...4, id: \.self) { idx in + ForEach(1...5, id: \.self) { idx in stepRow( index: idx, - title: stepTitle(idx, chainLockFallback: chainLockFallbackEngaged), + title: stepTitle(idx), state: stepState(idx, currentStep: step, isFailed: isFailed) ) - if idx == 4, let message = errorMessage { + if idx == 5, let message = errorMessage { Text(message) .font(.caption) .foregroundColor(.red) @@ -93,7 +99,7 @@ struct AddressFundingProgressSection: View { } header: { Text("Funding Progress") } footer: { - Text(footerText(step: step, isFailed: isFailed, chainLockFallback: chainLockFallbackEngaged)) + Text(footerText(step: step, isFailed: isFailed)) .font(.caption2) .foregroundColor(.secondary) } @@ -102,7 +108,7 @@ struct AddressFundingProgressSection: View { // MARK: - Step computation - /// 1...4, current active step. On `.completed` we report 5 (one + /// 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. @@ -111,17 +117,20 @@ struct AddressFundingProgressSection: View { case .idle: return 1 case .completed: - return 5 + // No visible "funded" step — terminalSection on + // `AddressFundingProgressView` 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 4 + case 2, 3: return 5 default: return 1 } } - return 4 + return 5 case .inFlight: guard let lock = activeLocks.first else { return 1 } switch lock.statusRaw { @@ -129,38 +138,59 @@ struct AddressFundingProgressSection: View { return 1 case 1: return broadcastSubStep(for: lock, now: now) - case 2, 3: - // IS-locked or CL-locked → submitting the ST. - return 4 + 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 is "active" while the lock is at - /// `statusRaw == 1`. Brief broadcasting window first, then IS - /// wait, then CL fallback (which still renders as step 3 with a - /// CL-specific footer so the step count stays at 4). + /// 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 } - return 3 + if elapsed < Self.instantLockTimeout { return 3 } + return 4 } - /// Returns true once the IS wait has rolled over to the CL - /// fallback window. Drives the step-3 title swap and the - /// footer text so the user knows the IS branch was abandoned. - private func isInChainLockFallback(now: Date) -> Bool { + /// 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 } - switch lock.statusRaw { - case 1: - return now.timeIntervalSince(lock.updatedAt) >= Self.instantLockTimeout - case 3: - return true - default: - 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 { @@ -173,26 +203,41 @@ struct AddressFundingProgressSection: View { return nil } - private func stepTitle(_ idx: Int, chainLockFallback: Bool) -> String { + private func stepTitle(_ idx: Int) -> String { switch idx { case 1: return "Building asset-lock transaction" case 2: return "Broadcasting" - case 3: - return chainLockFallback - ? "Waiting for ChainLock proof" - : "Waiting for InstantSend proof" - case 4: return "Funding platform address" + case 3: return "Waiting for InstantSend proof" + case 4: return "Waiting for ChainLock proof" + case 5: return "Funding platform address" default: return "" } } - enum StepState { case done, active, pending, failed } + /// 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 { @@ -236,6 +281,13 @@ struct AddressFundingProgressSection: View { .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) @@ -248,23 +300,21 @@ struct AddressFundingProgressSection: View { 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, chainLockFallback: Bool) -> String { + 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 chainLockFallback - ? "InstantSend timed out; falling back to ChainLock finality (~2 min)." - : "Waiting for the InstantSend lock so the asset-lock proof is final." - case 4: return "Submitting the AddressFundingFromAssetLock state transition to Platform." - case 5: return "Address funded." + 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 "" } } From 0a0ba19efc5c193755b351a76395db104a1dd922 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 23:44:19 +0700 Subject: [PATCH 14/35] review: SAFETY comments, mainnet HRP, dismissal paths (reviewer findings A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three HIGH findings from the parallel code review, batched as quick safety + UX wins: H4 — Bech32m HRP hardcoded to "tdash". The recipient-address display on `AssetLockStorageDetailView` rendered every address with the testnet HRP, producing a string that's bech32m-valid but decodes to the wrong `Network` on mainnet. Adds a `@Query` for the asset lock's owning `PersistentWallet` and reads `wallet.network` to pick `dash` vs `tdash` per DIP-0018. The earlier fallback string is retained as the orphan-wallet-row case (legacy rows without the relationship populated) — that case is already non-functional so the fallback HRP is inconsequential. H5 — Missing `// SAFETY:` comments on the resume FFI's `unsafe` blocks (`platform_address_wallet_resume_fund_with_existing_asset_lock_signer`). Sibling at line 90 already had the comment; resume sibling didn't. Trivial 1-line parity fix. H3 — `.swipeActions` on `PendingPlatformFundingRow` was dead. The modifier only fires when the row is inside a List/Form, but the row renders inside the VStack "Pending Platform Funding" card on the wallet detail screen. A `.failed` controller had no in-app dismissal path — relaunch was the only way to clear it. Replaced with an inline trash-icon `Button` next to the row, plus an explicit chevron-right (List would normally auto-render this). Standalone `AddressFundingProgressView`'s `.failed` terminal section was missing its Dismiss button entirely — added one that mirrors the inline `FundPlatformAddressView` variant so both surfaces have a working dismissal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../platform_addresses/fund_with_funding.rs | 2 + .../Views/AddressFundingProgressView.swift | 21 +++++++ .../Views/PendingPlatformFundingsList.swift | 55 ++++++++++++------- .../Views/StorageRecordDetailViews.swift | 34 +++++++++--- 4 files changed, 86 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs index 14fc6eb1678..c0a7d3514ed 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs @@ -178,6 +178,8 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_with_existing_asset 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( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift index 9e11f50ab16..bf5ee1f8b0a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift @@ -382,6 +382,27 @@ struct AddressFundingProgressView: View { .font(.callout) .foregroundColor(.primary) .textSelection(.enabled) + // Dismissal path mirroring the inline terminal + // section in `FundPlatformAddressView`. 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.addressFundingCoordinator.dismiss( + walletId: controller.walletId, + platformAccountIndex: controller.platformAccountIndex, + recipientHash: controller.recipientHash + ) + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.top, 4) } } default: diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift index 6c731808c73..e287664246f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift @@ -132,26 +132,41 @@ struct PendingPlatformFundingRow: View { @EnvironmentObject var walletManager: PlatformWalletManager var body: some View { - NavigationLink(destination: AddressFundingProgressView(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) + HStack(spacing: 8) { + NavigationLink(destination: AddressFundingProgressView(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) } - Text(recipientLabel) - .font(.caption2) - .foregroundColor(.secondary) - .lineLimit(1) + .padding(.vertical, 2) } - .padding(.vertical, 2) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { + .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.addressFundingCoordinator.dismiss( @@ -160,9 +175,11 @@ struct PendingPlatformFundingRow: View { recipientHash: controller.recipientHash ) } label: { - Label("Dismiss", systemImage: "trash") + Image(systemName: "trash") + .foregroundColor(.red) } - .tint(.red) + .buttonStyle(.borderless) + .accessibilityLabel("Dismiss failed funding") } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 39e1a0d8ee8..5f09052b3b2 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 @@ -2010,14 +2023,21 @@ struct AssetLockStorageDetailView: View { } /// Determine the network HRP for the wallet that owns this - /// asset lock. Falls back to testnet — the common case in - /// this example app. + /// 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 { - // Wallet row lookup is best-effort; we keep the explorer - // self-contained here rather than threading the wallet - // through every storage detail view's init. - // Default: testnet HRP. - "tdash" + guard let wallet = owningWallets.first, let network = wallet.network else { + return "tdash" + } + switch network { + case .mainnet: return "dash" + default: return "tdash" + } } } From 3fd355bed789bf59ae9a6c8100f462b35101fc76 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 19 May 2026 23:49:59 +0700 Subject: [PATCH 15/35] review: snapshot-delta back-fill + recipient type plumbing (reviewer findings B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H2 — Back-fill was hardcoding the recipient address type to P2PKH (`recipientType: UInt8 = 0`) instead of carrying the user's actual `recipient.addressType`. Today every platform address the wallet emits is P2PKH so the latent bug isn't visible, but the value persists on `PersistentAssetLock.recipientPlatformAddressType` and feeds the bech32m encoder's type byte (`0xb0` vs `0x80`). Any future P2SH funding would have been silently mis-tagged forever. Plumbed `recipientType` through `AddressFundingController` and `AddressFundingCoordinator.startFunding(...)` so the back-fill stamps the real value. H10 — Back-fill matched on `(walletId, fundingType==4, status==4, recipientHash==nil)` newest-first. Two concurrent fundings on the same wallet that Consume in close succession could trip the `updatedAt` ordering and stamp the wrong row — the in-code comment admitted the FIFO claim was hand-wavy. Replaced with a deterministic snapshot-delta: - `submit()` captures `preSubmitConsumedOutpoints` (every Consumed address-funding outpoint on the wallet RIGHT NOW). - `.onChange(.completed)` recomputes the set and filters the unrecipiented Consumed rows down to those NOT in the snapshot — those are the genuinely new rows. - Exactly one new outpoint → stamp it. - Zero new outpoints → persister lag; skip, next funding's delta will pick this up. - Multiple new outpoints → ambiguous race; refuse to stamp either, better unknown than wrong. - Snapshot missing → fall back to the legacy newest-heuristic (kept for any future caller that wires the coordinator directly without going through the view). Also addressed the swift-ios reviewer's MEDIUM finding about duplicate retention-sweep `Task`s: the `.idle`/`.failed` retry branch in `startFunding` was calling `scheduleRetentionSweep` again, spawning a second 30s poll loop against the same controller. Sweep is now scheduled once per controller (on creation only). Also replaced the previous `print(error)` swallow + `try?` save swallow with explicit `⚠️`-prefixed prints matching the surrounding app's logging idiom. Still not OSLog-grade but at least grep-discoverable in the console output (was invisible on the silent-failure-hunter pass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/AddressFundingController.swift | 31 +++- .../Services/AddressFundingCoordinator.swift | 11 +- .../Views/FundPlatformAddressView.swift | 139 ++++++++++++++---- 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift index b581c456020..db28c698703 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift @@ -81,6 +81,16 @@ final class AddressFundingController: ObservableObject { /// 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). @@ -91,10 +101,29 @@ final class AddressFundingController: ObservableObject { /// wired today (the FFI call doesn't yet support clean abort). private var task: Task? - init(walletId: Data, platformAccountIndex: UInt32, recipientHash: Data) { + /// Outpoints of `Consumed` address-funding locks observed on + /// this wallet **before** `submit()` fired. Captured by the + /// caller (`FundPlatformAddressView.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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift index f16633311e1..42ee9fa448d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift @@ -83,6 +83,7 @@ final class AddressFundingCoordinator: ObservableObject { walletId: Data, platformAccountIndex: UInt32, recipientHash: Data, + recipientType: UInt8, body: @escaping () async throws -> UInt64 ) -> AddressFundingController { let key = SlotKey( @@ -100,14 +101,20 @@ final class AddressFundingCoordinator: ObservableObject { case .idle, .failed: // Legitimate restart paths. existing.submit(body: body) - scheduleRetentionSweep(key: key, controller: existing) + // 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 = AddressFundingController( walletId: walletId, platformAccountIndex: platformAccountIndex, - recipientHash: recipientHash + recipientHash: recipientHash, + recipientType: recipientType ) controllers[key] = controller controller.submit(body: body) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index 74555c03ec5..5fd40f015ab 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -566,6 +566,15 @@ struct FundPlatformAddressView: View { } } + // 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. @@ -574,8 +583,10 @@ struct FundPlatformAddressView: View { 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 @@ -586,6 +597,23 @@ struct FundPlatformAddressView: View { 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 @@ -613,32 +641,37 @@ struct FundPlatformAddressView: View { // MARK: - Helpers - /// Stamp the recipient hash onto the most recent `Consumed` - /// asset-lock row on this wallet whose recipient isn't set yet. - /// Called from `.onChange` after the controller flips to - /// `.completed`. + /// Stamp the recipient hash + type onto the asset-lock row this + /// funding's FFI call just Consumed. Called from `.onChange` + /// after the controller flips to `.completed`. + /// + /// Match strategy: set-difference against the pre-submit + /// Consumed-outpoint snapshot captured at `submit()` time. The + /// new Consumed outpoint that wasn't in the snapshot is ours. /// - /// The match is `(walletId, fundingTypeRaw==4, statusRaw==4, - /// recipientPlatformAddressHash==nil)` newest-first. In the - /// happy path exactly one row matches: the lock the FFI just - /// consumed. If multiple match (two back-to-back fundings on - /// the same wallet where the first stamp hasn't run yet), we - /// stamp the newest — the older one will get picked up on its - /// own `.completed` since the back-fill is FIFO by completion - /// time anyway. + /// Edge cases: + /// - `preSubmitConsumedOutpoints` missing on controller: fall + /// back to the legacy `newest unrecipiented` heuristic. + /// - Zero new outpoints: nothing to stamp; the funding + /// succeeded but no Consumed row appeared yet (persister + /// callback lag). The catch-up on the next funding will fix + /// it via the same delta logic. + /// - Multiple new outpoints: two address-funding flows + /// completed Consumed in the same `.onChange` window. + /// Refuse to stamp either to avoid mis-attribution — better + /// to leave both showing "—" in the storage explorer than to + /// silently tag the wrong row. Both controllers will retry on + /// their next phase tick if they share the same view; in + /// practice one will land first and the other re-runs against + /// the now-smaller delta. private func backfillRecipientOnConsumedLock() { guard let controller = activeController else { return } let walletId = wallet.walletId - // Capture the recipient before the closure body — Swift - // strict-concurrency wants the value local, not the - // controller reference. let recipientHash = controller.recipientHash - // Type byte is fixed at P2PKH today (the only platform- - // address shape the wallet generates) but capture it - // alongside so the storage explorer can render correctly - // if P2SH is added later. - let recipientType: UInt8 = 0 // P2PKH discriminant. - var descriptor = FetchDescriptor( + let recipientType = controller.recipientType + let preSubmitSet = controller.preSubmitConsumedOutpoints + + let descriptor = FetchDescriptor( predicate: #Predicate { entry in entry.walletId == walletId && entry.fundingTypeRaw == 4 @@ -647,20 +680,62 @@ struct FundPlatformAddressView: View { }, sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] ) - descriptor.fetchLimit = 1 + let matches: [PersistentAssetLock] do { - let matches = try modelContext.fetch(descriptor) - if let lock = matches.first { - lock.recipientPlatformAddressHash = recipientHash - lock.recipientPlatformAddressType = recipientType - try? modelContext.save() + 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 { + // Legacy fallback — used when the snapshot wasn't + // captured (e.g. a future caller that wires up the + // coordinator directly). Newest-unrecipiented match. + // Documented race window: 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 { - // Surface as a log rather than alerting — the funding - // already succeeded, the only downside of a missed - // back-fill is a "Recipient — unknown" line in the - // storage explorer for this row. - print("backfillRecipient: fetch failed: \(error)") + // 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)") } } From 02795577186ed65e6badb058707913cc9dffcc31 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 00:01:45 +0700 Subject: [PATCH 16/35] review: error loudly on Platform proof contract violations (reviewer findings C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 (silent-failure hunter) — `write_address_balances_changeset` had two fallback paths that silently masked a Platform proof contract violation as a successful zero-credit funding: 1. A recipient with `maybe_info == None` in `AddressInfos` was mapped to `AddressFunds { balance: 0, nonce: 0 }` and pushed onto the changeset. The asset lock would be Consumed against a recipient whose ledger row shows "credited 0". 2. A recipient whose hash wasn't found in the account's address pool fell back to `address_index = 0` via `.unwrap_or(0)` — silently mis-attributing the credits to whatever real address happened to live at slot 0. Replaced both with `tracing::error!` + skip. The asset lock has already Consumed at this point, so propagating an `Err` would mis-report the protocol outcome — but writing the bad row is worse than writing nothing. Operators see the drift in logs; storage explorer shows "—" for the recipient column instead of a silently wrong value. H9 (reliability) — Added a post-condition assertion at the top of Step 4 that errors out when `address_infos.is_empty()` for a non-empty recipient set. An empty Ok response would have written no changeset rows AND consumed the asset lock, producing a terminal state where the user paid for an asset lock and got nothing in return. This refuses the consume entirely so the lock stays available for a retry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../platform_addresses/fund_with_funding.rs | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs index 2d54223a16c..54fd82918c7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs @@ -202,6 +202,25 @@ impl PlatformAddressWallet { // 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; @@ -257,18 +276,38 @@ impl PlatformAddressWallet { 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, - }, + // Platform's proof must carry an `AddressInfo` + // for every recipient we asked to fund. A `None` + // here is a protocol-contract violation — + // earlier shape silently mapped it to + // `balance: 0, nonce: 0` and pushed a "credited + // 0" row onto the changeset, which would have + // been indistinguishable from a successful + // zero-credit funding. Skip with a loud + // `tracing::error!` so operators see the drift; + // the asset lock has already Consumed, the + // changeset just won't reflect the recipient. + 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()); - let address_index = account + // Resolve the address's HD slot in the + // account's address pool. The + // `.unwrap_or(0)` earlier here silently + // mis-attributed credits to slot 0 if the + // recipient wasn't in the pool — log and skip + // instead, again to avoid writing a wrong-slot + // changeset entry that the persister would + // store as if it referred to address #0. + let Some(address_index) = account .addresses .addresses .iter() @@ -278,7 +317,13 @@ impl PlatformAddressWallet { .filter(|found| *found == p2pkh) .map(|_| idx) }) - .unwrap_or(0); + 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, From 431c80984b38f63035561c310357276ddf07ae4e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 00:08:02 +0700 Subject: [PATCH 17/35] review: document latency budget, cancellation, retry scope (reviewer findings D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H7 (reliability) — Added a Latency budget + Cancellation section to `fund_addresses_with_funding`'s doc-comment. Worst-case wall time stacks at ~690s on the IS-rejection branch; happy path is single- digit seconds. The function is explicitly documented as non-cancellation-safe; the Swift `AddressFundingController.task` deliberately doesn't call `.cancel()` (FFI dropping mid-call would leave partial state). UI dismissal hides the progress view without aborting the work; resume picks the lock back up via `FromExistingAssetLock`. H6 (reliability) — `consume_asset_lock` failure was logged as `warn` regardless of the underlying error. The expected failure mode is `WalletNotFound` (the wallet handle vanished between submit and cleanup) — that's benign because Platform's deterministic "lock already consumed" rejection handles the resume-attempt user-visible recovery path. Anything else is an unexpected invariant violation and should land at `error` level. Pattern-matched the error and split the log severity accordingly. H8 (reliability) — Documented `submit_with_cl_height_retry`'s retry scope. Only consensus 10506 is retried at THIS layer; every other `dash_sdk::Error` (transport, mempool, DAPI 5xx) falls through immediately because the `rs-dapi-client` layer underneath already implements per-request retry + endpoint rotation for transport failures. Adding a second generic-retry layer here would over-retry and risk double-submission. Rationale + when-to-widen guidance recorded in the doc comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/asset_lock/orchestration.rs | 19 ++++ .../platform_addresses/fund_with_funding.rs | 86 +++++++++++++++++-- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 98e3a9a0f0f..8a97dade53f 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -205,6 +205,25 @@ pub(crate) enum FundingResolution { /// 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 diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs index 54fd82918c7..068517a4985 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs @@ -84,6 +84,49 @@ impl PlatformAddressWallet { /// 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 `AddressFundingController.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 fund_addresses_with_funding( &self, @@ -226,16 +269,41 @@ impl PlatformAddressWallet { .await; if let Some(out_point) = tracked_out_point { - // Cleanup failure can only mean WalletNotFound (the wallet - // handle that just funded the addresses vanished). Surface - // as a warn — Platform DID accept the top-up, so - // propagating the error to the caller would be misleading. + // 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 { - tracing::warn!( - outpoint = %out_point, - error = %e, - "consume_asset_lock failed after successful Platform submit" - ); + 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'." + ); + } + } } } From 43efe2e4f041caacf0e579179f982078f8e5e65c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 00:40:40 +0700 Subject: [PATCH 18/35] =?UTF-8?q?review:=20MEDIUM=20fixes=20=E2=80=94=20st?= =?UTF-8?q?ale=20recipient,=20tracked=20status,=20dep=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five MEDIUM findings batched into a single commit: F6 (correctness) — `submit()` silently no-op'd when the selected recipient address was no longer in `recipientCandidates` (the row flipped to `isUsed = true` between user-tap and submit body). Now surfaces a typed `submitError` so the user sees "the address is no longer available; pick a fresh one" instead of an unresponsive button. F4 (correctness) — IS-rejection branch left the tracked asset-lock status at `InstantSendLocked` even after building a fresh CL proof. If the second `submit_with_cl_height_retry` then failed, the persisted status was stale (CL proof was attached internally but discarded; the row carried the rejected IS proof). The storage explorer + Resume UI both misreported the recovery state. Now calls `advance_asset_lock_status(ChainLocked, Some(chain_proof))` between the upgrade and the second submit so the row accurately reflects the lock's CL-attached state regardless of which way the submit goes. swift-ios #7 — "No funded Core (BIP44 standard) accounts on this wallet" empty-state copy fired even when zero-balance accounts existed. The picker shows all BIP44 standard accounts (incl. zero balance) so the "funded" qualifier was a lie. Dropped it; the picker's balance column tells the user what's spendable. rust-quality #3 — `drive-proof-verifier` Cargo dep was missing `default-features = false`. Consistency with every other path-dep in this crate; harmless today (default feature set is empty) but insulates from upstream feature additions. blockchain-security M2 — `decode_funding_addresses` zero-count short-circuit. The FFI's `slice::from_raw_parts(ptr, 0)` contract is technically sound even with a dangling non-null sentinel, but the explicit early-return makes the safety doc trivially satisfied + skips a function call. Side change: `AssetLockManager::queue_asset_lock_changeset` visibility widened from `pub(super)` to `pub(crate)` so the orchestrated funding flow can pair an `advance_asset_lock_status` call with a flush without going through the asset-lock module boundary. Still crate-private; no consumer-facing API change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../platform_addresses/fund_with_funding.rs | 14 ++++++++++++-- packages/rs-platform-wallet/Cargo.toml | 2 +- .../src/wallet/asset_lock/manager.rs | 9 ++++++++- .../platform_addresses/fund_with_funding.rs | 18 ++++++++++++++++++ .../Views/FundPlatformAddressView.swift | 18 +++++++++++++++--- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs index c0a7d3514ed..15faaf1b6d5 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs @@ -218,12 +218,22 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_with_existing_asset /// entry points agree on the wire shape. /// /// # Safety -/// - `addresses` must be a valid, non-null pointer to an array of at -/// least `addresses_count` `FundingAddressEntryFFI` entries. +/// - `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| { diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index be070e15156..56556fea9b1 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -10,7 +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" } +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/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/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs index 068517a4985..d7b5edfb10d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs @@ -224,6 +224,24 @@ impl PlatformAddressWallet { .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, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index 5fd40f015ab..517cb50559b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -172,7 +172,7 @@ struct FundPlatformAddressView: View { let options = coreAccountOptions Section { if options.isEmpty { - Text("No funded Core (BIP44 standard) accounts on this wallet.") + Text("No Core (BIP44 standard) accounts on this wallet.") .font(.caption) .foregroundColor(.secondary) } else { @@ -486,9 +486,21 @@ struct FundPlatformAddressView: View { private func submit() { guard let platformAcct = platformAccountIndex, - let hash = selectedRecipientHash, - let recipient = recipientCandidates.first(where: { $0.addressHash == hash }) + 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) or its balance may have moved. + // Surface that as a fail-fast error instead of silently + // dropping the tap — earlier shape returned nil and the + // button looked unresponsive. + 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 { From e27dcbacaaf7d80cb6ef8a820f1ee8405a0a113e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 01:22:16 +0700 Subject: [PATCH 19/35] chore(dpp): drop noisy entry/exit debug logs from AddressFunding signers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three `tracing::debug!` lines per method (entry, input/output counts, exit) on `try_from_asset_lock_with_signer_and_private_key` + `try_from_asset_lock_with_signers`. Boilerplate carried over from the legacy method that predates this PR — non-load-bearing, identical pattern to function entry/exit which tracing spans cover when anyone instruments the call site. The counts in particular are derivable from any wrapping span's fields. Drop both pairs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v0/v0_methods.rs | 18 ------------------ 1 file changed, 18 deletions(-) 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 b91153b8935..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 @@ -32,13 +32,6 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL user_fee_increase: UserFeeIncrease, _platform_version: &PlatformVersion, ) -> Result { - tracing::debug!("try_from_asset_lock_with_signer_and_private_key: Started"); - tracing::debug!( - input_count = inputs.len(), - output_count = outputs.len(), - "try_from_asset_lock_with_signer_and_private_key" - ); - // Create the unsigned transition let mut address_funding_transition = AddressFundingFromAssetLockTransitionV0 { asset_lock_proof, @@ -65,9 +58,6 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL } address_funding_transition.input_witnesses = input_witnesses; - tracing::debug!( - "try_from_asset_lock_with_signer_and_private_key: Successfully created transition" - ); Ok(address_funding_transition.into()) } @@ -87,13 +77,6 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL S: Signer, AS: ::key_wallet::signer::Signer, { - tracing::debug!("try_from_asset_lock_with_signers: Started"); - tracing::debug!( - input_count = inputs.len(), - output_count = outputs.len(), - "try_from_asset_lock_with_signers" - ); - // Build the unsigned inner transition. The outer wrapper // signature and the per-input witnesses are both // `#[platform_signable(exclude_from_sig_hash)]`, so they @@ -132,7 +115,6 @@ impl AddressFundingFromAssetLockTransitionMethodsV0 for AddressFundingFromAssetL .sign_with_core_signer(asset_lock_proof_path, asset_lock_signer) .await?; - tracing::debug!("try_from_asset_lock_with_signers: Successfully created transition"); Ok(state_transition) } } From d756e2ecd299d4fc0fbf9950f0f619b5ef65b400 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 02:29:20 +0700 Subject: [PATCH 20/35] chore(platform-wallet): drop IdentityFunding alias, rename callers to AssetLockFunding The `identity/types/funding.rs` file became a one-line `pub use ... as IdentityFunding` alias after the refactor that moved the type's body into `wallet/asset_lock/orchestration.rs`. The alias kept existing callers compiling without rename, but every in-tree caller is now updated, so the alias is dead weight. - Deleted `packages/rs-platform-wallet/src/wallet/identity/types/funding.rs`. - Dropped `pub mod funding;` + `pub use funding::IdentityFunding;` from `types/mod.rs`. - Migrated three in-tree call sites (`registration.rs`, `top_up.rs`, and `identity_registration_funded_with_signer.rs` in the FFI) to import `AssetLockFunding` directly from `platform_wallet::wallet::asset_lock`. - Updated public re-exports in `lib.rs` and `wallet/identity/mod.rs`. - Surfaced `AssetLockFunding` directly from the crate root (`platform_wallet::AssetLockFunding`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../identity_registration_funded_with_signer.rs | 8 ++++---- packages/rs-platform-wallet/src/lib.rs | 7 ++++--- .../src/wallet/asset_lock/orchestration.rs | 4 +--- .../src/wallet/identity/mod.rs | 4 ++-- .../wallet/identity/network/identity_handle.rs | 4 ---- .../src/wallet/identity/network/registration.rs | 16 ++++++---------- .../src/wallet/identity/network/top_up.rs | 6 +++--- .../src/wallet/identity/types/funding.rs | 10 ---------- .../src/wallet/identity/types/mod.rs | 2 -- 9 files changed, 20 insertions(+), 41 deletions(-) delete mode 100644 packages/rs-platform-wallet/src/wallet/identity/types/funding.rs 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/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/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 8a97dade53f..161c62f3f4a 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -104,9 +104,7 @@ pub(crate) const CL_HEIGHT_RETRY_BUDGET: Duration = Duration::from_secs(210); /// the asset-lock module when the platform-address funding flow /// needed the same shape — funding source is funding-target-agnostic; /// the `funding_type` argument to the resolver picks the BIP44 -/// derivation family. A `pub use ... as IdentityFunding` alias is -/// retained at the old path so existing external callers keep -/// compiling. +/// derivation family. #[derive(Debug, Clone)] pub enum AssetLockFunding { /// Build an asset lock from wallet UTXOs for the given amount. 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 b4b467364a9..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 @@ -465,8 +465,4 @@ impl IdentityWallet { )) }) } - - // `out_point_from_proof` moved to `wallet::asset_lock::orchestration` - // as a free `pub(crate) fn` — used by every asset-lock-funded flow - // (identity register/top-up, platform-address funding). } 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 92d0bb95881..a03bf8bf2c6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -70,14 +70,10 @@ 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, the CL-height retry helper, and the IS-timeout-aware -// funding resolver moved to `wallet::asset_lock::orchestration` so the -// identity register/top-up flows and the platform-address funding -// flow share one source of truth for these timeouts and retry shapes. // --------------------------------------------------------------------------- // register @@ -92,7 +88,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` @@ -124,7 +120,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, @@ -312,7 +308,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. @@ -347,7 +343,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 @@ -357,7 +353,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 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 348bd260920..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Funding source for identity registration and top-up. -//! -//! Re-exports [`AssetLockFunding`](crate::wallet::asset_lock::AssetLockFunding) -//! under the original `IdentityFunding` name so existing callers and -//! the FFI surface keep compiling. The type's body moved to the -//! asset-lock module when platform-address funding adopted the same -//! shape — funding source is funding-target-agnostic; the resolver's -//! `funding_type` parameter picks the BIP44 derivation family. - -pub use crate::wallet::asset_lock::AssetLockFunding as IdentityFunding; 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}; From 9cd055b969bc46aa21ec802eb815b65bf2257a2a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 22:15:49 +0700 Subject: [PATCH 21/35] chore: drop history-reference comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handful of comments in this PR's diff referenced what code USED to look like before this PR landed. They were noise — the current shape is what matters; the historical shape lives in git log. - `wallet/asset_lock/orchestration.rs` — dropped the "Historical note" section on `AssetLockFunding` that explained the rename from `IdentityFunding`. The alias was removed in the previous commit too, so the only readers are someone looking at this file fresh; the old name isn't load-bearing for them. - `wallet/platform_addresses/fund_with_funding.rs` — collapsed the two "earlier shape silently mapped..." paragraphs that justified the now-default skip behaviour. Reframed around the WHY (Platform contract violation, recipient must exist in pool) instead of the history (what the previous shape did). - `FundPlatformAddressView.swift` — trimmed the "earlier shape returned nil and the button looked unresponsive" justification on `submit()`'s recipient-staleness guard. Comment now reads as a forward statement about the race window. - `FundPlatformAddressView.swift` — softened the `backfillRecipientOnConsumedLock` "Legacy fallback" branch comment + doc to "snapshot not captured" without the legacy framing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/asset_lock/orchestration.rs | 9 ------- .../wallet/identity/network/registration.rs | 4 --- .../platform_addresses/fund_with_funding.rs | 27 +++++++------------ .../Views/FundPlatformAddressView.swift | 22 +++++++-------- 4 files changed, 19 insertions(+), 43 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 161c62f3f4a..932efcc7113 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -96,15 +96,6 @@ pub(crate) const CL_HEIGHT_RETRY_BUDGET: Duration = Duration::from_secs(210); /// `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`. -/// -/// ## Historical note -/// -/// This type used to be named `IdentityFunding` and lived under -/// `wallet/identity/types/funding.rs`. It was renamed and moved to -/// the asset-lock module when the platform-address funding flow -/// needed the same shape — funding source is funding-target-agnostic; -/// the `funding_type` argument to the resolver picks the BIP44 -/// derivation family. #[derive(Debug, Clone)] pub enum AssetLockFunding { /// Build an asset lock from wallet UTXOs for the given amount. 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 a03bf8bf2c6..0e9f76b8c38 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -74,7 +74,6 @@ use crate::wallet::asset_lock::AssetLockFunding; use super::*; - // --------------------------------------------------------------------------- // register // --------------------------------------------------------------------------- @@ -560,7 +559,4 @@ mod tests { (wallet-state mismatch is a hard failure)" ); } - - // The `submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget` - // test moved with its subject to `wallet::asset_lock::orchestration`. } diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs index d7b5edfb10d..911ed9f0161 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs @@ -364,15 +364,10 @@ impl PlatformAddressWallet { let p2pkh = PlatformP2PKHAddress::new(hash); // Platform's proof must carry an `AddressInfo` // for every recipient we asked to fund. A `None` - // here is a protocol-contract violation — - // earlier shape silently mapped it to - // `balance: 0, nonce: 0` and pushed a "credited - // 0" row onto the changeset, which would have - // been indistinguishable from a successful - // zero-credit funding. Skip with a loud - // `tracing::error!` so operators see the drift; - // the asset lock has already Consumed, the - // changeset just won't reflect the recipient. + // 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, @@ -385,14 +380,12 @@ impl PlatformAddressWallet { nonce: ai.nonce, }; account.set_address_credit_balance(p2pkh, funds.balance, key_source.as_ref()); - // Resolve the address's HD slot in the - // account's address pool. The - // `.unwrap_or(0)` earlier here silently - // mis-attributed credits to slot 0 if the - // recipient wasn't in the pool — log and skip - // instead, again to avoid writing a wrong-slot - // changeset entry that the persister would - // store as if it referred to address #0. + // 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift index 517cb50559b..122d39c6a70 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift @@ -491,10 +491,8 @@ struct FundPlatformAddressView: View { // 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) or its balance may have moved. - // Surface that as a fail-fast error instead of silently - // dropping the tap — earlier shape returned nil and the - // button looked unresponsive. + // 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." @@ -663,7 +661,7 @@ struct FundPlatformAddressView: View { /// /// Edge cases: /// - `preSubmitConsumedOutpoints` missing on controller: fall - /// back to the legacy `newest unrecipiented` heuristic. + /// back to a newest-unrecipiented heuristic. /// - Zero new outpoints: nothing to stamp; the funding /// succeeded but no Consumed row appeared yet (persister /// callback lag). The catch-up on the next funding will fix @@ -672,10 +670,7 @@ struct FundPlatformAddressView: View { /// completed Consumed in the same `.onChange` window. /// Refuse to stamp either to avoid mis-attribution — better /// to leave both showing "—" in the storage explorer than to - /// silently tag the wrong row. Both controllers will retry on - /// their next phase tick if they share the same view; in - /// practice one will land first and the other re-runs against - /// the now-smaller delta. + /// silently tag the wrong row. private func backfillRecipientOnConsumedLock() { guard let controller = activeController else { return } let walletId = wallet.walletId @@ -730,10 +725,11 @@ struct FundPlatformAddressView: View { return } } else { - // Legacy fallback — used when the snapshot wasn't - // captured (e.g. a future caller that wires up the - // coordinator directly). Newest-unrecipiented match. - // Documented race window: see the doc comment above. + // 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 } From c7828f8c4b76d861e2d3e3f616c3cd85b17aad8d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 20 May 2026 22:46:19 +0700 Subject: [PATCH 22/35] =?UTF-8?q?rename:=20top=5Fup=20=E2=80=94=20drop=20l?= =?UTF-8?q?egacy=20fund=5Ffrom=5Fasset=5Flock=20+=20"with=5Ffunding"=20suf?= =?UTF-8?q?fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two renames that hung in the air after the wallet-orchestrated flow became the only path the example app uses: 1. **Remove the legacy raw-private-key wallet path.** `PlatformAddressWallet::fund_from_asset_lock` (and its FFI `platform_address_wallet_fund_from_asset_lock`) only existed as a raw-key wrapper that took a pre-built proof + a 32-byte private key. Every caller in this PR's stack goes through the orchestrated signer-pair path now, so the raw-key surface is dead weight at the platform-wallet layer. Deleted both files. The SDK-level `TopUpAddress::top_up` raw-key trait method stays — it's used by `rs-sdk-ffi/address/transitions/top_up_from_asset_lock.rs`, a different public FFI path that doesn't go through `platform-wallet`. 2. **Drop the `_with_funding` suffix.** The identity side keeps `top_up_identity_with_funding` so it pairs with `register_identity_with_funding`. The address side has no register counterpart (addresses are HD-derived, not registered), so the redundant suffix on `fund_addresses_with_funding` was reading as "fund with funding". Renamed everywhere: Layer | Was | Now ------|-----|---- wallet method | `fund_addresses_with_funding` | `top_up` wallet file | `fund_with_funding.rs` | `top_up.rs` wallet FFI | `platform_address_wallet_fund_with_funding_signer` | `platform_address_wallet_top_up_signer` wallet FFI resume | `..._resume_fund_with_existing_asset_lock_signer` | `..._resume_top_up_with_existing_asset_lock_signer` Swift SDK | `fundFromCoreAssetLock` / `resumeFundFromAssetLock` | `topUpFromCore` / `resumeTopUpFromAssetLock` Swift type | `FundingRecipient` | `TopUpRecipient` App view | `FundPlatformAddressView` | `TopUpPlatformAddressView` App controller | `AddressFundingController` | `AddressTopUpController` App coordinator | `AddressFundingCoordinator` | `AddressTopUpCoordinator` App progress | `AddressFundingProgressView/Section` | `AddressTopUpProgressView/Section` App pending list | `PendingPlatformFundingsList` | `PendingPlatformTopUpsList` Manager extension | `addressFundingCoordinator` | `addressTopUpCoordinator` User strings | "Fund Address" / "Funding…" / "Pending Platform Funding" | "Top Up" / "Topping up…" / "Pending Platform Top Ups" Also collapsed the `write_address_balances_changeset` helper from `pub(super)` to `async fn` (private). It only had one caller after the fund_from_asset_lock removal so the cross-module visibility isn't needed. Workspace cargo check green; 124/124 wallet lib tests pass; iOS sim build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/platform_address_types.rs | 2 +- .../fund_from_asset_lock.rs | 86 --------------- .../src/platform_addresses/mod.rs | 6 +- .../{fund_with_funding.rs => top_up.rs} | 18 ++- .../fund_from_asset_lock.rs | 104 ------------------ .../src/wallet/platform_addresses/mod.rs | 3 +- .../{fund_with_funding.rs => top_up.rs} | 21 +--- .../src/wallet/platform_addresses/wallet.rs | 2 +- .../Models/PersistentAssetLock.swift | 2 +- .../ManagedPlatformAddressWallet.swift | 28 ++--- .../Core/Views/WalletDetailView.swift | 16 +-- ...ler.swift => AddressTopUpController.swift} | 12 +- ...or.swift => AddressTopUpCoordinator.swift} | 20 ++-- ...lletManager+AddressTopUpCoordinator.swift} | 16 +-- ...w.swift => AddressTopUpProgressView.swift} | 34 +++--- ....swift => PendingPlatformTopUpsList.swift} | 46 ++++---- .../Views/StorageRecordDetailViews.swift | 2 +- ...w.swift => TopUpPlatformAddressView.swift} | 48 ++++---- 18 files changed, 130 insertions(+), 336 deletions(-) delete mode 100644 packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs rename packages/rs-platform-wallet-ffi/src/platform_addresses/{fund_with_funding.rs => top_up.rs} (94%) delete mode 100644 packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs rename packages/rs-platform-wallet/src/wallet/platform_addresses/{fund_with_funding.rs => top_up.rs} (96%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/{AddressFundingController.swift => AddressTopUpController.swift} (94%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/{AddressFundingCoordinator.swift => AddressTopUpCoordinator.swift} (92%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/{PlatformWalletManager+AddressFundingCoordinator.swift => PlatformWalletManager+AddressTopUpCoordinator.swift} (65%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/{AddressFundingProgressView.swift => AddressTopUpProgressView.swift} (94%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/{PendingPlatformFundingsList.swift => PendingPlatformTopUpsList.swift} (88%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/{FundPlatformAddressView.swift => TopUpPlatformAddressView.swift} (95%) 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 9bbfaa1042e..00000000000 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs +++ /dev/null @@ -1,86 +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 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| { - // Pull the network from the wallet so `PrivateKey::network` - // matches the SPV chain we're signing for. The earlier - // hardcoded `Network::Mainnet` quietly produced a testnet/ - // devnet PrivateKey wearing a mainnet label — harmless for - // ECDSA signing itself (the `network` field is purely a - // serialization tag) but a footgun if the value was ever - // exposed back over an FFI. - let private_key = match dashcore::PrivateKey::from_byte_array(key_array, wallet.network()) { - Ok(pk) => pk, - Err(e) => { - return Err(platform_wallet::PlatformWalletError::AddressOperation( - format!("invalid asset-lock private key bytes: {e}"), - )); - } - }; - 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 7294a346208..f2c098ac1c6 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs @@ -2,16 +2,14 @@ //! //! Mirrors the structure of `platform_wallet::wallet::platform_addresses`. -mod fund_from_asset_lock; -mod fund_with_funding; 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 fund_with_funding::*; +pub use top_up::*; pub use sync::*; pub use transfer::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs similarity index 94% rename from packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs rename to packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs index 15faaf1b6d5..900aadb9259 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs @@ -49,7 +49,7 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// ownership. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_address_wallet_fund_with_funding_signer( +pub unsafe extern "C" fn platform_address_wallet_top_up_signer( handle: Handle, amount_duffs: u64, account_index: u32, @@ -98,7 +98,7 @@ pub unsafe extern "C" fn platform_address_wallet_fund_with_funding_signer( ) }; wallet_clone - .fund_addresses_with_funding( + .top_up( AssetLockFunding::FromWalletBalance { amount_duffs, account_index, @@ -122,7 +122,7 @@ pub unsafe extern "C" fn platform_address_wallet_fund_with_funding_signer( /// Resume a platform-address funding flow from an already-tracked /// asset lock by outpoint. /// -/// Sister to [`platform_address_wallet_fund_with_funding_signer`]: +/// 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 @@ -136,10 +136,10 @@ pub unsafe extern "C" fn platform_address_wallet_fund_with_funding_signer( /// `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_fund_with_funding_signer`]. +/// [`platform_address_wallet_top_up_signer`]. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_address_wallet_resume_fund_with_existing_asset_lock_signer( +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, @@ -189,7 +189,7 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_with_existing_asset ) }; wallet_clone - .fund_addresses_with_funding( + .top_up( AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, }, @@ -211,11 +211,7 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_with_existing_asset /// Decode an FFI array of `FundingAddressEntryFFI` into the /// `BTreeMap>` shape that -/// `fund_addresses_with_funding` consumes. -/// -/// Shared with the legacy raw-private-key -/// [`crate::platform_addresses::fund_from_asset_lock`] FFI so both -/// entry points agree on the wire shape. +/// `top_up` consumes. /// /// # Safety /// - `addresses` must be a valid, non-null pointer to an array of 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 642e679f01e..00000000000 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs +++ /dev/null @@ -1,104 +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?; - - // Bookkeeping shared with the orchestrated - // `fund_addresses_with_funding` path so the two flows can't - // drift apart on the post-success balance-write shape. - Ok(self - .write_address_balances_changeset(account_index, &address_infos) - .await) - } -} 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 0112e3b2e7f..cace3e6682d 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,8 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; pub use dpp::prelude::AddressNonce; -mod fund_from_asset_lock; -mod fund_with_funding; pub(crate) mod provider; +mod top_up; mod sync; mod transfer; mod wallet; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs similarity index 96% rename from packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs rename to packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs index 911ed9f0161..63971f4addb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_with_funding.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs @@ -7,8 +7,7 @@ //! //! ## Pipeline //! -//! 1. **Pre-flight** — exactly-one-`None`-recipient invariant -//! (matches [`PlatformAddressWallet::fund_from_asset_lock`]); each +//! 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`]. @@ -128,7 +127,7 @@ impl PlatformAddressWallet { /// work; resume picks the lock back up via /// `FromExistingAssetLock`. #[allow(clippy::too_many_arguments)] - pub async fn fund_addresses_with_funding( + pub async fn top_up( &self, funding: AssetLockFunding, platform_account_index: u32, @@ -142,9 +141,8 @@ impl PlatformAddressWallet { S: Signer + Send + Sync, AS: ::key_wallet::signer::Signer + Send + Sync, { - // Step 1: pre-flight. Identical invariants to the existing - // `fund_from_asset_lock` raw-key path; failing fast here - // avoids broadcasting an unfundable asset-lock tx. + // 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 @@ -331,14 +329,7 @@ impl PlatformAddressWallet { /// Apply proof-attested credit balances to the /// `ManagedPlatformAccount` for each recipient address, emitting /// a `PlatformAddressChangeSet` describing the new balances. - /// - /// Shared between - /// [`fund_addresses_with_funding`](Self::fund_addresses_with_funding) - /// and the existing raw-key - /// [`fund_from_asset_lock`](Self::fund_from_asset_lock) so the - /// two paths can't drift apart on the post-success bookkeeping - /// shape. - pub(super) async fn write_address_balances_changeset( + async fn write_address_balances_changeset( &self, platform_account_index: u32, address_infos: &AddressInfos, @@ -428,7 +419,7 @@ async fn validate_recipient_addresses( ) -> Result<(), PlatformWalletError> { if addresses.is_empty() { return Err(PlatformWalletError::AddressOperation( - "fund_addresses_with_funding requires at least one recipient address".to_string(), + "top_up requires at least one recipient address".to_string(), )); } 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 615514c2276..1cdd9a01f5a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -30,7 +30,7 @@ pub struct PlatformAddressWallet { /// transfer/withdraw paths take `read` for key_source lookups. pub(crate) provider: Arc>>, /// Shared asset-lock manager. Threaded in so the orchestrated - /// `fund_addresses_with_funding` path can drive + /// `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>, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index d3fb929cc15..0f9774338aa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -99,7 +99,7 @@ public final class PersistentAssetLock { /// 20-byte hash of the recipient platform address for asset /// locks consumed by an `AddressFundingFromAssetLockTransition` /// (`fundingTypeRaw == 4`). Populated by Swift after a - /// successful `fundFromCoreAssetLock` call — the recipient is + /// 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). /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 91bc30123c0..8ad06e2a630 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -372,13 +372,13 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { // MARK: - Fund from Core asset lock - /// Recipient entry for `fundFromCoreAssetLock(...)`. + /// 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 FundingRecipient: Sendable { + public struct TopUpRecipient: Sendable { /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. public let addressType: UInt8 /// 20-byte address hash. @@ -422,14 +422,14 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// Returns the list of `UpdatedBalance`s for each recipient, with /// the new proof-attested credit balance. @discardableResult - public func fundFromCoreAssetLock( + public func topUpFromCore( amountDuffs: UInt64, fundingAccountIndex: UInt32, platformAccountIndex: UInt32, - recipients: [FundingRecipient], + recipients: [TopUpRecipient], signer: KeychainSigner ) async throws -> [UpdatedBalance] { - try fundFromCoreAssetLockPreflight(recipients: recipients) + try topUpFromCorePreflight(recipients: recipients) let handle = self.handle let signerHandle = signer.handle let recipientRows = recipients @@ -467,7 +467,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { let result = withExtendedLifetime((signer, coreSigner)) { ffiAddresses.withUnsafeBufferPointer { addrBp in feeRows.withUnsafeBufferPointer { feeBp in - platform_address_wallet_fund_with_funding_signer( + platform_address_wallet_top_up_signer( handle, amountDuffs, fundingAccountIndex, @@ -493,7 +493,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// Resume a stuck platform-address funding flow from an already- /// tracked asset lock by outpoint. /// - /// Sibling to [`fundFromCoreAssetLock`]: the wallet-balance variant + /// 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 @@ -510,11 +510,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// - outPointVout: Funding output index (always 0 for asset /// locks built by this wallet, but kept for generality). @discardableResult - public func resumeFundFromAssetLock( + public func resumeTopUpFromAssetLock( outPointTxid: Data, outPointVout: UInt32, platformAccountIndex: UInt32, - recipients: [FundingRecipient], + recipients: [TopUpRecipient], signer: KeychainSigner ) async throws -> [UpdatedBalance] { guard outPointTxid.count == 32 else { @@ -522,7 +522,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" ) } - try fundFromCoreAssetLockPreflight(recipients: recipients) + try topUpFromCorePreflight(recipients: recipients) let handle = self.handle let signerHandle = signer.handle let recipientRows = recipients @@ -565,7 +565,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { let result = withExtendedLifetime((signer, coreSigner)) { ffiAddresses.withUnsafeBufferPointer { addrBp in feeRows.withUnsafeBufferPointer { feeBp in - platform_address_wallet_resume_fund_with_existing_asset_lock_signer( + platform_address_wallet_resume_top_up_with_existing_asset_lock_signer( handle, &outPoint, platformAccountIndex, @@ -591,8 +591,8 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// 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 fundFromCoreAssetLockPreflight( - recipients: [FundingRecipient] + private func topUpFromCorePreflight( + recipients: [TopUpRecipient] ) throws { guard !recipients.isEmpty else { throw PlatformWalletError.invalidParameter("recipients is empty") @@ -606,7 +606,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { for r in recipients { guard r.hash.count == 20 else { throw PlatformWalletError.invalidParameter( - "FundingRecipient.hash must be exactly 20 bytes (got \(r.hash.count))" + "TopUpRecipient.hash must be exactly 20 bytes (got \(r.hash.count))" ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index ca87ba10c50..9887926f1b8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -28,8 +28,8 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false @State private var showFundPlatformAddress = false - /// Bound by `PendingPlatformFundingsList`'s Resume row. Setting - /// non-nil presents `FundPlatformAddressView` in resume mode + /// Bound by `PendingPlatformTopUpsList`'s Resume row. Setting + /// non-nil presents `TopUpPlatformAddressView` in resume mode /// against this lock's outpoint. @State private var resumingAssetLock: PersistentAssetLock? @@ -117,8 +117,8 @@ struct WalletDetailView: View { // nothing when there's no in-flight controller and no // orphan asset-lock row, so a freshly-synced wallet // with nothing pending doesn't see an empty card. - PendingPlatformFundingsList( - coordinator: walletManager.addressFundingCoordinator, + PendingPlatformTopUpsList( + coordinator: walletManager.addressTopUpCoordinator, walletId: wallet.walletId, assetLocks: walletAssetLocks, resumingAssetLock: $resumingAssetLock @@ -206,13 +206,13 @@ struct WalletDetailView: View { } } .sheet(isPresented: $showFundPlatformAddress) { - FundPlatformAddressView(wallet: wallet) + TopUpPlatformAddressView(wallet: wallet) } .sheet(item: $resumingAssetLock) { lock in // Resume mode: the Fund view branches on `resumeFromLock` - // and routes Submit to `resumeFundFromAssetLock` against + // and routes Submit to `resumeTopUpFromAssetLock` against // this lock's outpoint instead of building a fresh one. - FundPlatformAddressView(wallet: wallet, resumeFromLock: lock) + TopUpPlatformAddressView(wallet: wallet, resumeFromLock: lock) } .onAppear { appUIState.showWalletsSyncDetails = false } } @@ -786,7 +786,7 @@ struct BalanceCardView: View { trailingAction: onFundPlatform.map { fund in WalletBalanceRow.TrailingAction( systemImage: "plus.circle.fill", - accessibilityLabel: "Fund Platform Balance from Core", + accessibilityLabel: "Top Up Platform Balance from Core", action: fund ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift similarity index 94% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift index db28c698703..1005a162b37 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift @@ -6,12 +6,12 @@ import SwiftDashSDK /// Mirrors [`IdentityRegistrationController`] for the /// `AddressFundingFromAssetLockTransition` flow. One controller is /// created per `(walletId, platformAccountIndex, recipientHash)` -/// slot when the user submits `FundPlatformAddressView`. The +/// slot when the user submits `TopUpPlatformAddressView`. The /// controller owns the in-flight `Task`, exposes its current `phase` /// via `@Published`, and survives view dismissal via -/// `AddressFundingCoordinator` on `PlatformWalletManager`. +/// `AddressTopUpCoordinator` on `PlatformWalletManager`. /// -/// The 4-step progress in `AddressFundingProgressView` derives its +/// 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, @@ -25,7 +25,7 @@ import SwiftDashSDK /// credit-output keys from the `AssetLockAddressTopUp` BIP44 family; /// the index advances naturally per call. @MainActor -final class AddressFundingController: ObservableObject { +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 @@ -40,7 +40,7 @@ final class AddressFundingController: ObservableObject { /// surface in the terminal banner. case completed(newBalance: UInt64) /// Failure terminal state. Message is shown inline in - /// `AddressFundingProgressView`'s step 4; the row stays in + /// `AddressTopUpProgressView`'s step 4; the row stays in /// the coordinator's map until the user dismisses it /// manually. case failed(String) @@ -103,7 +103,7 @@ final class AddressFundingController: ObservableObject { /// Outpoints of `Consumed` address-funding locks observed on /// this wallet **before** `submit()` fired. Captured by the - /// caller (`FundPlatformAddressView.submit`) immediately before + /// 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift similarity index 92% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift index 42ee9fa448d..cf353a09a46 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundingCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift @@ -14,7 +14,7 @@ import SwiftDashSDK /// from double-tapping the same address-funding submission during /// the asset-lock broadcast window. @MainActor -final class AddressFundingCoordinator: ObservableObject { +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 @@ -26,9 +26,9 @@ final class AddressFundingCoordinator: ObservableObject { } /// Active controllers keyed by slot. Stored as `@Published` so - /// the "Pending Platform Funding" row on the Wallet Detail + /// the "Pending Platform Top Ups" row on the Wallet Detail /// screen can observe map mutations via `objectWillChange`. - @Published private(set) var controllers: [SlotKey: AddressFundingController] = [:] + @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(_:)` @@ -49,7 +49,7 @@ final class AddressFundingCoordinator: ObservableObject { walletId: Data, platformAccountIndex: UInt32, recipientHash: Data - ) -> AddressFundingController? { + ) -> AddressTopUpController? { controllers[ SlotKey( walletId: walletId, @@ -63,7 +63,7 @@ final class AddressFundingCoordinator: ObservableObject { /// last submit (most recent first). Used by the "Pending /// Platform Funding" row so dismissed-but-still-running flows /// remain reachable. - func activeControllers() -> [AddressFundingController] { + func activeControllers() -> [AddressTopUpController] { controllers.values.sorted { lhs, rhs in (lhs.lastSubmittedAt ?? .distantPast) > (rhs.lastSubmittedAt ?? .distantPast) } @@ -71,8 +71,8 @@ final class AddressFundingCoordinator: ObservableObject { /// Start a funding for the slot, or reuse an existing /// controller if one is already in flight for it. Returns the - /// controller for `FundPlatformAddressView` to bind a - /// `AddressFundingProgressView` against. + /// 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 @@ -85,7 +85,7 @@ final class AddressFundingCoordinator: ObservableObject { recipientHash: Data, recipientType: UInt8, body: @escaping () async throws -> UInt64 - ) -> AddressFundingController { + ) -> AddressTopUpController { let key = SlotKey( walletId: walletId, platformAccountIndex: platformAccountIndex, @@ -110,7 +110,7 @@ final class AddressFundingCoordinator: ObservableObject { return existing } } - let controller = AddressFundingController( + let controller = AddressTopUpController( walletId: walletId, platformAccountIndex: platformAccountIndex, recipientHash: recipientHash, @@ -148,7 +148,7 @@ final class AddressFundingCoordinator: ObservableObject { /// `RegistrationCoordinator`. private func scheduleRetentionSweep( key: SlotKey, - controller: AddressFundingController + controller: AddressTopUpController ) { Task { [weak self, weak controller] in guard let controller = controller else { return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundingCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift similarity index 65% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundingCoordinator.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift index 88d9305dc4c..cd0573d84c3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundingCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift @@ -2,33 +2,33 @@ import Foundation import ObjectiveC import SwiftDashSDK -/// Per-manager `AddressFundingCoordinator` accessor. Mirrors the +/// 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 `AddressFundingController` instances, which live in the +/// 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 addressFundingCoordinatorKey: UInt8 = 0 + private static var addressTopUpCoordinatorKey: UInt8 = 0 /// Per-manager address-funding coordinator. Created on first /// access; subsequent reads return the same instance. - var addressFundingCoordinator: AddressFundingCoordinator { + var addressTopUpCoordinator: AddressTopUpCoordinator { if let existing = objc_getAssociatedObject( self, - &PlatformWalletManager.addressFundingCoordinatorKey - ) as? AddressFundingCoordinator { + &PlatformWalletManager.addressTopUpCoordinatorKey + ) as? AddressTopUpCoordinator { return existing } - let fresh = AddressFundingCoordinator() + let fresh = AddressTopUpCoordinator() objc_setAssociatedObject( self, - &PlatformWalletManager.addressFundingCoordinatorKey, + &PlatformWalletManager.addressTopUpCoordinatorKey, fresh, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift similarity index 94% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift index bf5ee1f8b0a..448b6dff229 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundingProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift @@ -32,11 +32,11 @@ import SwiftDashSDK /// the lock. /// /// `.completed` is the *terminal* state and is not a separate step; -/// the parent `AddressFundingProgressView` renders the "Address +/// 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 AddressFundingProgressSection: View { - @ObservedObject var controller: AddressFundingController +struct AddressTopUpProgressSection: View { + @ObservedObject var controller: AddressTopUpController /// Asset-lock rows for this wallet, filtered to the /// AssetLockAddressTopUp variant (discriminant `4`). Queried @@ -55,7 +55,7 @@ struct AddressFundingProgressSection: View { /// `AssetLockManager`'s 300 s IS wait. private static let instantLockTimeout: TimeInterval = 300.0 - init(controller: AddressFundingController) { + init(controller: AddressTopUpController) { self.controller = controller let walletId = controller.walletId // `fundingTypeRaw == 4` is `AssetLockFundingType::AssetLockAddressTopUp` @@ -97,7 +97,7 @@ struct AddressFundingProgressSection: View { } } } header: { - Text("Funding Progress") + Text("Top Up Progress") } footer: { Text(footerText(step: step, isFailed: isFailed)) .font(.caption2) @@ -118,7 +118,7 @@ struct AddressFundingProgressSection: View { return 1 case .completed: // No visible "funded" step — terminalSection on - // `AddressFundingProgressView` carries that state. + // `AddressTopUpProgressView` carries that state. // Return 6 so every step row (1...5) is marked `.done`. return 6 case .failed: @@ -321,23 +321,23 @@ struct AddressFundingProgressSection: View { } /// Standalone navigation destination for an address funding in -/// flight, completed, or failed. Pushed from `FundPlatformAddressView` -/// on submit and (later) from the "Resumable Funding" surface. -struct AddressFundingProgressView: View { - @ObservedObject var controller: AddressFundingController +/// 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: AddressFundingController) { + init(controller: AddressTopUpController) { self.controller = controller } var body: some View { Form { - AddressFundingProgressSection(controller: controller) + AddressTopUpProgressSection(controller: controller) terminalSection } - .navigationTitle("Fund Platform Address") + .navigationTitle("Top Up Platform Address") .navigationBarTitleDisplayMode(.inline) } @@ -358,7 +358,7 @@ struct AddressFundingProgressView: View { .font(.system(.body, design: .monospaced)) } Button { - walletManager.addressFundingCoordinator.dismiss( + walletManager.addressTopUpCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -375,7 +375,7 @@ struct AddressFundingProgressView: View { case .failed(let message): Section { VStack(alignment: .leading, spacing: 8) { - Label("Funding failed", systemImage: "xmark.octagon.fill") + Label("Top Up failed", systemImage: "xmark.octagon.fill") .foregroundColor(.red) .font(.headline) Text(message) @@ -383,7 +383,7 @@ struct AddressFundingProgressView: View { .foregroundColor(.primary) .textSelection(.enabled) // Dismissal path mirroring the inline terminal - // section in `FundPlatformAddressView`. Without + // 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 @@ -391,7 +391,7 @@ struct AddressFundingProgressView: View { // outside a List, so neither surface had a // working dismissal. Button { - walletManager.addressFundingCoordinator.dismiss( + walletManager.addressTopUpCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift similarity index 88% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift index e287664246f..2955611a23f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundingsList.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift @@ -1,11 +1,11 @@ -// PendingPlatformFundingsList.swift +// PendingPlatformTopUpsList.swift // SwiftExampleApp // -// Wallet-scoped "Pending Platform Funding" surface that mirrors the +// 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 `AddressFundingCoordinator` — the +// 1. In-flight controllers from `AddressTopUpCoordinator` — the // live submit-still-running case. // 2. Orphaned `PersistentAssetLock` rows with // `fundingTypeRaw == AssetLockAddressTopUp` (4) and @@ -24,11 +24,11 @@ import SwiftDashSDK /// Section view backing the Wallet Detail screen's "Pending Platform /// Funding" surface for a single wallet. Observes -/// `AddressFundingCoordinator` directly (`@ObservedObject`) so its +/// `AddressTopUpCoordinator` directly (`@ObservedObject`) so its /// `@Published controllers` map mutations trigger SwiftUI re-renders /// of the in-flight rows. -struct PendingPlatformFundingsList: View { - @ObservedObject var coordinator: AddressFundingCoordinator +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 @@ -39,7 +39,7 @@ struct PendingPlatformFundingsList: View { /// another `@Query`. let assetLocks: [PersistentAssetLock] /// Bound to the parent's "resume sheet" state. Setting non-nil - /// presents `FundPlatformAddressView` in resume mode. + /// presents `TopUpPlatformAddressView` in resume mode. @Binding var resumingAssetLock: PersistentAssetLock? var body: some View { @@ -60,15 +60,15 @@ struct PendingPlatformFundingsList: View { if !inFlight.isEmpty || !orphans.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack { - Text("Pending Platform Funding (\(inFlight.count + orphans.count))") + Text("Pending Platform Top Ups (\(inFlight.count + orphans.count))") .font(.headline) Spacer() } .padding(.horizontal) VStack(spacing: 0) { - ForEach(Array(inFlight.enumerated()), id: \.element.platformFundingRowID) { idx, controller in - PendingPlatformFundingRow(controller: controller) + 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 { @@ -76,7 +76,7 @@ struct PendingPlatformFundingsList: View { } } ForEach(Array(orphans.enumerated()), id: \.element.id) { idx, lock in - ResumablePlatformFundingRow( + ResumablePlatformTopUpRow( lock: lock, onResume: { resumingAssetLock = lock } ) @@ -95,7 +95,7 @@ struct PendingPlatformFundingsList: View { } /// In-flight controllers scoped to this wallet, newest-first. - private var activeControllersForWallet: [AddressFundingController] { + private var activeControllersForWallet: [AddressTopUpController] { coordinator.activeControllers().filter { $0.walletId == walletId } } @@ -113,27 +113,27 @@ struct PendingPlatformFundingsList: View { } } -private extension AddressFundingController { +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 platformFundingRowID: String { + 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 `AddressFundingController`. -/// Tappable navigation pushes to `AddressFundingProgressView`. -struct PendingPlatformFundingRow: View { - @ObservedObject var controller: AddressFundingController +/// 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: AddressFundingProgressView(controller: controller)) { + NavigationLink(destination: AddressTopUpProgressView(controller: controller)) { VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: phaseIcon) @@ -169,7 +169,7 @@ struct PendingPlatformFundingRow: View { // restart. if case .failed = controller.phase { Button { - walletManager.addressFundingCoordinator.dismiss( + walletManager.addressTopUpCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -212,7 +212,7 @@ struct PendingPlatformFundingRow: View { private var phaseLabel: String { switch controller.phase { case .idle: return "Idle" - case .inFlight: return "Funding…" + case .inFlight: return "Topping up…" case .completed: return "Done" case .failed: return "Failed" } @@ -221,9 +221,9 @@ struct PendingPlatformFundingRow: View { /// Single row in the orphaned-asset-lock section. Renders the lock /// summary (txid prefix, amount, status) plus a compact Resume button -/// that opens `FundPlatformAddressView` in resume mode pre-seeded with +/// that opens `TopUpPlatformAddressView` in resume mode pre-seeded with /// the outpoint. -struct ResumablePlatformFundingRow: View { +struct ResumablePlatformTopUpRow: View { let lock: PersistentAssetLock let onResume: () -> Void diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 5f09052b3b2..976c466cb42 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1860,7 +1860,7 @@ struct AssetLockStorageDetailView: View { if isAddressFunding { // Address-funding section: show the recipient // platform address when Swift stamped it after a - // successful `fundFromCoreAssetLock`. `nil` on rows + // 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- diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift similarity index 95% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift index 122d39c6a70..1cb353cfef7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift @@ -1,9 +1,9 @@ -// FundPlatformAddressView.swift +// TopUpPlatformAddressView.swift // SwiftExampleApp // // Stepped UI for funding a Platform payment address from a Core // (SPV) wallet balance. Drives the new `ManagedPlatformAddressWallet -// .fundFromCoreAssetLock(...)` end-to-end: +// .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). @@ -20,7 +20,7 @@ import SwiftUI import SwiftDashSDK import SwiftData -struct FundPlatformAddressView: View { +struct TopUpPlatformAddressView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @EnvironmentObject var walletManager: PlatformWalletManager @@ -35,7 +35,7 @@ struct FundPlatformAddressView: 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.resumeFundFromAssetLock` instead + /// `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. @@ -63,15 +63,15 @@ struct FundPlatformAddressView: View { /// 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 - /// `AddressFundingProgressView`'s terminal section instead. + /// `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 `AddressFundingProgressSection` + a + /// the form body for `AddressTopUpProgressSection` + a /// terminal section that follows the controller's phase. - /// Lifetime-owned by `walletManager.addressFundingCoordinator` + /// Lifetime-owned by `walletManager.addressTopUpCoordinator` /// so view dismissal mid-flight doesn't lose the work. - @State private var activeController: AddressFundingController? = nil + @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. @@ -92,7 +92,7 @@ struct FundPlatformAddressView: View { // not nested; the progress section + terminal // section follow the same shape as // `RegistrationProgressView`. - AddressFundingProgressSection(controller: controller) + AddressTopUpProgressSection(controller: controller) progressTerminalSection(controller: controller) } else if resumeFromLock != nil { // Resume mode: the asset lock + amount + Core @@ -118,7 +118,7 @@ struct FundPlatformAddressView: View { } } } - .navigationTitle("Fund Platform Address") + .navigationTitle("Top Up Platform Address") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -128,7 +128,7 @@ struct FundPlatformAddressView: View { } .alert(item: $submitError) { err in Alert( - title: Text("Could not fund address"), + title: Text("Could not top up address"), message: Text(err.message), dismissButton: .default(Text("OK")) ) @@ -185,7 +185,7 @@ struct FundPlatformAddressView: View { } } } header: { - Text("Core Funding Source") + 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.") } @@ -271,7 +271,7 @@ struct FundPlatformAddressView: View { submit() } label: { HStack { - Text(resumeFromLock == nil ? "Fund Address" : "Resume Funding") + Text(resumeFromLock == nil ? "Top Up" : "Resume Top Up") Spacer() } .foregroundColor(.white) @@ -318,12 +318,12 @@ struct FundPlatformAddressView: View { /// Inline terminal section that follows the controller's /// `.completed` / `.failed` phase. Mirrors the - /// `terminalSection` shape on `AddressFundingProgressView`, + /// `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: AddressFundingController + controller: AddressTopUpController ) -> some View { switch controller.phase { case .completed(let newBalance): @@ -340,7 +340,7 @@ struct FundPlatformAddressView: View { .font(.system(.body, design: .monospaced)) } Button { - walletManager.addressFundingCoordinator.dismiss( + walletManager.addressTopUpCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -357,7 +357,7 @@ struct FundPlatformAddressView: View { case .failed(let message): Section { VStack(alignment: .leading, spacing: 8) { - Label("Funding failed", systemImage: "xmark.octagon.fill") + Label("Top Up failed", systemImage: "xmark.octagon.fill") .foregroundColor(.red) .font(.headline) Text(message) @@ -365,7 +365,7 @@ struct FundPlatformAddressView: View { .foregroundColor(.primary) .textSelection(.enabled) Button("Dismiss") { - walletManager.addressFundingCoordinator.dismiss( + walletManager.addressTopUpCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -534,12 +534,12 @@ struct FundPlatformAddressView: View { return } body = { - let updates = try await addressWallet.resumeFundFromAssetLock( + let updates = try await addressWallet.resumeTopUpFromAssetLock( outPointTxid: parsed.txid, outPointVout: parsed.vout, platformAccountIndex: platformAcct, recipients: [ - ManagedPlatformAddressWallet.FundingRecipient( + ManagedPlatformAddressWallet.TopUpRecipient( addressType: recipientType, hash: recipientHash, credits: nil @@ -558,12 +558,12 @@ struct FundPlatformAddressView: View { let duffs = parsedDuffs else { return } body = { - let updates = try await addressWallet.fundFromCoreAssetLock( + let updates = try await addressWallet.topUpFromCore( amountDuffs: duffs, fundingAccountIndex: fundingAccountIndex, platformAccountIndex: platformAcct, recipients: [ - ManagedPlatformAddressWallet.FundingRecipient( + ManagedPlatformAddressWallet.TopUpRecipient( addressType: recipientType, hash: recipientHash, credits: nil @@ -588,7 +588,7 @@ struct FundPlatformAddressView: View { // Single-flight gate via the coordinator. The same slot // re-presents the existing controller on a duplicate tap // so two FFI calls never race for the same asset lock. - let coordinator = walletManager.addressFundingCoordinator + let coordinator = walletManager.addressTopUpCoordinator let controller = coordinator.startFunding( walletId: walletId, platformAccountIndex: platformAcct, @@ -602,7 +602,7 @@ struct FundPlatformAddressView: View { // 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 Funding" section on + // reachable via the "Pending Platform Top Ups" section on // the wallet detail screen. activeController = controller } From 2c3fb5f7d87815413498b965117a02739bc94dba Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 21 May 2026 00:08:19 +0700 Subject: [PATCH 23/35] docs(PersistentAssetLock): codify per-funding-type destination convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the rule explicitly at the top of the model: each funding type owns its own typed destination-field family; new funding types follow suit rather than collapsing into a polymorphic `destinationBytes: Data?` blob. - Identity (fundingTypeRaw ∈ {0,1,2,3}) → `identityIndexRaw` - Platform address (fundingTypeRaw == 4) → `recipientPlatformAddress*` - Shielded (fundingTypeRaw == 5, future) → reserve a `recipientShielded*` family rather than reusing the platform address fields or going polymorphic. This keeps SwiftData predicates typed (`#Predicate { $0.identity\ Index == X }` works) and matches the SwiftData lightweight- migration story for adding a new funding type later. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/PersistentAssetLock.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 0f9774338aa..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 From 08c44437757e9ace62ed68cac514cb8c496b01af Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 21 May 2026 00:41:54 +0700 Subject: [PATCH 24/35] chore(WalletDetailView): trim restates-what-the-type-says comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comment blocks in this PR's diff described what was already visible from the surrounding code: - `resumingAssetLock` doc: collapsed from 3 lines describing the type's behaviour (binding → sheet presentation) to one line naming the producer. The `@State PersistentAssetLock?` + the `.sheet(item: $resumingAssetLock)` ten lines down already say the rest. - `walletAssetLocks` doc: dropped entirely. The name + `@Query` + the filter set in init are self-explanatory; the prior comment restated them. - `PendingPlatformTopUpsList` inline comment: dropped. The "collapses to nothing on empty" behaviour is internal to that view (and obvious from its `if !inFlight.isEmpty || ...` outer guard) — the WalletDetailView reader doesn't need it. - Resume `.sheet(item:)` inline comment: dropped. The `resumeFromLock: lock` parameter name is the explanation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/WalletDetailView.swift | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 9887926f1b8..a1660326d9d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -28,15 +28,9 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false @State private var showFundPlatformAddress = false - /// Bound by `PendingPlatformTopUpsList`'s Resume row. Setting - /// non-nil presents `TopUpPlatformAddressView` in resume mode - /// against this lock's outpoint. + /// Set by `PendingPlatformTopUpsList`'s Resume tap. @State private var resumingAssetLock: PersistentAssetLock? - /// Asset-lock rows for this wallet. Drives both the "Pending - /// Platform Funding" section's resumable-row enumeration and - /// (cheap) reactivity to status transitions across the wallet - /// detail screen. @Query private var walletAssetLocks: [PersistentAssetLock] // Badge count for "View All Transactions". Transactions are no @@ -113,10 +107,6 @@ struct WalletDetailView: View { } .padding(.horizontal) - // Pending / Resumable platform funding — collapses to - // nothing when there's no in-flight controller and no - // orphan asset-lock row, so a freshly-synced wallet - // with nothing pending doesn't see an empty card. PendingPlatformTopUpsList( coordinator: walletManager.addressTopUpCoordinator, walletId: wallet.walletId, @@ -209,9 +199,6 @@ struct WalletDetailView: View { TopUpPlatformAddressView(wallet: wallet) } .sheet(item: $resumingAssetLock) { lock in - // Resume mode: the Fund view branches on `resumeFromLock` - // and routes Submit to `resumeTopUpFromAssetLock` against - // this lock's outpoint instead of building a fresh one. TopUpPlatformAddressView(wallet: wallet, resumeFromLock: lock) } .onAppear { appUIState.showWalletsSyncDetails = false } From 3ea2f3acc59a9392f0ddccc24abd050c6dd6579d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 21 May 2026 01:08:41 +0700 Subject: [PATCH 25/35] review: dedupe FFI recipients + filter zero-balance Core accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit findings from PR review: **CR-1** (`platform-wallet-ffi/.../top_up.rs:248`) — `decode_funding_addresses` used `BTreeMap::insert` which silently overwrote duplicates, so a caller sending two entries for the same recipient address would have one entry's amount lost. Swift's `topUpFromCorePreflight` already dedupes client-side, but the FFI is the trust boundary — a future Swift bug or a non-Swift caller could trip the silent collapse. Now returns `ErrorInvalidParameter("duplicate platform address in funding request")` on the second insert. **CR-5** (`TopUpPlatformAddressView.swift:417`) — `coreAccountOptions` included zero-balance accounts, and `canSubmit` never compared the selected account's `balanceDuffs` against `parsedDuffs`. The form presented a valid-looking submit path for requests guaranteed to fail at Rust-side UTXO selection. Two fixes: 1. Filter `coreAccountOptions` to `confirmed > 0` so the picker only shows spendable accounts. 2. Gate `canSubmit` on `selectedCoreAccountBalanceDuffs >= amount` via a new computed helper. Empty-state copy also tightened: "No Core (BIP44 standard) accounts on this wallet." → "No spendable Core (BIP44 standard) accounts on this wallet." matching the new filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/platform_addresses/top_up.rs | 13 +++++++- .../Views/TopUpPlatformAddressView.swift | 30 ++++++++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) 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 index 900aadb9259..6cf143fb50a 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs @@ -243,7 +243,18 @@ pub(super) unsafe fn decode_funding_addresses( } else { None }; - address_map.insert(addr, balance); + // 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift index 1cb353cfef7..19e38f2ee08 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift @@ -172,7 +172,7 @@ struct TopUpPlatformAddressView: View { let options = coreAccountOptions Section { if options.isEmpty { - Text("No Core (BIP44 standard) accounts on this wallet.") + Text("No spendable Core (BIP44 standard) accounts on this wallet.") .font(.caption) .foregroundColor(.secondary) } else { @@ -392,21 +392,25 @@ struct TopUpPlatformAddressView: View { } private var coreAccountOptions: [CoreAccountOption] { - // Surface Core BIP44 standard accounts only. The compound - // filter `typeTag == 0 && standardTag == 0` matches BIP44 + // 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 - // (meaningless for non-Standard variants), surfacing - // duplicate "Account #0" rows in the picker. + // 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 } + .filter { $0.typeTag == 0 && $0.standardTag == 0 && $0.confirmed > 0 } .sorted { $0.index < $1.index } .map { CoreAccountOption( @@ -416,6 +420,14 @@ struct TopUpPlatformAddressView: View { } } + /// 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. @@ -456,10 +468,12 @@ struct TopUpPlatformAddressView: View { && selectedRecipientHash != nil && activeController == nil } + let amount = parsedDuffs ?? 0 return fundingCoreAccountIndex != nil && platformAccountIndex != nil && selectedRecipientHash != nil - && (parsedDuffs ?? 0) >= Self.minDuffs + && amount >= Self.minDuffs + && selectedCoreAccountBalanceDuffs >= amount && activeController == nil } From 723942aba862cd7dbe3a67649c0be71eb5dc135b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 21 May 2026 01:14:53 +0700 Subject: [PATCH 26/35] chore: cargo fmt + fix stale Swift type reference in doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `cargo fmt --all -- --check` failed on PR #3671 — the post- rename `mod top_up` ordering in the `platform_addresses/mod.rs` files and a `let Some(...) = account...` chain wrap in `top_up.rs` both needed rustfmt normalization. Pure formatting; no behavior change. Also caught a stale `AddressFundingController` reference in a doc-comment on `PlatformAddressWallet::top_up` that the earlier mass rename missed (was inside a `///` doc-comment so it didn't break compilation, but it would have confused anyone following the "Cancellation" section to the Swift side). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/platform_addresses/mod.rs | 2 +- .../src/wallet/platform_addresses/mod.rs | 2 +- .../src/wallet/platform_addresses/top_up.rs | 23 ++++++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) 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 f2c098ac1c6..40d3809d59e 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs @@ -9,8 +9,8 @@ mod wallet; mod withdrawal; // Re-export all FFI types and functions. -pub use top_up::*; pub use sync::*; +pub use top_up::*; pub use transfer::*; pub use wallet::*; pub use withdrawal::*; 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 cace3e6682d..523880df90d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -7,8 +7,8 @@ use dpp::fee::Credits; pub use dpp::prelude::AddressNonce; pub(crate) mod provider; -mod top_up; 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 index 63971f4addb..b685facdf14 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs @@ -120,7 +120,7 @@ impl PlatformAddressWallet { /// either `consume_asset_lock` completes or the next resume /// advances it. /// - /// The Swift `AddressFundingController.task` field deliberately + /// 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 @@ -377,16 +377,17 @@ impl PlatformAddressWallet { // 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) - }) + 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, From 974142d7a3774ecece66df771ee43b9a390ddb84 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 21 May 2026 01:41:27 +0700 Subject: [PATCH 27/35] chore(rs-sdk): allow too_many_arguments on top_up_with_signers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI clippy ran with `-D warnings` and flagged the new trait method + two impls at 8 args > 7. The signer-pair shape is unavoidable here (sdk, asset_lock_proof, asset_lock_proof_path, fee_strategy, signer, asset_lock_signer, settings — plus self on impls). Matches the existing `broadcast_request_for_new_identity_with_signer` allow on the identity-side broadcast helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-sdk/src/platform/transition/top_up_address.rs | 3 +++ 1 file changed, 3 insertions(+) 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 a697e34605b..126bb5857c8 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_address.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_address.rs @@ -58,6 +58,7 @@ pub trait TopUpAddress> { /// (`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, @@ -102,6 +103,7 @@ where } #[cfg(feature = "core_key_wallet")] + #[allow(clippy::too_many_arguments)] async fn top_up_with_signers( &self, sdk: &Sdk, @@ -165,6 +167,7 @@ impl> TopUpAddress for AddressesWithBalances { } #[cfg(feature = "core_key_wallet")] + #[allow(clippy::too_many_arguments)] async fn top_up_with_signers( &self, sdk: &Sdk, From 91b9604d8df3eea213b4159d44f3c6b1f58f0918 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 22 May 2026 16:36:18 +0700 Subject: [PATCH 28/35] =?UTF-8?q?rename:=20top=5Fup=20=E2=86=92=20fund=5Ff?= =?UTF-8?q?rom=5Fasset=5Flock=20across=20address-funding=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the descriptive legacy name across rs-platform-wallet, rs-platform-wallet-ffi, Swift SDK, and SwiftExampleApp. The previous `top_up` name was ambiguous next to the identity-side `register_identity_with_funding` and conflicted with the prior legacy `fund_from_asset_lock` terminology. Pre-existing `TopUpAddress` SDK trait and `top_up_with_signers` method in rs-sdk (added in #2875/#3008) are untouched — they're shared SDK API, not part of this PR's surface. Same for `AssetLockFundingType::*TopUp` enum variants. Build verified: cargo check -p platform-wallet-ffi, ./build_ios.sh (sim, debug), xcodebuild SwiftExampleApp — all green. --- .../{top_up.rs => fund_from_asset_lock.rs} | 16 ++++---- .../src/platform_addresses/mod.rs | 4 +- .../{top_up.rs => fund_from_asset_lock.rs} | 6 +-- .../src/wallet/platform_addresses/mod.rs | 2 +- .../src/wallet/platform_addresses/wallet.rs | 2 +- .../Models/PersistentAssetLock.swift | 2 +- .../ManagedPlatformAddressWallet.swift | 28 ++++++------- .../Core/Views/WalletDetailView.swift | 10 ++--- ... AddressFundFromAssetLockController.swift} | 12 +++--- ...AddressFundFromAssetLockCoordinator.swift} | 18 ++++----- ...AddressFundFromAssetLockCoordinator.swift} | 16 ++++---- ...ddressFundFromAssetLockProgressView.swift} | 26 ++++++------ ...undFromAssetLockPlatformAddressView.swift} | 36 ++++++++--------- ...ndingPlatformFundFromAssetLocksList.swift} | 40 +++++++++---------- .../Views/StorageRecordDetailViews.swift | 2 +- 15 files changed, 110 insertions(+), 110 deletions(-) rename packages/rs-platform-wallet-ffi/src/platform_addresses/{top_up.rs => fund_from_asset_lock.rs} (95%) rename packages/rs-platform-wallet/src/wallet/platform_addresses/{top_up.rs => fund_from_asset_lock.rs} (99%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/{AddressTopUpController.swift => AddressFundFromAssetLockController.swift} (93%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/{AddressTopUpCoordinator.swift => AddressFundFromAssetLockCoordinator.swift} (92%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/{PlatformWalletManager+AddressTopUpCoordinator.swift => PlatformWalletManager+AddressFundFromAssetLockCoordinator.swift} (61%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/{AddressTopUpProgressView.swift => AddressFundFromAssetLockProgressView.swift} (94%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/{TopUpPlatformAddressView.swift => FundFromAssetLockPlatformAddressView.swift} (96%) rename packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/{PendingPlatformTopUpsList.swift => PendingPlatformFundFromAssetLocksList.swift} (88%) diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs similarity index 95% rename from packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs rename to packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs index 6cf143fb50a..bb19f2d2533 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/top_up.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs @@ -49,7 +49,7 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// ownership. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_address_wallet_top_up_signer( +pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock_signer( handle: Handle, amount_duffs: u64, account_index: u32, @@ -98,7 +98,7 @@ pub unsafe extern "C" fn platform_address_wallet_top_up_signer( ) }; wallet_clone - .top_up( + .fund_from_asset_lock( AssetLockFunding::FromWalletBalance { amount_duffs, account_index, @@ -122,7 +122,7 @@ pub unsafe extern "C" fn platform_address_wallet_top_up_signer( /// Resume a platform-address funding flow from an already-tracked /// asset lock by outpoint. /// -/// Sister to [`platform_address_wallet_top_up_signer`]: +/// Sister to [`platform_address_wallet_fund_from_asset_lock_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 @@ -136,10 +136,10 @@ pub unsafe extern "C" fn platform_address_wallet_top_up_signer( /// `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`]. +/// [`platform_address_wallet_fund_from_asset_lock_signer`]. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_address_wallet_resume_top_up_with_existing_asset_lock_signer( +pub unsafe extern "C" fn platform_address_wallet_resume_fund_from_asset_lock_signer( handle: Handle, out_point: *const OutPointFFI, platform_account_index: u32, @@ -189,7 +189,7 @@ pub unsafe extern "C" fn platform_address_wallet_resume_top_up_with_existing_ass ) }; wallet_clone - .top_up( + .fund_from_asset_lock( AssetLockFunding::FromExistingAssetLock { out_point: resume_outpoint, }, @@ -211,7 +211,7 @@ pub unsafe extern "C" fn platform_address_wallet_resume_top_up_with_existing_ass /// Decode an FFI array of `FundingAddressEntryFFI` into the /// `BTreeMap>` shape that -/// `top_up` consumes. +/// `fund_from_asset_lock` consumes. /// /// # Safety /// - `addresses` must be a valid, non-null pointer to an array of @@ -244,7 +244,7 @@ pub(super) unsafe fn decode_funding_addresses( None }; // Reject duplicates rather than silently collapsing them. - // The Swift wrapper's `topUpFromCorePreflight` already + // The Swift wrapper's `fundFromAssetLockPreflight` 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 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 40d3809d59e..d5e67aa5163 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs @@ -3,14 +3,14 @@ //! Mirrors the structure of `platform_wallet::wallet::platform_addresses`. mod sync; -mod top_up; +mod fund_from_asset_lock; mod transfer; mod wallet; mod withdrawal; // Re-export all FFI types and functions. pub use sync::*; -pub use top_up::*; +pub use fund_from_asset_lock::*; pub use transfer::*; pub use wallet::*; pub use withdrawal::*; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs similarity index 99% rename from packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs rename to packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs index b685facdf14..98664f5f125 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/top_up.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs @@ -120,14 +120,14 @@ impl PlatformAddressWallet { /// either `consume_asset_lock` completes or the next resume /// advances it. /// - /// The Swift `AddressTopUpController.task` field deliberately + /// The Swift `AddressFundFromAssetLockController.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( + pub async fn fund_from_asset_lock( &self, funding: AssetLockFunding, platform_account_index: u32, @@ -420,7 +420,7 @@ async fn validate_recipient_addresses( ) -> Result<(), PlatformWalletError> { if addresses.is_empty() { return Err(PlatformWalletError::AddressOperation( - "top_up requires at least one recipient address".to_string(), + "fund_from_asset_lock requires at least one recipient address".to_string(), )); } 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 523880df90d..82547d671a3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -8,7 +8,7 @@ pub use dpp::prelude::AddressNonce; pub(crate) mod provider; mod sync; -mod top_up; +mod fund_from_asset_lock; mod transfer; mod wallet; mod withdrawal; 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 1e948a0dbe3..8708d101565 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -30,7 +30,7 @@ pub struct PlatformAddressWallet { /// 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 + /// `fund_from_asset_lock` 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>, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 4f886eb2298..361c3ce4cc8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -129,7 +129,7 @@ public final class PersistentAssetLock { /// 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 + /// successful `fundFromAssetLock` 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). /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 8ad06e2a630..7286ff528fe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -372,13 +372,13 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { // MARK: - Fund from Core asset lock - /// Recipient entry for `topUpFromCore(...)`. + /// Recipient entry for `fundFromAssetLock(...)`. /// /// 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 { + public struct FundFromAssetLockRecipient: Sendable { /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. public let addressType: UInt8 /// 20-byte address hash. @@ -422,14 +422,14 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// Returns the list of `UpdatedBalance`s for each recipient, with /// the new proof-attested credit balance. @discardableResult - public func topUpFromCore( + public func fundFromAssetLock( amountDuffs: UInt64, fundingAccountIndex: UInt32, platformAccountIndex: UInt32, - recipients: [TopUpRecipient], + recipients: [FundFromAssetLockRecipient], signer: KeychainSigner ) async throws -> [UpdatedBalance] { - try topUpFromCorePreflight(recipients: recipients) + try fundFromAssetLockPreflight(recipients: recipients) let handle = self.handle let signerHandle = signer.handle let recipientRows = recipients @@ -467,7 +467,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { let result = withExtendedLifetime((signer, coreSigner)) { ffiAddresses.withUnsafeBufferPointer { addrBp in feeRows.withUnsafeBufferPointer { feeBp in - platform_address_wallet_top_up_signer( + platform_address_wallet_fund_from_asset_lock_signer( handle, amountDuffs, fundingAccountIndex, @@ -493,7 +493,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// Resume a stuck platform-address funding flow from an already- /// tracked asset lock by outpoint. /// - /// Sibling to [`topUpFromCore`]: the wallet-balance variant + /// Sibling to [`fundFromAssetLock`]: 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 @@ -510,11 +510,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// - outPointVout: Funding output index (always 0 for asset /// locks built by this wallet, but kept for generality). @discardableResult - public func resumeTopUpFromAssetLock( + public func resumeFundFromAssetLock( outPointTxid: Data, outPointVout: UInt32, platformAccountIndex: UInt32, - recipients: [TopUpRecipient], + recipients: [FundFromAssetLockRecipient], signer: KeychainSigner ) async throws -> [UpdatedBalance] { guard outPointTxid.count == 32 else { @@ -522,7 +522,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" ) } - try topUpFromCorePreflight(recipients: recipients) + try fundFromAssetLockPreflight(recipients: recipients) let handle = self.handle let signerHandle = signer.handle let recipientRows = recipients @@ -565,7 +565,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { let result = withExtendedLifetime((signer, coreSigner)) { ffiAddresses.withUnsafeBufferPointer { addrBp in feeRows.withUnsafeBufferPointer { feeBp in - platform_address_wallet_resume_top_up_with_existing_asset_lock_signer( + platform_address_wallet_resume_fund_from_asset_lock_signer( handle, &outPoint, platformAccountIndex, @@ -591,8 +591,8 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// 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] + private func fundFromAssetLockPreflight( + recipients: [FundFromAssetLockRecipient] ) throws { guard !recipients.isEmpty else { throw PlatformWalletError.invalidParameter("recipients is empty") @@ -606,7 +606,7 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { for r in recipients { guard r.hash.count == 20 else { throw PlatformWalletError.invalidParameter( - "TopUpRecipient.hash must be exactly 20 bytes (got \(r.hash.count))" + "FundFromAssetLockRecipient.hash must be exactly 20 bytes (got \(r.hash.count))" ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 6e0cd28cf1f..723e321b057 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -29,7 +29,7 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false @State private var showFundPlatformAddress = false - /// Set by `PendingPlatformTopUpsList`'s Resume tap. + /// Set by `PendingPlatformFundFromAssetLocksList`'s Resume tap. @State private var resumingAssetLock: PersistentAssetLock? @Query private var walletAssetLocks: [PersistentAssetLock] @@ -108,8 +108,8 @@ struct WalletDetailView: View { } .padding(.horizontal) - PendingPlatformTopUpsList( - coordinator: walletManager.addressTopUpCoordinator, + PendingPlatformFundFromAssetLocksList( + coordinator: walletManager.addressFundFromAssetLockCoordinator, walletId: wallet.walletId, assetLocks: walletAssetLocks, resumingAssetLock: $resumingAssetLock @@ -197,10 +197,10 @@ struct WalletDetailView: View { } } .sheet(isPresented: $showFundPlatformAddress) { - TopUpPlatformAddressView(wallet: wallet) + FundFromAssetLockPlatformAddressView(wallet: wallet) } .sheet(item: $resumingAssetLock) { lock in - TopUpPlatformAddressView(wallet: wallet, resumeFromLock: lock) + FundFromAssetLockPlatformAddressView(wallet: wallet, resumeFromLock: lock) } .onAppear { appUIState.showWalletsSyncDetails = false diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockController.swift similarity index 93% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockController.swift index 1005a162b37..65060e9a985 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockController.swift @@ -6,12 +6,12 @@ import SwiftDashSDK /// Mirrors [`IdentityRegistrationController`] for the /// `AddressFundingFromAssetLockTransition` flow. One controller is /// created per `(walletId, platformAccountIndex, recipientHash)` -/// slot when the user submits `TopUpPlatformAddressView`. The +/// slot when the user submits `FundFromAssetLockPlatformAddressView`. The /// controller owns the in-flight `Task`, exposes its current `phase` /// via `@Published`, and survives view dismissal via -/// `AddressTopUpCoordinator` on `PlatformWalletManager`. +/// `AddressFundFromAssetLockCoordinator` on `PlatformWalletManager`. /// -/// The 4-step progress in `AddressTopUpProgressView` derives its +/// The 4-step progress in `AddressFundFromAssetLockProgressView` 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, @@ -25,7 +25,7 @@ import SwiftDashSDK /// credit-output keys from the `AssetLockAddressTopUp` BIP44 family; /// the index advances naturally per call. @MainActor -final class AddressTopUpController: ObservableObject { +final class AddressFundFromAssetLockController: ObservableObject { enum Phase: Equatable { /// Pre-submit. The controller exists but `submit` hasn't /// fired yet. Not surfaced by the progress view (the view @@ -40,7 +40,7 @@ final class AddressTopUpController: ObservableObject { /// 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 + /// `AddressFundFromAssetLockProgressView`'s step 4; the row stays in /// the coordinator's map until the user dismisses it /// manually. case failed(String) @@ -103,7 +103,7 @@ final class AddressTopUpController: ObservableObject { /// Outpoints of `Consumed` address-funding locks observed on /// this wallet **before** `submit()` fired. Captured by the - /// caller (`TopUpPlatformAddressView.submit`) immediately before + /// caller (`FundFromAssetLockPlatformAddressView.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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift similarity index 92% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift index cf353a09a46..2644cdaed07 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressTopUpCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift @@ -14,7 +14,7 @@ import SwiftDashSDK /// from double-tapping the same address-funding submission during /// the asset-lock broadcast window. @MainActor -final class AddressTopUpCoordinator: ObservableObject { +final class AddressFundFromAssetLockCoordinator: 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 @@ -28,7 +28,7 @@ final class AddressTopUpCoordinator: ObservableObject { /// 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] = [:] + @Published private(set) var controllers: [SlotKey: AddressFundFromAssetLockController] = [:] /// True when at least one slot is currently in flight (phase /// `.inFlight`). Used by the network toggle's `.disabled(_:)` @@ -49,7 +49,7 @@ final class AddressTopUpCoordinator: ObservableObject { walletId: Data, platformAccountIndex: UInt32, recipientHash: Data - ) -> AddressTopUpController? { + ) -> AddressFundFromAssetLockController? { controllers[ SlotKey( walletId: walletId, @@ -63,7 +63,7 @@ final class AddressTopUpCoordinator: ObservableObject { /// last submit (most recent first). Used by the "Pending /// Platform Funding" row so dismissed-but-still-running flows /// remain reachable. - func activeControllers() -> [AddressTopUpController] { + func activeControllers() -> [AddressFundFromAssetLockController] { controllers.values.sorted { lhs, rhs in (lhs.lastSubmittedAt ?? .distantPast) > (rhs.lastSubmittedAt ?? .distantPast) } @@ -71,8 +71,8 @@ final class AddressTopUpCoordinator: ObservableObject { /// 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. + /// controller for `FundFromAssetLockPlatformAddressView` to bind a + /// `AddressFundFromAssetLockProgressView` against. /// /// Single-flighting is enforced here at the coordinator level /// because the controller's `submit()` only guards within its @@ -85,7 +85,7 @@ final class AddressTopUpCoordinator: ObservableObject { recipientHash: Data, recipientType: UInt8, body: @escaping () async throws -> UInt64 - ) -> AddressTopUpController { + ) -> AddressFundFromAssetLockController { let key = SlotKey( walletId: walletId, platformAccountIndex: platformAccountIndex, @@ -110,7 +110,7 @@ final class AddressTopUpCoordinator: ObservableObject { return existing } } - let controller = AddressTopUpController( + let controller = AddressFundFromAssetLockController( walletId: walletId, platformAccountIndex: platformAccountIndex, recipientHash: recipientHash, @@ -148,7 +148,7 @@ final class AddressTopUpCoordinator: ObservableObject { /// `RegistrationCoordinator`. private func scheduleRetentionSweep( key: SlotKey, - controller: AddressTopUpController + controller: AddressFundFromAssetLockController ) { Task { [weak self, weak controller] in guard let controller = controller else { return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundFromAssetLockCoordinator.swift similarity index 61% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundFromAssetLockCoordinator.swift index cd0573d84c3..480f7409ae5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressTopUpCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+AddressFundFromAssetLockCoordinator.swift @@ -2,33 +2,33 @@ import Foundation import ObjectiveC import SwiftDashSDK -/// Per-manager `AddressTopUpCoordinator` accessor. Mirrors the +/// Per-manager `AddressFundFromAssetLockCoordinator` 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 +/// stores `AddressFundFromAssetLockController` 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 + private static var addressFundFromAssetLockCoordinatorKey: UInt8 = 0 /// Per-manager address-funding coordinator. Created on first /// access; subsequent reads return the same instance. - var addressTopUpCoordinator: AddressTopUpCoordinator { + var addressFundFromAssetLockCoordinator: AddressFundFromAssetLockCoordinator { if let existing = objc_getAssociatedObject( self, - &PlatformWalletManager.addressTopUpCoordinatorKey - ) as? AddressTopUpCoordinator { + &PlatformWalletManager.addressFundFromAssetLockCoordinatorKey + ) as? AddressFundFromAssetLockCoordinator { return existing } - let fresh = AddressTopUpCoordinator() + let fresh = AddressFundFromAssetLockCoordinator() objc_setAssociatedObject( self, - &PlatformWalletManager.addressTopUpCoordinatorKey, + &PlatformWalletManager.addressFundFromAssetLockCoordinatorKey, fresh, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundFromAssetLockProgressView.swift similarity index 94% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundFromAssetLockProgressView.swift index 448b6dff229..049727269e8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressTopUpProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressFundFromAssetLockProgressView.swift @@ -32,11 +32,11 @@ import SwiftDashSDK /// the lock. /// /// `.completed` is the *terminal* state and is not a separate step; -/// the parent `AddressTopUpProgressView` renders the "Address +/// the parent `AddressFundFromAssetLockProgressView` 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 +struct AddressFundFromAssetLockProgressSection: View { + @ObservedObject var controller: AddressFundFromAssetLockController /// Asset-lock rows for this wallet, filtered to the /// AssetLockAddressTopUp variant (discriminant `4`). Queried @@ -55,7 +55,7 @@ struct AddressTopUpProgressSection: View { /// `AssetLockManager`'s 300 s IS wait. private static let instantLockTimeout: TimeInterval = 300.0 - init(controller: AddressTopUpController) { + init(controller: AddressFundFromAssetLockController) { self.controller = controller let walletId = controller.walletId // `fundingTypeRaw == 4` is `AssetLockFundingType::AssetLockAddressTopUp` @@ -118,7 +118,7 @@ struct AddressTopUpProgressSection: View { return 1 case .completed: // No visible "funded" step — terminalSection on - // `AddressTopUpProgressView` carries that state. + // `AddressFundFromAssetLockProgressView` carries that state. // Return 6 so every step row (1...5) is marked `.done`. return 6 case .failed: @@ -321,20 +321,20 @@ struct AddressTopUpProgressSection: View { } /// Standalone navigation destination for an address funding in -/// flight, completed, or failed. Pushed from `TopUpPlatformAddressView` +/// flight, completed, or failed. Pushed from `FundFromAssetLockPlatformAddressView` /// on submit and (later) from the "Resumable Top Up" surface. -struct AddressTopUpProgressView: View { - @ObservedObject var controller: AddressTopUpController +struct AddressFundFromAssetLockProgressView: View { + @ObservedObject var controller: AddressFundFromAssetLockController @Environment(\.dismiss) private var dismiss @EnvironmentObject var walletManager: PlatformWalletManager - init(controller: AddressTopUpController) { + init(controller: AddressFundFromAssetLockController) { self.controller = controller } var body: some View { Form { - AddressTopUpProgressSection(controller: controller) + AddressFundFromAssetLockProgressSection(controller: controller) terminalSection } .navigationTitle("Top Up Platform Address") @@ -358,7 +358,7 @@ struct AddressTopUpProgressView: View { .font(.system(.body, design: .monospaced)) } Button { - walletManager.addressTopUpCoordinator.dismiss( + walletManager.addressFundFromAssetLockCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -383,7 +383,7 @@ struct AddressTopUpProgressView: View { .foregroundColor(.primary) .textSelection(.enabled) // Dismissal path mirroring the inline terminal - // section in `TopUpPlatformAddressView`. Without + // section in `FundFromAssetLockPlatformAddressView`. Without // this the only way to clear a `.failed` // controller from a pushed progress view was to // relaunch the app — the `Pending Platform @@ -391,7 +391,7 @@ struct AddressTopUpProgressView: View { // outside a List, so neither surface had a // working dismissal. Button { - walletManager.addressTopUpCoordinator.dismiss( + walletManager.addressFundFromAssetLockCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift similarity index 96% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift index 19e38f2ee08..63ef9aead89 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift @@ -1,9 +1,9 @@ -// TopUpPlatformAddressView.swift +// FundFromAssetLockPlatformAddressView.swift // SwiftExampleApp // // Stepped UI for funding a Platform payment address from a Core // (SPV) wallet balance. Drives the new `ManagedPlatformAddressWallet -// .topUpFromCore(...)` end-to-end: +// .fundFromAssetLock(...)` 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). @@ -20,7 +20,7 @@ import SwiftUI import SwiftDashSDK import SwiftData -struct TopUpPlatformAddressView: View { +struct FundFromAssetLockPlatformAddressView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @EnvironmentObject var walletManager: PlatformWalletManager @@ -35,7 +35,7 @@ struct TopUpPlatformAddressView: 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 + /// `ManagedPlatformAddressWallet.resumeFundFromAssetLock` 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. @@ -63,15 +63,15 @@ struct TopUpPlatformAddressView: View { /// 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. + /// `AddressFundFromAssetLockProgressView`'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 + /// the form body for `AddressFundFromAssetLockProgressSection` + a /// terminal section that follows the controller's phase. - /// Lifetime-owned by `walletManager.addressTopUpCoordinator` + /// Lifetime-owned by `walletManager.addressFundFromAssetLockCoordinator` /// so view dismissal mid-flight doesn't lose the work. - @State private var activeController: AddressTopUpController? = nil + @State private var activeController: AddressFundFromAssetLockController? = nil /// 1 DASH = 1e8 duffs (Core side). The asset-lock builder takes /// duffs; we convert here for display ergonomics only. @@ -92,7 +92,7 @@ struct TopUpPlatformAddressView: View { // not nested; the progress section + terminal // section follow the same shape as // `RegistrationProgressView`. - AddressTopUpProgressSection(controller: controller) + AddressFundFromAssetLockProgressSection(controller: controller) progressTerminalSection(controller: controller) } else if resumeFromLock != nil { // Resume mode: the asset lock + amount + Core @@ -318,12 +318,12 @@ struct TopUpPlatformAddressView: View { /// Inline terminal section that follows the controller's /// `.completed` / `.failed` phase. Mirrors the - /// `terminalSection` shape on `AddressTopUpProgressView`, + /// `terminalSection` shape on `AddressFundFromAssetLockProgressView`, /// 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 + controller: AddressFundFromAssetLockController ) -> some View { switch controller.phase { case .completed(let newBalance): @@ -340,7 +340,7 @@ struct TopUpPlatformAddressView: View { .font(.system(.body, design: .monospaced)) } Button { - walletManager.addressTopUpCoordinator.dismiss( + walletManager.addressFundFromAssetLockCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -365,7 +365,7 @@ struct TopUpPlatformAddressView: View { .foregroundColor(.primary) .textSelection(.enabled) Button("Dismiss") { - walletManager.addressTopUpCoordinator.dismiss( + walletManager.addressFundFromAssetLockCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -548,12 +548,12 @@ struct TopUpPlatformAddressView: View { return } body = { - let updates = try await addressWallet.resumeTopUpFromAssetLock( + let updates = try await addressWallet.resumeFundFromAssetLock( outPointTxid: parsed.txid, outPointVout: parsed.vout, platformAccountIndex: platformAcct, recipients: [ - ManagedPlatformAddressWallet.TopUpRecipient( + ManagedPlatformAddressWallet.FundFromAssetLockRecipient( addressType: recipientType, hash: recipientHash, credits: nil @@ -572,12 +572,12 @@ struct TopUpPlatformAddressView: View { let duffs = parsedDuffs else { return } body = { - let updates = try await addressWallet.topUpFromCore( + let updates = try await addressWallet.fundFromAssetLock( amountDuffs: duffs, fundingAccountIndex: fundingAccountIndex, platformAccountIndex: platformAcct, recipients: [ - ManagedPlatformAddressWallet.TopUpRecipient( + ManagedPlatformAddressWallet.FundFromAssetLockRecipient( addressType: recipientType, hash: recipientHash, credits: nil @@ -602,7 +602,7 @@ struct TopUpPlatformAddressView: View { // Single-flight gate via the coordinator. The same slot // re-presents the existing controller on a duplicate tap // so two FFI calls never race for the same asset lock. - let coordinator = walletManager.addressTopUpCoordinator + let coordinator = walletManager.addressFundFromAssetLockCoordinator let controller = coordinator.startFunding( walletId: walletId, platformAccountIndex: platformAcct, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift similarity index 88% rename from packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift rename to packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift index 2955611a23f..bb73bd65d11 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformTopUpsList.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift @@ -1,11 +1,11 @@ -// PendingPlatformTopUpsList.swift +// PendingPlatformFundFromAssetLocksList.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 +// 1. In-flight controllers from `AddressFundFromAssetLockCoordinator` — the // live submit-still-running case. // 2. Orphaned `PersistentAssetLock` rows with // `fundingTypeRaw == AssetLockAddressTopUp` (4) and @@ -24,11 +24,11 @@ import SwiftDashSDK /// Section view backing the Wallet Detail screen's "Pending Platform /// Funding" surface for a single wallet. Observes -/// `AddressTopUpCoordinator` directly (`@ObservedObject`) so its +/// `AddressFundFromAssetLockCoordinator` directly (`@ObservedObject`) so its /// `@Published controllers` map mutations trigger SwiftUI re-renders /// of the in-flight rows. -struct PendingPlatformTopUpsList: View { - @ObservedObject var coordinator: AddressTopUpCoordinator +struct PendingPlatformFundFromAssetLocksList: View { + @ObservedObject var coordinator: AddressFundFromAssetLockCoordinator /// 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 @@ -39,7 +39,7 @@ struct PendingPlatformTopUpsList: View { /// another `@Query`. let assetLocks: [PersistentAssetLock] /// Bound to the parent's "resume sheet" state. Setting non-nil - /// presents `TopUpPlatformAddressView` in resume mode. + /// presents `FundFromAssetLockPlatformAddressView` in resume mode. @Binding var resumingAssetLock: PersistentAssetLock? var body: some View { @@ -67,8 +67,8 @@ struct PendingPlatformTopUpsList: View { .padding(.horizontal) VStack(spacing: 0) { - ForEach(Array(inFlight.enumerated()), id: \.element.platformTopUpRowID) { idx, controller in - PendingPlatformTopUpRow(controller: controller) + ForEach(Array(inFlight.enumerated()), id: \.element.platformFundFromAssetLockRowID) { idx, controller in + PendingPlatformFundFromAssetLockRow(controller: controller) .padding(.horizontal) .padding(.vertical, 10) if idx < inFlight.count - 1 || !orphans.isEmpty { @@ -76,7 +76,7 @@ struct PendingPlatformTopUpsList: View { } } ForEach(Array(orphans.enumerated()), id: \.element.id) { idx, lock in - ResumablePlatformTopUpRow( + ResumablePlatformFundFromAssetLockRow( lock: lock, onResume: { resumingAssetLock = lock } ) @@ -95,7 +95,7 @@ struct PendingPlatformTopUpsList: View { } /// In-flight controllers scoped to this wallet, newest-first. - private var activeControllersForWallet: [AddressTopUpController] { + private var activeControllersForWallet: [AddressFundFromAssetLockController] { coordinator.activeControllers().filter { $0.walletId == walletId } } @@ -113,27 +113,27 @@ struct PendingPlatformTopUpsList: View { } } -private extension AddressTopUpController { +private extension AddressFundFromAssetLockController { /// 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 { + var platformFundFromAssetLockRowID: 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 +/// Single row representing an in-flight `AddressFundFromAssetLockController`. +/// Tappable navigation pushes to `AddressFundFromAssetLockProgressView`. +struct PendingPlatformFundFromAssetLockRow: View { + @ObservedObject var controller: AddressFundFromAssetLockController @EnvironmentObject var walletManager: PlatformWalletManager var body: some View { HStack(spacing: 8) { - NavigationLink(destination: AddressTopUpProgressView(controller: controller)) { + NavigationLink(destination: AddressFundFromAssetLockProgressView(controller: controller)) { VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: phaseIcon) @@ -169,7 +169,7 @@ struct PendingPlatformTopUpRow: View { // restart. if case .failed = controller.phase { Button { - walletManager.addressTopUpCoordinator.dismiss( + walletManager.addressFundFromAssetLockCoordinator.dismiss( walletId: controller.walletId, platformAccountIndex: controller.platformAccountIndex, recipientHash: controller.recipientHash @@ -221,9 +221,9 @@ struct PendingPlatformTopUpRow: View { /// 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 +/// that opens `FundFromAssetLockPlatformAddressView` in resume mode pre-seeded with /// the outpoint. -struct ResumablePlatformTopUpRow: View { +struct ResumablePlatformFundFromAssetLockRow: View { let lock: PersistentAssetLock let onResume: () -> Void diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 09d120f6abe..da655489643 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1928,7 +1928,7 @@ struct AssetLockStorageDetailView: View { if isAddressFunding { // Address-funding section: show the recipient // platform address when Swift stamped it after a - // successful `topUpFromCore`. `nil` on rows + // successful `fundFromAssetLock`. `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- From 9bbdc6676083664e98e3c74bb5c09aa786cb3166 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 22 May 2026 19:46:44 +0700 Subject: [PATCH 29/35] refactor(rs-platform-wallet): consume AddressInfos via dash-sdk re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the direct `drive-proof-verifier` path dep in favor of the `dash_sdk::query_types::AddressInfos` re-export that already exists at packages/rs-sdk/src/lib.rs:99 — keeps the wallet crate's dependency surface to dash-sdk + dpp, no extra proof-verifier hop. Addresses QuantumExplorer's review comment on #3671. --- Cargo.lock | 1 - packages/rs-platform-wallet/Cargo.toml | 1 - .../src/wallet/platform_addresses/fund_from_asset_lock.rs | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21e4db4e9cc..2119a766129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4822,7 +4822,6 @@ dependencies = [ "dash-spv", "dashcore", "dpp", - "drive-proof-verifier", "grovedb-commitment-tree", "hex", "image", diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 3559f7ac3db..846e736e94a 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -10,7 +10,6 @@ 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/wallet/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs index 98664f5f125..401ac139e23 100644 --- 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 @@ -33,10 +33,10 @@ 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 dash_sdk::query_types::AddressInfos; 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; From 2dbcc2a5692767356a355a863b073eb7ccf6acc7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 13:49:32 +0700 Subject: [PATCH 30/35] =?UTF-8?q?style:=20cargo=20fmt=20=E2=80=94=20alphab?= =?UTF-8?q?etize=20fund=5Ffrom=5Fasset=5Flock=20module=20declarations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top_up → fund_from_asset_lock rename left the module declarations out of alphabetical order. cargo fmt --check on the macOS CI runner caught the drift. --- packages/rs-platform-wallet-ffi/src/platform_addresses/mod.rs | 4 ++-- .../rs-platform-wallet/src/wallet/platform_addresses/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 d5e67aa5163..d0a3cd724fd 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 sync; mod fund_from_asset_lock; +mod sync; mod transfer; mod wallet; mod withdrawal; // Re-export all FFI types and functions. -pub use sync::*; pub use fund_from_asset_lock::*; +pub use sync::*; pub use transfer::*; pub use wallet::*; pub use withdrawal::*; 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 82547d671a3..d216228284a 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 fund_from_asset_lock; mod transfer; mod wallet; mod withdrawal; From 8d40d8ce34f7ecd393e107983fd1447f54c305af Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 14:13:16 +0700 Subject: [PATCH 31/35] test(dpp): signing tests for AddressFundingFromAssetLockTransition Mirrors the existing address_funds_transfer_transition signing_tests pattern: a TestAddressSigner for the per-input witness path, plus a FixedKeySigner (key_wallet::signer::Signer) for the with_signers path. Covers: - methods/mod.rs version dispatcher (via the outer enum) - v0/v0_methods.rs try_from_asset_lock_with_signer_and_private_key - v0/v0_methods.rs try_from_asset_lock_with_signers (Swift / HSM path, gated on core_key_wallet) Bumps codecov patch coverage above the 50% threshold on this PR. --- .../mod.rs | 2 + .../signing_tests.rs | 297 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs index 819d9e6bf97..6da2475c99e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/mod.rs @@ -4,6 +4,8 @@ mod fields; mod json_conversion; pub mod methods; mod proved; +#[cfg(all(test, feature = "state-transition-signing"))] +mod signing_tests; mod state_transition_estimated_fee_validation; mod state_transition_fee_strategy; mod state_transition_like; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs new file mode 100644 index 00000000000..67f95088409 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs @@ -0,0 +1,297 @@ +//! Signing tests for AddressFundingFromAssetLockTransition. +//! +//! Covers the two construction paths: +//! - `try_from_asset_lock_with_signer_and_private_key` — asset-lock-proof +//! signature produced from a raw private key held in-process. +//! - `try_from_asset_lock_with_signers` — asset-lock-proof signature +//! produced by an external `key_wallet::signer::Signer` (Swift / HSM +//! / hardware-wallet flow). Gated on `core_key_wallet`. +//! +//! These also exercise the outer-enum version dispatcher in +//! `methods/mod.rs`, which routes to the V0 impl based on the +//! `address_funding_from_asset_lock_transition` conversion version. + +use std::collections::{BTreeMap, HashMap}; + +use dashcore::hashes::Hash; +use dashcore::secp256k1::{PublicKey as RawPublicKey, Secp256k1, SecretKey as RawSecretKey}; +use dashcore::{OutPoint, PublicKey}; +use platform_value::BinaryData; +use platform_version::version::PlatformVersion; + +use crate::address_funds::{AddressFundsFeeStrategyStep, AddressWitness, PlatformAddress}; +use crate::identity::signer::Signer; +use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use crate::prelude::AssetLockProof; +use crate::serialization::Signable; +use crate::state_transition::address_funding_from_asset_lock_transition::methods::AddressFundingFromAssetLockTransitionMethodsV0; +use crate::state_transition::address_funding_from_asset_lock_transition::v0::AddressFundingFromAssetLockTransitionV0; +use crate::state_transition::address_funding_from_asset_lock_transition::AddressFundingFromAssetLockTransition; +use crate::state_transition::StateTransition; +use crate::ProtocolError; + +/// Per-input P2PKH signer for tests. +#[derive(Debug, Default)] +struct TestAddressSigner { + keys: HashMap<[u8; 20], (RawSecretKey, PublicKey)>, +} + +impl TestAddressSigner { + fn add_p2pkh(&mut self, seed: [u8; 32]) -> PlatformAddress { + let secp = Secp256k1::new(); + let secret = RawSecretKey::from_byte_array(&seed).expect("valid secret key"); + let public = PublicKey::new(RawPublicKey::from_secret_key(&secp, &secret)); + let hash = *public.pubkey_hash().as_byte_array(); + self.keys.insert(hash, (secret, public)); + PlatformAddress::P2pkh(hash) + } +} + +#[async_trait::async_trait] +impl Signer for TestAddressSigner { + async fn sign(&self, key: &PlatformAddress, data: &[u8]) -> Result { + let PlatformAddress::P2pkh(hash) = key else { + return Err(ProtocolError::Generic( + "only P2PKH supported in tests".into(), + )); + }; + let (secret, _) = self + .keys + .get(hash) + .ok_or_else(|| ProtocolError::Generic(format!("unknown key {}", hex::encode(hash))))?; + let sig = dashcore::signer::sign(data, secret.as_ref()) + .map_err(|e| ProtocolError::Generic(e.to_string()))?; + Ok(BinaryData::new(sig.to_vec())) + } + + async fn sign_create_witness( + &self, + key: &PlatformAddress, + data: &[u8], + ) -> Result { + let signature = self.sign(key, data).await?; + Ok(AddressWitness::P2pkh { signature }) + } + + fn can_sign_with(&self, key: &PlatformAddress) -> bool { + match key { + PlatformAddress::P2pkh(hash) => self.keys.contains_key(hash), + _ => false, + } + } +} + +fn make_chain_asset_lock_proof() -> AssetLockProof { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point: OutPoint::from([11u8; 36]), + }) +} + +fn extract_v0(state_transition: StateTransition) -> AddressFundingFromAssetLockTransitionV0 { + let StateTransition::AddressFundingFromAssetLock(AddressFundingFromAssetLockTransition::V0(v0)) = + state_transition + else { + panic!("expected AddressFundingFromAssetLock V0 variant"); + }; + v0 +} + +#[tokio::test] +async fn try_from_asset_lock_with_signer_and_private_key_signs_single_p2pkh_input() { + let mut signer = TestAddressSigner::default(); + let input_addr = signer.add_p2pkh([1u8; 32]); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_addr, (0u32, 1_000_000u64)); + + let mut outputs = BTreeMap::new(); + outputs.insert(PlatformAddress::P2pkh([9u8; 20]), None); + + let asset_lock_private_key = [7u8; 32]; + + // Drive the outer-enum dispatcher in `methods/mod.rs`, which routes + // to the V0 impl in `v0/v0_methods.rs`. + let st = + AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signer_and_private_key( + make_chain_asset_lock_proof(), + &asset_lock_private_key, + inputs.clone(), + outputs, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + PlatformVersion::latest(), + ) + .await + .expect("transition should sign"); + + let v0 = extract_v0(st); + assert_eq!(v0.inputs, inputs); + assert_eq!(v0.input_witnesses.len(), 1); + assert!(matches!( + v0.input_witnesses[0], + AddressWitness::P2pkh { .. } + )); + assert_eq!( + v0.signature.len(), + 65, + "asset-lock signature must be 65-byte recoverable compact", + ); + + // The per-input witness must verify against the transition's + // signable bytes, which is the contract `sign_create_witness` + // promises. + let signable = StateTransition::from(v0.clone()) + .signable_bytes() + .expect("signable_bytes"); + let input_addr = v0.inputs.keys().next().expect("one input"); + input_addr + .verify_bytes_against_witness(&v0.input_witnesses[0], &signable) + .expect("witness should verify against signable bytes"); +} + +#[tokio::test] +async fn try_from_asset_lock_with_signer_and_private_key_signs_multiple_inputs() { + let mut signer = TestAddressSigner::default(); + let a = signer.add_p2pkh([1u8; 32]); + let b = signer.add_p2pkh([2u8; 32]); + let c = signer.add_p2pkh([3u8; 32]); + + let mut inputs = BTreeMap::new(); + inputs.insert(a, (0u32, 500_000u64)); + inputs.insert(b, (0u32, 300_000u64)); + inputs.insert(c, (0u32, 200_000u64)); + + let mut outputs = BTreeMap::new(); + outputs.insert(PlatformAddress::P2pkh([9u8; 20]), None); + + let st = + AddressFundingFromAssetLockTransitionV0::try_from_asset_lock_with_signer_and_private_key( + make_chain_asset_lock_proof(), + &[7u8; 32], + inputs.clone(), + outputs, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + PlatformVersion::latest(), + ) + .await + .expect("transition should sign"); + + let v0 = extract_v0(st); + + // One witness per input, in input-iteration order. BTreeMap iteration + // is stable, so witness[i] must verify against inputs.keys().nth(i). + assert_eq!(v0.input_witnesses.len(), 3); + let signable = StateTransition::from(v0.clone()) + .signable_bytes() + .expect("signable_bytes"); + for (addr, witness) in v0.inputs.keys().zip(v0.input_witnesses.iter()) { + addr.verify_bytes_against_witness(witness, &signable) + .expect("witness should verify against signable bytes"); + } +} + +// ---------------------------------------------------------------------------- +// `try_from_asset_lock_with_signers` — external `key_wallet::signer::Signer` +// path (Swift / hardware wallet / HSM). Gated on `core_key_wallet`. +// ---------------------------------------------------------------------------- + +#[cfg(feature = "core_key_wallet")] +#[tokio::test] +async fn try_from_asset_lock_with_signers_produces_matching_signature() { + use async_trait::async_trait; + use dashcore::secp256k1::{ecdsa, Message}; + use key_wallet::bip32::DerivationPath; + use key_wallet::signer::{Signer as KwSigner, SignerMethod}; + + /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how the + /// Swift KeychainSigner behaves: derive once, sign atomically. Path + /// is ignored — the wrapper holds exactly one key. + #[derive(Debug)] + struct FixedKeySigner { + secret: RawSecretKey, + public: RawPublicKey, + } + + #[async_trait] + impl KwSigner for FixedKeySigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, RawPublicKey), Self::Error> { + let secp = Secp256k1::new(); + let msg = Message::from_digest(sighash); + Ok((secp.sign_ecdsa(&msg, &self.secret), self.public)) + } + + async fn public_key(&self, _path: &DerivationPath) -> Result { + Ok(self.public) + } + } + + let secp = Secp256k1::new(); + let asset_lock_secret = RawSecretKey::from_byte_array(&[7u8; 32]).expect("valid secret"); + let asset_lock_public = RawPublicKey::from_secret_key(&secp, &asset_lock_secret); + + let mut input_signer = TestAddressSigner::default(); + let input_addr = input_signer.add_p2pkh([1u8; 32]); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_addr, (0u32, 1_000_000u64)); + + let mut outputs = BTreeMap::new(); + outputs.insert(PlatformAddress::P2pkh([9u8; 20]), None); + + let asset_lock_signer = FixedKeySigner { + secret: asset_lock_secret, + public: asset_lock_public, + }; + let path = DerivationPath::default(); + + let st_signers = AddressFundingFromAssetLockTransition::try_from_asset_lock_with_signers( + make_chain_asset_lock_proof(), + &path, + inputs.clone(), + outputs.clone(), + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &input_signer, + &asset_lock_signer, + 0, + PlatformVersion::latest(), + ) + .await + .expect("with_signers should sign"); + + // Cross-check: the byte-parity test in `state_transition::mod` pins + // that `sign_with_core_signer` produces a byte-identical asset-lock + // signature to `sign_by_private_key` for the same key. Here we + // exercise the address-funding-specific path and verify the same + // 65-byte recoverable-compact shape. + let v0 = extract_v0(st_signers); + assert_eq!(v0.input_witnesses.len(), 1); + assert_eq!( + v0.signature.len(), + 65, + "asset-lock signature must be 65-byte recoverable compact", + ); + + // And the per-input witness must verify the same way as the + // legacy path. + let signable = StateTransition::from(v0.clone()) + .signable_bytes() + .expect("signable_bytes"); + let input_addr = v0.inputs.keys().next().expect("one input"); + input_addr + .verify_bytes_against_witness(&v0.input_witnesses[0], &signable) + .expect("witness should verify against signable bytes"); +} From 9d8d3e47980f3b2626e2e5ba51100b2726171883 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 14:49:57 +0700 Subject: [PATCH 32/35] fix(platform-wallet): reject partial-proof results in fund_from_asset_lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-submit guard at v3.1 #3671 only checked the wholly-empty `address_infos.is_empty()` case. When the proof returned `Some(addr) -> None` or omitted a recipient entirely, `write_address_balances_changeset` logged and continued past those entries while `consume_asset_lock` ran anyway — terminally destroying the only resumable record for the L1 funding outpoint while one or more recipients silently lost credits. The function's own comment already characterised the case as a 'DAPI / proof- verifier contract violation, NOT a successful zero-credit funding'; the guard just didn't enforce it. Extract the post-condition into a pure helper `validate_address_infos_complete` and extend it to reject both `Some(addr) -> None` entries and recipients absent from the map. Test would have caught this in CI: - against the previous empty-only logic, the two partial-case tests fail (`rejects_recipient_with_none_address_info`, `rejects_recipient_absent_from_address_infos`). - after the fix all 4 tests pass. Verified locally by reverting the partial-case branch in `validate_address_infos_complete` and re-running the suite: 2 failed, 2 passed (RED); restoring the branch: 4 passed (GREEN). Closes review thread thepastaclaw finding 765f8b73c0a4 / 864a12a85aae. --- .../fund_from_asset_lock.rs | 151 ++++++++++++++++-- 1 file changed, 135 insertions(+), 16 deletions(-) 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 index 401ac139e23..763b62c3e11 100644 --- 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 @@ -263,22 +263,14 @@ impl PlatformAddressWallet { // 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 - ))); - } + // the proof-attested `address_infos` with a `Some(_)` info. + // A wholly-empty map, an absent recipient, or a + // `Some(addr) -> None` entry would each 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 for some + // or all recipients. + validate_address_infos_complete(&addresses, &address_infos)?; let cs = self .write_address_balances_changeset(platform_account_index, &address_infos) @@ -464,3 +456,130 @@ async fn validate_recipient_addresses( } Ok(()) } + +/// Post-submit guard: confirm the proof-attested `address_infos` carry a +/// usable `AddressInfo` for every requested recipient. +/// +/// A wholly-empty map, an entry whose value is `None`, or a recipient +/// not present in the map at all are each a DAPI / proof-verifier +/// contract violation — Platform accepted the transition, but the +/// proof omits credits for the caller's recipients. Returning `Ok` +/// here would let `consume_asset_lock` terminally destroy the only +/// resumable record for the L1 funding outpoint while one or more +/// recipients silently lose credits, which (since asset locks are +/// non-refundable) is permanent value loss. +fn validate_address_infos_complete( + addresses: &BTreeMap>, + address_infos: &AddressInfos, +) -> Result<(), PlatformWalletError> { + 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 missing_recipients: Vec = addresses + .keys() + .filter_map(|address| match address_infos.get(address) { + Some(Some(_)) => None, + _ => Some(format!("{address:?}")), + }) + .collect(); + + if !missing_recipients.is_empty() { + return Err(PlatformWalletError::AddressSync(format!( + "Address-funding ST succeeded but the proof omitted usable AddressInfo \ + for recipient(s): {}", + missing_recipients.join(", ") + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::query_types::AddressInfo; + + fn p2pkh(b: u8) -> PlatformAddress { + PlatformAddress::P2pkh([b; 20]) + } + + fn info(addr: PlatformAddress) -> AddressInfo { + AddressInfo { + address: addr, + balance: 1_000, + nonce: 0, + } + } + + #[test] + fn rejects_empty_address_infos_for_non_empty_recipients() { + let mut addresses = BTreeMap::new(); + addresses.insert(p2pkh(1), None); + let address_infos: AddressInfos = AddressInfos::new(); + let err = validate_address_infos_complete(&addresses, &address_infos) + .expect_err("empty address_infos must be rejected"); + let msg = format!("{}", err); + assert!( + msg.contains("returned no address infos"), + "unexpected error: {msg}" + ); + } + + #[test] + fn rejects_recipient_with_none_address_info() { + // The proof contract violation we actually hit in practice: + // proof carries the recipient key but with a `None` value. + // Pre-fix, this slipped past the inline `is_empty()` guard + // and the asset lock got consumed regardless. + let mut addresses = BTreeMap::new(); + addresses.insert(p2pkh(1), None); + let mut address_infos: AddressInfos = AddressInfos::new(); + address_infos.insert(p2pkh(1), None); + let err = validate_address_infos_complete(&addresses, &address_infos) + .expect_err("None info must be rejected"); + let msg = format!("{}", err); + assert!( + msg.contains("omitted usable AddressInfo"), + "unexpected: {msg}" + ); + } + + #[test] + fn rejects_recipient_absent_from_address_infos() { + // Multi-recipient case: proof present for one address but + // missing the other entirely. Without the per-recipient + // check, this would also silently consume the lock with + // partial crediting. + let mut addresses = BTreeMap::new(); + addresses.insert(p2pkh(1), Some(500)); + addresses.insert(p2pkh(2), None); + let mut address_infos: AddressInfos = AddressInfos::new(); + address_infos.insert(p2pkh(1), Some(info(p2pkh(1)))); + let err = validate_address_infos_complete(&addresses, &address_infos) + .expect_err("missing recipient must be rejected"); + let msg = format!("{}", err); + assert!( + msg.contains("omitted usable AddressInfo"), + "unexpected: {msg}" + ); + } + + #[test] + fn accepts_every_recipient_with_some_address_info() { + let mut addresses = BTreeMap::new(); + addresses.insert(p2pkh(1), Some(500)); + addresses.insert(p2pkh(2), None); + let mut address_infos: AddressInfos = AddressInfos::new(); + address_infos.insert(p2pkh(1), Some(info(p2pkh(1)))); + address_infos.insert(p2pkh(2), Some(info(p2pkh(2)))); + validate_address_infos_complete(&addresses, &address_infos) + .expect("complete proof must pass"); + } +} From f2875fdf1ddf8ed5e3b3fc3656f03a770d16b931 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 14:51:29 +0700 Subject: [PATCH 33/35] fix(platform-wallet): persist balances after fund_from_asset_lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `write_address_balances_changeset` mutates `ManagedPlatformAccount` in memory, but the flow returned `Ok(cs)` without ever calling `self.persister.store(cs.clone().into())`. Siblings `transfer.rs` and `sync.rs` both persist their non-empty balance changesets with the exact same justification: without it, persisted rows stay frozen at pre-mutation values until the next BLAST sync overwrites them, and on the next process start `initialize_from_persisted` seeds `account.address_credit_balance` from those stale rows. Skipping the persist is especially load-bearing here: the asset-lock record gets marked `Consumed` immediately after, so a restart would leave a Consumed lock paired with a stale balance row — `auto_select_inputs` would under-budget and Platform would deterministically reject the next ST until a sync repairs the rows. Mirror the existing transfer.rs:155-159 pattern (persist before `consume_asset_lock`, log-on-error so a persistence hiccup doesn't mask Platform-side success). No unit test: the orchestration involves the SDK submit + proof verification path, which is not unit-testable without significant mock infrastructure. The integration coverage lives in drive-abci strategy tests, and the persist call itself mirrors a sibling pattern verbatim — change is mechanical. Closes review thread thepastaclaw finding 0f13aaa45967. --- .../fund_from_asset_lock.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 index 763b62c3e11..347152ff8a4 100644 --- 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 @@ -25,6 +25,7 @@ //! tracked outpoint so the row is marked `Consumed` (terminal) //! and dropped from the in-memory tracked-lock map. +use crate::changeset::Merge; use crate::wallet::asset_lock::orchestration::{ out_point_from_proof, submit_with_cl_height_retry, AssetLockFunding, FundingResolution, ResolvedFunding, CL_FALLBACK_TIMEOUT, @@ -276,6 +277,36 @@ impl PlatformAddressWallet { .write_address_balances_changeset(platform_account_index, &address_infos) .await; + // Mirror `transfer.rs` / `sync.rs`: push the post-submit + // balances through the persister so any external store stays + // in sync with the in-memory account state we just updated. + // Without this, persisted rows for these recipients stay + // frozen at pre-top-up values until the next BLAST sync + // overwrites them. On the next process start before that + // sync, `initialize_from_persisted` would seed + // `account.address_credit_balance` from the stale rows while + // the asset-lock record is already `Consumed` — leaving + // `auto_select_inputs` to under-budget and produce + // protocol-level rejections until a sync repairs them. + // + // The persist MUST happen before `consume_asset_lock` so + // we never have a Consumed lock paired with a stale balance + // row on disk. + // + // Log-on-error rather than propagate: Platform already + // accepted the transition, and a persistence hiccup shouldn't + // mask that. A subsequent sync reconciles. + if !cs.is_empty() { + if let Err(e) = self.persister.store(cs.clone().into()) { + tracing::error!( + error = %e, + "Failed to persist fund-from-asset-lock changeset; \ + in-memory balances are updated but durable rows are stale \ + until the next BLAST sync" + ); + } + } + 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 From d8c3ce74058528ac7e12a2d15582b943d88a47b4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 14:51:51 +0700 Subject: [PATCH 34/35] fix(swift-sdk): preflight addressType in fund_from_asset_lock recipients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swift `FundFromAssetLockRecipient` type documents `0 = P2PKH, 1 = P2SH`, but the Rust FFI only accepts `address_type == 0` (see `packages/rs-platform-wallet-ffi/src/platform_address_types.rs`). The pre-flight at `fundFromAssetLockPreflight` validated only `recipients.isEmpty`, the single-remainder invariant, and `hash.count == 20` — never the addressType discriminant. A caller following the documented API and passing `addressType: 1` would clear the synchronous preflight, pay for the `Task.detached` setup + signer pinning, and only then receive a generic FFI error, defeating the preflight's stated purpose. Both `fundFromAssetLock` and `resumeFundFromAssetLock` share the preflight, so this also fixes the resume entry point. No unit test: Swift SDK lacks an established XCTest harness for preflight validation in this repo, and the change is a one-line guard. Swift SDK build CI exercises the compile path. Closes review thread thepastaclaw finding fc557c547a45 / 3f5db8de992b. --- .../ManagedPlatformAddressWallet.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 7286ff528fe..ef8ad96ea0b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -604,6 +604,18 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { ) } for r in recipients { + // The Rust FFI accepts only addressType == 0 (P2PKH) — see + // `impl TryFrom for PlatformAddress` in + // packages/rs-platform-wallet-ffi/src/platform_address_types.rs. + // Catch the P2SH discriminant the type signature still + // documents so the caller gets a synchronous, type-specific + // error instead of paying for the Task detach + signer pin + // and then receiving a generic FFI failure. + guard r.addressType == 0 else { + throw PlatformWalletError.invalidParameter( + "FundFromAssetLockRecipient.addressType must be 0 (P2PKH) for platform-address funding (got \(r.addressType))" + ) + } guard r.hash.count == 20 else { throw PlatformWalletError.invalidParameter( "FundFromAssetLockRecipient.hash must be exactly 20 bytes (got \(r.hash.count))" From 3619d2daacac7caec4463b45890b7a6fa180f6c2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 15:18:34 +0700 Subject: [PATCH 35/35] fix(platform-wallet): look up address_index before mutating in-memory balance The pool-lookup at `write_address_balances_changeset` came AFTER `set_address_credit_balance`, so a pool miss (the documented "pool mutated between preflight and post-submit" race) hit `continue` with the in-memory balance already updated but no entry pushed onto `cs.addresses`. The persist-before-consume flow added in f2875fdf1d would then store a changeset missing this recipient, leaving exactly the in-memory vs durable divergence that fix was meant to eliminate. Reorder: pool lookup runs first; if it misses, `continue` runs before any in-memory mutation, so the recipient is dropped consistently from both stores until the next BLAST sync. No unit test: the in-memory mutation site is behind the wallet manager's write guard and not unit-testable without the orchestration scaffolding; the reorder is verifiable by inspection (lookup precedes mutation; `continue` arm cannot strand state). Closes review thread thepastaclaw finding e16c047d7323. --- .../wallet/platform_addresses/fund_from_asset_lock.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 index 347152ff8a4..c84478589e1 100644 --- 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 @@ -393,13 +393,21 @@ impl PlatformAddressWallet { 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. + // + // Look this up BEFORE mutating the in-memory + // balance: if we mutated first and then `continue`d + // on a pool miss, the changeset would be missing + // this recipient's entry while the in-memory state + // already carried the new balance — defeating the + // persist-before-consume invariant the caller + // relies on (the persisted row would stay stale + // while the asset lock is consumed regardless). let Some(address_index) = account .addresses @@ -418,6 +426,7 @@ impl PlatformAddressWallet { ); continue; }; + account.set_address_credit_balance(p2pkh, funds.balance, key_source.as_ref()); cs.addresses.push(crate::PlatformAddressBalanceEntry { wallet_id: self.wallet_id, account_index: platform_account_index,