diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index d1aa778b140..b9b15168467 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -1060,7 +1060,7 @@ "sdk": "not-applicable", "example_app": "not-applicable", "restart": "not_applicable", - "reason": "Android derives contact payment attribution from transaction history on reads and does not consume PaymentEntry rows (confirmed by the Android team during the sent-payment reconstruction review), so the JNI vtable deliberately leaves the slot None and there is nothing to persist or restore on this host." + "reason": null } }, "verification": [ diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index a63ff74953d..300a34c375c 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **25** +Capabilities tracked: **26** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 15 | 8 | 1 | 1 | -| Swift | Example app | 4 | 12 | 1 | 8 | -| Kotlin | SDK | 13 | 12 | 0 | 0 | -| Kotlin | Example app | 5 | 12 | 0 | 8 | +| Swift | SDK | 15 | 9 | 1 | 1 | +| Swift | Example app | 4 | 12 | 1 | 9 | +| Kotlin | SDK | 13 | 12 | 0 | 1 | +| Kotlin | Example app | 5 | 12 | 0 | 9 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 0 | 7 | 18 | -| Kotlin | 4 | 6 | 15 | +| Swift | 0 | 8 | 18 | +| Kotlin | 4 | 6 | 16 | ## Capability status @@ -37,6 +37,7 @@ Capabilities tracked: **25** | `core.compact_filter_rescan` | supported / partial / not_applicable | partial / partial / not_applicable | | `dpns.contested_names_by_identity` | supported / partial / not_applicable | partial / partial / required | | `persistence.sync_fault_latch` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | +| `persistence.dashpay_payment_history` | partial / not-applicable / required | not-applicable / not-applicable / not_applicable | | `network.masternode_discovery` | partial / not-applicable / not_applicable | partial / not-applicable / not_applicable | | `identity.platform_address_auto_funding` | partial / partial / not_applicable | partial / partial / not_applicable | | `tokens.authorization_decisions` | partial / partial / not_applicable | partial / partial / not_applicable | diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 789995526a7..c049c2d4339 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1030,6 +1030,125 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( PlatformWalletFFIResult::ok() } +/// Fund the shielded pool by DRAINING the wallet's CoinJoin account +/// (`m/9'/coinType'/4'/account_index'`) into a single asset lock. +/// +/// Sister to [`platform_wallet_manager_shielded_fund_from_asset_lock`], +/// with two differences: +/// +/// 1. **Funding**: instead of coin-selecting an exact amount from a BIP44 +/// account, every final CoinJoin UTXO is consumed and the lock value is +/// `Σ inputs − L1 fee`, computed by the builder. There is no amount +/// parameter, and the mixed coins never hop through a transparent BIP44 +/// address — this is the CoinJoin → Shielded migration path. +/// 2. **No surplus output**: the single-recipient remainder flow pins the +/// consensus surplus to zero (see the resume sibling's doc), so the +/// parameter is omitted rather than plumbed. +/// +/// The recipient receives `lock_value − pool_fee` credits. A stuck lock is +/// resumable via +/// [`platform_wallet_manager_shielded_resume_fund_from_asset_lock`] exactly +/// like a BIP44-funded one. The preflight rejects a drain whose balance +/// could not clear the Type 18 pool fee, so an unrecoverable dust lock is +/// never broadcast. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `recipient_raw_43` must point to 43 readable bytes (raw Orchard +/// payment address: 11-byte diversifier + 32-byte pk_d). +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// `dash_sdk_mnemonic_resolver_create`. The caller retains ownership. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain( + handle: Handle, + wallet_id_bytes: *const u8, + account_index: u32, + recipient_raw_43: *const u8, + core_signer_handle: *mut MnemonicResolverHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(recipient_raw_43); + check_ptr!(core_signer_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let mut recipient_bytes = [0u8; 43]; + std::ptr::copy_nonoverlapping(recipient_raw_43, recipient_bytes.as_mut_ptr(), 43); + let recipient = match OrchardAddress::from_raw_bytes(&recipient_bytes) { + Ok(a) => a, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid Orchard recipient address: {e}"), + ); + } + }; + + // The Type 18 live activity recorder writes to the coordinator's + // shared in-memory store, so resolve the coordinator alongside the + // wallet (same as the BIP44-funded sibling). + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + let network = wallet.network(); + + // Round-trip the resolver handle through `usize` so the worker + // future's capture is `Send + 'static`. + let core_signer_addr = core_signer_handle as usize; + + // Run the proof on a worker thread (8 MB stack) — see the sibling for + // why the Halo 2 synthesis cannot run on the calling thread. + let result = block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the resolver handle + // is pinned alive for the duration of this FFI call. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let prover = CachedOrchardProver::new(); + wallet + .shielded_fund_from_asset_lock( + &coordinator, + AssetLockFunding::DrainAccountBalance { + account: + key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingAccount::CoinJoin { + account_index, + }, + // The shielded fund flow stamps the authoritative + // pool-fee floor before resolving the funding. + minimum_lock_duffs: None, + }, + vec![(recipient, None)], + &asset_lock_signer, + &prover, + // Single-recipient remainder flow: surplus is structurally + // zero, so no surplus output. + None, + // Single real note, no anonymity-set fillers. + 0, + None, + // User-facing funding: wait for the ChainLock indefinitely — + // a broadcast asset lock is pending finality, never failed. + None, + ) + .await + }); + match result { + Ok(()) => PlatformWalletFFIResult::ok(), + // Typed conversion — preserves the broadcast-outcome distinction + // (ErrorTransactionBroadcastUnconfirmed vs ...Rejected) so the host + // can choose resume/do-not-redrain for a possibly-broadcast + // whole-account lock vs safely retrying a rejected build. + Err(e) => e.into(), + } +} + /// Resume a shielded fund-from-asset-lock by outpoint. /// /// Sister to [`platform_wallet_manager_shielded_fund_from_asset_lock`]: diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 108172420fb..1fe80310c2a 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -30,6 +30,28 @@ use super::tracked::{AssetLockStatus, TrackedAssetLock}; // Asset lock transaction building // --------------------------------------------------------------------------- +/// Amount semantics of a funded asset-lock build. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetLockBuildAmount { + /// Lock exactly this many duffs; funding UTXOs are coin-selected and + /// change returns to the funding account. + Exact(u64), + /// Drain the funding account: every final UTXO is consumed and the + /// lock value is `Σ inputs − fee`, computed by the key-wallet builder + /// (see `build_asset_lock_with_signer`'s drain mode). Required for + /// CoinJoin funding, whose accounts have no change semantics. + DrainAll { + /// Authoritative floor on the drained lock value, checked against + /// the BUILT payload before anything is tracked or broadcast — the + /// only sound place to enforce it, since the drained value is + /// unknowable beforehand (a pre-build balance estimate races + /// concurrent reservations and coin-selection filters). An + /// undersized build is abandoned with an owner-guarded reservation + /// release and nothing reaches the wire. `None` skips the check. + minimum_lock_duffs: Option, + }, +} + impl AssetLockManager { /// Build an asset lock transaction using the key-wallet builder. /// @@ -39,6 +61,10 @@ impl AssetLockManager { /// `DerivationPath` is what the caller hands back to the same /// `signer` when the credit output is later consumed on Platform. /// + /// Exact-amount BIP44 form — the historical entry point; the + /// funding-parameterized form is + /// [`Self::build_asset_lock_transaction_with_funding`]. + /// /// # Arguments /// /// * `amount_duffs` — Amount to lock in duffs. @@ -61,7 +87,49 @@ impl AssetLockManager { identity_index: u32, signer: &S, ) -> Result<(Transaction, DerivationPath), PlatformWalletError> { - if amount_duffs == 0 { + self.build_asset_lock_transaction_with_funding( + AssetLockBuildAmount::Exact(amount_duffs), + AssetLockFundingAccount::Bip44 { account_index }, + funding_type, + identity_index, + signer, + ) + .await + // Historical callers never had the reservation token; the funded + // pipeline (`broadcast_funded_asset_lock_with_funding`) threads it. + .map(|(tx, path, _token)| (tx, path)) + } + + /// Funding-parameterized form of [`Self::build_asset_lock_transaction`]: + /// `funding_account` picks the account family supplying (and signing) + /// the funding UTXOs, and `amount` picks exact-amount vs whole-balance + /// drain semantics (see [`AssetLockBuildAmount`]). CoinJoin funding is + /// drain-only — the key-wallet builder rejects a non-drain CoinJoin + /// build. + pub async fn build_asset_lock_transaction_with_funding( + &self, + amount: AssetLockBuildAmount, + funding_account: AssetLockFundingAccount, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + ) -> Result< + ( + Transaction, + DerivationPath, + Option, + ), + PlatformWalletError, + > { + let (amount_duffs, drain) = match amount { + AssetLockBuildAmount::Exact(v) => (v, false), + // The credit-output value is a placeholder — the key-wallet + // drain build rewrites it to Σ inputs − fee. The minimum is + // enforced by `broadcast_funded_asset_lock_with_funding` + // against the built payload. + AssetLockBuildAmount::DrainAll { .. } => (0, true), + }; + if amount_duffs == 0 && !drain { return Err(PlatformWalletError::AssetLockTransaction( "Amount must be greater than zero".to_string(), )); @@ -106,18 +174,17 @@ impl AssetLockManager { identity_index, }; - // 3. Delegate to the key-wallet signer-driven builder. Platform - // asset locks fund from the standard BIP44 account and never - // drain; upstream only supports non-drain funding for BIP44 - // (CoinJoin funding is drain-only). + // 3. Delegate to the key-wallet signer-driven builder with the + // caller's funding account + drain semantics (the key-wallet side + // enforces that CoinJoin funding is drain-only). let result = info .core_wallet .build_asset_lock_with_signer( wallet, - AssetLockFundingAccount::Bip44 { account_index }, + funding_account, vec![funding], DEFAULT_FEE_PER_KB, - false, + drain, signer, ) .await @@ -151,7 +218,7 @@ impl AssetLockManager { } }; - Ok((result.transaction, path)) + Ok((result.transaction, path, result.reservation_token)) } /// Peek at the next unused address from a funding account without @@ -578,18 +645,39 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + self.create_funded_asset_lock_proof_with_funding( + AssetLockBuildAmount::Exact(amount_duffs), + AssetLockFundingAccount::Bip44 { account_index }, + funding_type, + identity_index, + signer, + ) + .await + } + + /// Funding-parameterized form of [`Self::create_funded_asset_lock_proof`] + /// — same build → broadcast → proof pipeline with the account family and + /// amount semantics of [`Self::build_asset_lock_transaction_with_funding`]. + pub async fn create_funded_asset_lock_proof_with_funding( + &self, + amount: AssetLockBuildAmount, + funding_account: AssetLockFundingAccount, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { let (path, out_point) = self - .broadcast_funded_asset_lock( - amount_duffs, - account_index, + .broadcast_funded_asset_lock_with_funding( + amount, + funding_account, funding_type, identity_index, signer, ) .await?; let proof = self - .wait_for_funded_asset_lock_proof(&out_point, account_index) + .wait_for_funded_asset_lock_proof(&out_point, funding_account.account_index()) .await?; Ok((proof, path, out_point)) } @@ -609,6 +697,25 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, + ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { + self.broadcast_funded_asset_lock_with_funding( + AssetLockBuildAmount::Exact(amount_duffs), + AssetLockFundingAccount::Bip44 { account_index }, + funding_type, + identity_index, + signer, + ) + .await + } + + /// Funding-parameterized form of [`Self::broadcast_funded_asset_lock`]. + pub(crate) async fn broadcast_funded_asset_lock_with_funding( + &self, + amount: AssetLockBuildAmount, + funding_account: AssetLockFundingAccount, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { // Serialize build→persist so a concurrent build cannot interleave its // pool snapshot with ours. The snapshot is collected from live wallet @@ -640,10 +747,10 @@ impl AssetLockManager { let build_persist_guard = self.build_persist_serial.lock().await; // 1. Build the asset lock transaction. - let (tx, path) = self - .build_asset_lock_transaction( - amount_duffs, - account_index, + let (tx, path, reservation_token) = self + .build_asset_lock_transaction_with_funding( + amount, + funding_account, funding_type, identity_index, signer, @@ -653,6 +760,58 @@ impl AssetLockManager { let txid = tx.txid(); let out_point = OutPoint::new(txid, 0); + // The tracked/logged amount is read back from the built payload — + // for `Exact` it equals the requested value; for `DrainAll` the + // builder computed it (Σ inputs − fee) and this is the only place + // it is known. + let locked_amount_duffs: u64 = match &tx.special_transaction_payload { + Some( + dashcore::blockdata::transaction::special_transaction::TransactionPayload::AssetLockPayloadType(p), + ) => p.credit_outputs.iter().map(|o| o.value).sum(), + _ => 0, + }; + + // Authoritative drain floor: judged on the BUILT payload, before the + // lock is tracked or broadcast. An undersized drain (its consumers + // derive `shield_amount = lock_value − pool_fee`, so a lock at or + // below the fee is unconsumable) is abandoned: owner-guarded + // reservation release (the build `.await`ed, so the reservation may + // have been swept and re-owned) and no transaction reaches the wire. + // The funding key index consumed by the build is the same residue any + // discarded build leaves and is reclaimed by the gap-limit scan. + if let AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(minimum), + } = amount + { + if locked_amount_duffs < minimum { + drop(build_persist_guard); + let reserved_account = match funding_account { + AssetLockFundingAccount::Bip44 { account_index } => { + crate::wallet::reservations::ReservedFundingAccount::Standard( + key_wallet::account::account_type::StandardAccountType::BIP44Account, + account_index, + ) + } + AssetLockFundingAccount::CoinJoin { account_index } => { + crate::wallet::reservations::ReservedFundingAccount::CoinJoin(account_index) + } + }; + crate::wallet::reservations::release_reservation_after_rejected_broadcast( + &self.wallet_manager, + &self.wallet_id, + reserved_account, + &tx, + reservation_token, + ) + .await; + return Err(PlatformWalletError::AssetLockTransaction(format!( + "drained asset lock of {locked_amount_duffs} duffs is below the required \ + minimum of {minimum} duffs (the balance cannot clear the shield pool fee); \ + nothing was broadcast" + ))); + } + } + // Persist the funding account's address pool now that the build marked // its index used. These asset-lock accounts fund OP_RETURN-payload // credit outputs that never appear as on-chain UTXOs, so SPV can never @@ -693,10 +852,10 @@ impl AssetLockManager { .track_asset_lock(TrackedAssetLock { out_point, transaction: tx.clone(), - account_index, + account_index: funding_account.account_index(), funding_type, identity_index, - amount: amount_duffs, + amount: locked_amount_duffs, status: AssetLockStatus::Built, proof: None, }) @@ -730,12 +889,25 @@ impl AssetLockManager { let removed_built_row = cs_untrack.removed.contains(&out_point); self.queue_asset_lock_changeset(cs_untrack); if removed_built_row { + let reserved_account = match funding_account { + AssetLockFundingAccount::Bip44 { + account_index, + } => crate::wallet::reservations::ReservedFundingAccount::Standard( + key_wallet::account::account_type::StandardAccountType::BIP44Account, + account_index, + ), + AssetLockFundingAccount::CoinJoin { + account_index, + } => crate::wallet::reservations::ReservedFundingAccount::CoinJoin( + account_index, + ), + }; crate::wallet::reservations::release_reservation_after_rejected_broadcast( &self.wallet_manager, &self.wallet_id, - key_wallet::account::account_type::StandardAccountType::BIP44Account, - account_index, + reserved_account, &tx, + reservation_token, ) .await; } @@ -872,6 +1044,133 @@ mod tests { } } + /// Broadcaster that succeeds and counts its calls, so a test can assert + /// an abandoned build never reached the wire. + #[derive(Default)] + struct CountingOkBroadcaster { + calls: std::sync::atomic::AtomicUsize, + } + + impl CountingOkBroadcaster { + fn calls(&self) -> usize { + self.calls.load(std::sync::atomic::Ordering::SeqCst) + } + } + + #[async_trait] + impl TransactionBroadcaster for CountingOkBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(transaction.txid()) + } + } + + /// Builds an `AssetLockManager` over the CoinJoin-funded fixture + /// (CoinJoin account 0 holds a single 10_000_000-duff spendable UTXO). + async fn coinjoin_funded_asset_lock_manager( + broadcaster: Arc, + ) -> ( + Arc>, + crate::test_support::WalletSigner, + Arc, + ) { + let persistence = Arc::new(CapturingPersistence::default()); + let (wallet_manager, wallet_id, _generation, signer) = + crate::test_support::funded_coinjoin_wallet_manager().await; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(AssetLockManager::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + broadcaster, + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + )); + + (manager, signer, persistence) + } + + /// An undersized drain is abandoned BEFORE tracking or broadcast — the + /// floor is judged on the BUILT payload (the fixture's 10_000_000-duff + /// CoinJoin balance minus L1 fee), nothing reaches the wire, no row is + /// tracked, and the owner-guarded reservation release frees the inputs + /// so an immediate follow-up drain over the SAME single-UTXO account + /// can select them and succeed. + #[tokio::test] + async fn undersized_drain_abandoned_before_broadcast() { + let broadcaster = Arc::new(CountingOkBroadcaster::default()); + let (manager, signer, persistence) = + coinjoin_funded_asset_lock_manager(Arc::clone(&broadcaster)).await; + + let result = manager + .broadcast_funded_asset_lock_with_funding( + super::AssetLockBuildAmount::DrainAll { + // Far above the fixture balance: the built lock value + // (Σ inputs − fee < 10_000_000) must fail the floor. + minimum_lock_duffs: Some(u64::MAX), + }, + key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingAccount::CoinJoin { + account_index: 0, + }, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + ) + .await; + let err = result.expect_err("undersized drain must be refused"); + assert!( + err.to_string().contains("below the required minimum"), + "unexpected error for undersized drain: {err}" + ); + assert_eq!( + broadcaster.calls(), + 0, + "an abandoned drain must never reach the broadcaster" + ); + { + let wm = manager.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&manager.wallet_id) + .expect("wallet still present"); + assert!( + info.tracked_asset_locks.is_empty(), + "an abandoned drain must not leave a tracked row, got {:?}", + info.tracked_asset_locks + ); + } + assert!( + persistence.removed_outpoints().is_empty(), + "nothing was tracked, so nothing should be queued for removal" + ); + + // The reservation was released through the owner token: a follow-up + // drain over the same single-UTXO CoinJoin account must be able to + // select the inputs immediately and broadcast. + manager + .broadcast_funded_asset_lock_with_funding( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingAccount::CoinJoin { + account_index: 0, + }, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + ) + .await + .expect("follow-up drain must reselect the released inputs"); + assert_eq!( + broadcaster.calls(), + 1, + "the follow-up drain should broadcast exactly once" + ); + } + /// Builds an `AssetLockManager` over the shared BIP44-funded fixture. async fn funded_asset_lock_manager( broadcaster: Arc, 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 ea22396015e..e762ea06ec7 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -122,22 +122,40 @@ pub enum AssetLockFunding { /// - 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`]. + /// BIP44 account index) supplies the UTXOs. This exact-amount form + /// is BIP44-only; CoinJoin funding exists solely as the + /// whole-balance [`AssetLockFunding::DrainAccountBalance`] form + /// (CoinJoin accounts have no change semantics). BIP32 funding + /// remains unsupported. 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, }, + /// Build an asset lock that drains a funding account's whole balance: + /// every final UTXO of `account` is consumed and the lock value is + /// `Σ inputs − fee`, computed by the key-wallet builder. + /// + /// This is the CoinJoin → Shielded path: mixed coins fund the asset + /// lock directly (CoinJoin funding is drain-only — those accounts have + /// no change semantics), so they never hop through a transparent BIP44 + /// address. A `Bip44` account is also accepted for a whole-balance + /// BIP44 lock. + DrainAccountBalance { + /// The account family + index to drain. + account: + key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingAccount, + /// Floor on the drained lock value, enforced against the BUILT + /// payload before tracking/broadcast (see + /// [`AssetLockBuildAmount::DrainAll`]); an undersized build is + /// abandoned with nothing on the wire. `None` skips the check. + /// + /// [`AssetLockBuildAmount::DrainAll`]: super::build::AssetLockBuildAmount::DrainAll + minimum_lock_duffs: Option, + }, + /// Resume from a tracked asset lock identified by its outpoint /// (txid + output index). /// @@ -428,6 +446,35 @@ impl AssetLockManager { Err(e) => Err(e), } } + AssetLockFunding::DrainAccountBalance { + account, + minimum_lock_duffs, + } => { + // Same pipeline as `FromWalletBalance`, with drain amount + // semantics and the caller-picked funding account family. + match self + .create_funded_asset_lock_proof_with_funding( + super::build::AssetLockBuildAmount::DrainAll { minimum_lock_duffs }, + account, + 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)) => { + Ok(FundingResolution::IsTimeout { out_point }) + } + Err(e) => Err(e), + } + } AssetLockFunding::FromExistingAssetLock { out_point, consume_invitation_voucher, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index be590f5f84d..074a4dba537 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -44,6 +44,31 @@ pub(super) fn record_or_persister( persister.get_core_tx_record(txid) } +/// Family-aware in-memory funding-tx record lookup, shared by EVERY proof, +/// ChainLock-wait, and recovery path. `TrackedAssetLock.account_index` is +/// family-less (it doesn't record whether the lock was BIP44- or +/// CoinJoin-funded), and key-wallet files a transaction that spends CoinJoin +/// inputs under `coinjoin_accounts` — so a BIP44-only lookup leaves a +/// whole-balance drain lock's IS/CL record invisible (fatal on hosts running +/// `NoPlatformPersistence`, whose persister fallback always returns `None`). +/// BIP44 is checked first (every historical lock), then CoinJoin. +pub(in crate::wallet::asset_lock) fn funding_tx_record( + accounts: &key_wallet::account::ManagedAccountCollection, + account_index: u32, + txid: &Txid, +) -> Option { + accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions().get(txid).cloned()) + .or_else(|| { + accounts + .coinjoin_accounts + .get(&account_index) + .and_then(|a| a.transactions().get(txid).cloned()) + }) +} + /// Variant of [`record_or_persister`] that swallows persister errors /// as `None` after a `warn`-level log. Use this from poll loops where /// the next iteration retries — a hard error from a single tick would @@ -98,11 +123,7 @@ impl AssetLockManager { let info = wm .get_wallet_info(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - info.core_wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .and_then(|a| a.transactions().get(&out_point.txid).cloned()) + funding_tx_record(&info.core_wallet.accounts, account_index, &out_point.txid) // wm dropped at end of block — release before persister + DAPI calls. }; @@ -192,11 +213,7 @@ impl AssetLockManager { let info = wm .get_wallet_info(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - info.core_wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .and_then(|a| a.transactions().get(&txid).cloned()) + funding_tx_record(&info.core_wallet.accounts, account_index, &txid) }; let record = record_or_persister(in_memory, &self.persister, &txid).map_err(|e| { @@ -298,11 +315,7 @@ impl AssetLockManager { let in_memory = { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id).and_then(|info| { - info.core_wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .and_then(|a| a.transactions().get(&out_point.txid).cloned()) + funding_tx_record(&info.core_wallet.accounts, account_index, &out_point.txid) }) }; if let Some(record) = @@ -409,14 +422,10 @@ impl AssetLockManager { .and_then(|i| i.core_wallet.metadata.last_applied_chain_lock.as_ref()) .map(|cl| cl.block_height); let rec = info.as_ref().and_then(|i| { - i.core_wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .and_then(|a| a.transactions().get(&out_point.txid)) + funding_tx_record(&i.core_wallet.accounts, account_index, &out_point.txid) }); - let ctx = rec.map(|r| format!("{:?}", r.context)); - let h = rec.and_then(|r| r.height()); + let ctx = rec.as_ref().map(|r| format!("{:?}", r.context)); + let h = rec.as_ref().and_then(|r| r.height()); (cl_h, ctx, h) }; tracing::debug!( @@ -433,11 +442,7 @@ impl AssetLockManager { let in_memory = { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id).and_then(|info| { - info.core_wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .and_then(|a| a.transactions().get(&out_point.txid).cloned()) + funding_tx_record(&info.core_wallet.accounts, account_index, &out_point.txid) }) }; if let Some(record) = @@ -590,6 +595,80 @@ mod tests { use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use crate::wallet::platform_wallet::WalletId; + /// CoinJoin-family regression for [`funding_tx_record`]: a record filed + /// only under `coinjoin_accounts` (how key-wallet records a tx spending + /// CoinJoin inputs, e.g. a whole-balance drain asset lock) must be + /// visible — the pre-fix BIP44-only lookups missed it and burned the + /// full proof-wait timeout under `NoPlatformPersistence`. + #[test] + fn funding_tx_record_finds_coinjoin_only_record() { + use key_wallet::test_utils::TestWalletContext; + + let mut ctx = TestWalletContext::new_random(); + let record = coinjoin_record_with_txid(0x77); + let txid = record.txid; + ctx.managed_wallet + .first_coinjoin_managed_account_mut() + .expect("default wallet has CoinJoin account 0") + .transactions_mut() + .insert(txid, record); + + let found = funding_tx_record(&ctx.managed_wallet.accounts, 0, &txid) + .expect("CoinJoin-family record must be found by the shared lookup"); + assert_eq!(found.txid, txid); + + // Unknown txid and unknown account index are clean misses. + assert!( + funding_tx_record(&ctx.managed_wallet.accounts, 0, &Txid::from([0x01; 32])).is_none() + ); + assert!(funding_tx_record(&ctx.managed_wallet.accounts, 9, &txid).is_none()); + } + + /// The historical BIP44 path through [`funding_tx_record`] still resolves. + #[test] + fn funding_tx_record_finds_bip44_record() { + use key_wallet::test_utils::TestWalletContext; + + let mut ctx = TestWalletContext::new_random(); + let record = record_with_txid(0x42); + let txid = record.txid; + ctx.managed_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("default wallet has BIP44 account 0") + .transactions_mut() + .insert(txid, record); + + let found = funding_tx_record(&ctx.managed_wallet.accounts, 0, &txid) + .expect("BIP44-family record must be found by the shared lookup"); + assert_eq!(found.txid, txid); + } + + /// [`record_with_txid`] sibling filed as a CoinJoin-account record. + fn coinjoin_record_with_txid(seed: u8) -> TransactionRecord { + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: dashcore::OutPoint::new(Txid::from([seed; 32]), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + TransactionRecord::new( + tx, + AccountType::CoinJoin { index: 0 }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ) + } + fn record_with_txid(seed: u8) -> TransactionRecord { // A unique txid per `seed` falls out of the (different) input // outpoint; the actual transaction body doesn't matter for the diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 6083f298227..8d11cde99f6 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -59,11 +59,11 @@ impl AssetLockManager { // it (no proof was provided). Otherwise the proof we // already have determines the status without a lookup. if proof.is_none() { - info.core_wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .and_then(|a| a.transactions().get(&out_point.txid).cloned()) + super::proof::funding_tx_record( + &info.core_wallet.accounts, + account_index, + &out_point.txid, + ) } else { None } diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 10b1cbebdfd..365d6514229 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -54,9 +54,12 @@ pub(crate) async fn broadcast_releasing_on_rejection>, wallet_id: &WalletId, - account_type: StandardAccountType, - account_index: u32, + funding_account: ReservedFundingAccount, tx: &Transaction, + reservation_token: Option, ) { // `release_reservation` takes `&self` and the manager map is // untouched, so a read lock suffices — this cleanup does not @@ -86,20 +101,36 @@ pub(crate) async fn release_reservation_after_rejected_broadcast( let wm = wallet_manager.read().await; let account = wm .get_wallet_and_info(wallet_id) - .and_then(|(_, info)| match account_type { - StandardAccountType::BIP44Account => info - .core_wallet - .bip44_managed_account_at_index(account_index), - StandardAccountType::BIP32Account => info + .and_then(|(_, info)| match funding_account { + ReservedFundingAccount::Standard(StandardAccountType::BIP44Account, account_index) => { + info.core_wallet + .bip44_managed_account_at_index(account_index) + } + ReservedFundingAccount::Standard(StandardAccountType::BIP32Account, account_index) => { + info.core_wallet + .bip32_managed_account_at_index(account_index) + } + ReservedFundingAccount::CoinJoin(account_index) => info .core_wallet - .bip32_managed_account_at_index(account_index), + .accounts + .coinjoin_accounts + .get(&account_index), }); match account { - Some(account) => account.release_reservation(tx), + // Owner-guarded when the build's `ReservationToken` is available: + // this cleanup always runs after `.await`s (build → broadcast), so + // the original reservation may have been swept and the same + // outpoints re-reserved by a NEWER build — an unconditional release + // would clobber that newer owner and make its inputs re-selectable + // by a conflicting transaction. Callers without a token (paths that + // predate token plumbing) keep the historical unconditional release. + Some(account) => match reservation_token { + Some(token) => account.release_reservation_if_owner(tx, token), + None => account.release_reservation(tx), + }, None => tracing::warn!( wallet_id = %hex::encode(wallet_id), - ?account_type, - account_index, + ?funding_account, "could not release UTXO reservation after rejected broadcast: \ wallet or funds account not found" ), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 72c613965c7..07058ae6568 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -188,6 +188,31 @@ impl PlatformWallet { } } + // Drain path: stamp the authoritative lock-value floor into the + // funding request. The drained value is only knowable from the BUILT + // payload (a pre-build balance estimate races concurrent + // reservations and the builder's own selection filters), so the + // asset-lock pipeline enforces this floor post-build / pre-broadcast + // and abandons an undersized build with an owner-guarded reservation + // release — a single-use L1 outpoint that could never clear the + // Type 18 pool fee is never created. The floor is the smallest + // whole-duff value STRICTLY above the pool fee, mirroring the + // `lock_value − pool_fee > 0` consumability requirement in Step 3. + let funding = match funding { + AssetLockFunding::DrainAccountBalance { + account, + minimum_lock_duffs: _, + } => { + let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; + let minimum_lock_duffs = pool_fee_credits / CREDITS_PER_DUFF + 1; + AssetLockFunding::DrainAccountBalance { + account, + minimum_lock_duffs: Some(minimum_lock_duffs), + } + } + other => other, + }; + // Single-flight: serialise shield-class operations on this // wallet so two concurrent calls can't race the asset-lock // tracker into a half-consumed state. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 93ae2a4361b..15458b3ca5b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -187,6 +187,75 @@ extension PlatformWalletManager { }.value } + /// Fund the shielded pool by DRAINING the wallet's CoinJoin account + /// (`m/9'/coinType'/4'/accountIndex'`) into a single asset lock. + /// + /// Sibling to [`shieldedFundFromAssetLock`] with drain funding: every + /// final mixed-coin UTXO is consumed and the lock value is + /// `Σ inputs − L1 fee`, computed Rust-side — the mixed coins never hop + /// through a transparent BIP44 address. The recipient receives + /// `lock_value − pool_fee` credits. The Rust preflight rejects a drain + /// whose balance could not clear the Type 18 pool fee, so an + /// unrecoverable dust lock is never broadcast. A stuck lock resumes via + /// [`shieldedResumeFundFromAssetLock`] exactly like a BIP44-funded one. + /// + /// - Parameters: + /// - walletId: 32-byte wallet identifier. + /// - coinJoinAccountIndex: CoinJoin account whose whole balance funds + /// the asset lock (account 0 for every current wallet). + /// - recipients: Destination Orchard address (exactly one entry, no + /// explicit credits — same single-recipient remainder contract as + /// `shieldedFundFromAssetLock`). + public func shieldedFundFromCoinJoinDrain( + walletId: Data, + coinJoinAccountIndex: UInt32 = 0, + recipients: [ShieldedFundFromAssetLockRecipient] + ) async throws { + try shieldedFundFromAssetLockPreflight( + walletId: walletId, + recipients: recipients + ) + + let handle = self.handle + let recipientRaw43 = recipients[0].recipientRaw43 + // Constructed on the calling actor so it lives for the entire + // detached Task — see `shieldedFundFromAssetLock` for why + // `withExtendedLifetime` (not a bare discard) is required. + let coreSigner = MnemonicResolver() + + try await Task.detached(priority: .userInitiated) { + try walletId.withUnsafeBytes { widRaw in + guard + let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "walletId baseAddress is nil" + ) + } + try recipientRaw43.withUnsafeBytes { recipientRaw in + guard + let recipientPtr = recipientRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "recipient baseAddress is nil" + ) + } + let result = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain( + handle, + widPtr, + coinJoinAccountIndex, + recipientPtr, + coreSigner.handle + ) + } + try result.check() + } + } + }.value + } + /// Resume a stuck shielded fund-from-asset-lock from an /// already-tracked outpoint. /// diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift index cd9bf95e409..d415024a21f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift @@ -1,8 +1,13 @@ import Foundation import SwiftDashSDK -/// Wraps a `ManagedPlatformWallet` and a `ManagedCoreWallet` -final class TestWalletWrapper { +/// Wraps a `ManagedPlatformWallet` and a `ManagedCoreWallet`. +/// +/// `@unchecked Sendable` on the same grounds as `IntegrationTestEnv`: the +/// stored SDK handles are immutable `let`s and each wrapper is driven by one +/// test task at a time, but it crosses the `makeTestWallet` async boundary, +/// which strict-concurrency toolchains reject for non-Sendable results. +final class TestWalletWrapper: @unchecked Sendable { private let core: ManagedCoreWallet private let wallet: ManagedPlatformWallet