diff --git a/Cargo.lock b/Cargo.lock index 14ee6074fba..aebe116bdc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "bincode", "dashcore-private", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" [[package]] name = "glob" @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=b056d07c61f8618f05082552bbb88072290d57c1#b056d07c61f8618f05082552bbb88072290d57c1" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5a80bd71f6ba13a5055780d644f0aa724a34ca04#5a80bd71f6ba13a5055780d644f0aa724a34ca04" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index c8d2ff28af0..420a8a21679 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "b056d07c61f8618f05082552bbb88072290d57c1" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5a80bd71f6ba13a5055780d644f0aa724a34ca04" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs index 3419860cc32..1438ce4c446 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs @@ -20,6 +20,14 @@ use std::os::raw::c_char; /// Build an asset lock transaction via an external mnemonic resolver. /// +/// Funding is POOLED across `platform_wallet::ASSET_LOCK_FUNDING_SOURCES`: +/// coin selection draws from the union of the BIP44 and BIP32 accounts at +/// `account_index` plus every DashPay contact-receiving account, and change +/// returns to BIP44. A lock therefore no longer needs its whole amount sitting +/// in one account, and the caller no longer has to sweep accounts together +/// first. `account_index` addresses the standard families only; DashPay +/// accounts span their own indices and are pooled in regardless. +/// /// On success: /// - `out_tx_bytes`/`out_tx_len`: serialized signed transaction /// - `out_derivation_path`: NUL-terminated C string with the diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs index ea37d5886d3..988fa5807aa 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs @@ -12,7 +12,9 @@ pub struct TrackedAssetLockFFI { pub txid: [u8; 32], /// Outpoint vout. pub vout: u32, - /// BIP44 account index. + /// Family-independent source index of the pooled funding (BIP44 and + /// BIP32 at this index plus DashPay receiving accounts). Not a + /// BIP44-only selector. pub account_index: u32, /// Funding type (0=IdentityRegistration, 1=IdentityTopUp, 2=IdentityTopUpNotBound, /// 3=IdentityInvitation, 4=AssetLockAddressTopUp, 5=AssetLockShieldedAddressTopUp). diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs index 965e6ea1b11..fc0f4e0d5fe 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs @@ -41,7 +41,9 @@ pub struct AssetLockEntryFFI { /// for the callback window. pub transaction_bytes: *const u8, pub transaction_bytes_len: usize, - /// BIP44 account index that funded this asset lock. + /// Family-independent source index of the pooled funding (BIP44 and + /// BIP32 at this index plus DashPay receiving accounts; change + /// returns to BIP44). Not a BIP44-only selector. pub account_index: u32, /// Discriminant of [`key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType`]: /// 0 = IdentityRegistration, 1 = IdentityTopUp, 2 = IdentityTopUpNotBound, 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 49495b26b50..a323a132cb5 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 @@ -43,12 +43,13 @@ fn existing_asset_lock_funding( /// Register a new asset-lock-funded identity using an external signer. /// -/// `account_index` selects which BIP44 *standard* account (by BIP44 -/// account index) the asset-lock funding UTXOs are drawn from. Only -/// BIP44 standard accounts are supported today; the Swift UI is -/// expected to filter the funding picker accordingly (CoinJoin / BIP32 -/// funding for new-identity registration is not yet wired through -/// `create_funded_asset_lock_proof`). +/// `account_index` addresses the *standard* families: the asset-lock +/// funding POOLS the BIP44 and BIP32 accounts at that index together +/// with every DashPay receiving account (change returns to BIP44). +/// The index does not restrict which DashPay receiving accounts +/// contribute, so the UI must not present it as an account-scoped +/// funding or privacy choice. CoinJoin funding remains drain-only and +/// is not reachable here. /// /// # Safety /// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` diff --git a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs index fb227870ecc..7762a0f7382 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_top_up.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_top_up.rs @@ -170,9 +170,12 @@ const MIN_TOP_UP_DUFFS: u64 = 50_500; /// with [`AssetLockFunding::FromWalletBalance`] — the same L2 orchestrator /// (funding resolution, IS→CL fallback, asset-lock cleanup) that /// [`platform_wallet_register_identity_with_funding_signer`] drives for -/// registration. `account_index` selects which BIP44 *standard* account -/// the asset-lock UTXOs are drawn from (only BIP44 standard accounts are -/// supported today, matching registration). +/// registration. `account_index` addresses the *standard* families: the +/// asset lock POOLS the BIP44 and BIP32 accounts at that index together +/// with every DashPay receiving account (change returns to BIP44). The +/// index does not restrict which DashPay receiving accounts contribute — +/// callers must not present this as an account-scoped funding or privacy +/// choice (matching registration). /// /// Unlike registration this takes NO identity-key signer: the /// `IdentityTopUp` state-transition is signed entirely by the asset lock's diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index a367896437a..dccbcf72bb0 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5657,9 +5657,12 @@ impl UnresolvedRestoreStats { } /// Project a slice of [`UnresolvedAssetLockTxRecordFFI`] rows onto the -/// in-memory `transactions()` maps of the matching -/// `standard_bip44_accounts[account_index]` slots on the rebuilt -/// `ManagedWalletInfo`. +/// in-memory `transactions()` maps of the rebuilt `ManagedWalletInfo`, +/// routing each record to the first present family the proof lookup +/// searches: `standard_bip44_accounts[account_index]`, then +/// `standard_bip32_accounts[account_index]`, then +/// `coinjoin_accounts[account_index]`, then any DashPay receival +/// account (the lookup scans those by txid, index-independent). /// /// See the call site in [`build_wallet_start_state`] for the design /// rationale on WHY this exists at all (selective bulk-restore for @@ -5735,22 +5738,43 @@ fn restore_unresolved_asset_lock_tx_records( _ => TransactionContext::Mempool, }; - // Asset-lock txs are funded from a BIP44 account; that's - // the only account map the asset-lock recovery flow - // consults (`recover_asset_lock_blocking` reads - // `info.core_wallet.accounts.standard_bip44_accounts.get( - // &account_index)...transactions().get(&out_point.txid)`), - // so restoration goes through the same map. Records for - // other variants would never be reached by that lookup. - let Some(account) = wallet_info - .accounts + // A pooled asset lock can be funded ENTIRELY from a BIP32 + // account or a DashPay receiving account — the builder + // deliberately skips absent source families — so + // `standard_bip44_accounts[account_index]` may not exist + // for a perfectly valid record. The recovery lookup + // (`funding_tx_record` in sync::proof) searches BIP44 and + // BIP32 by this index, CoinJoin by index, and DashPay + // receiving accounts by txid regardless of index; restore + // the synthetic record into the FIRST present family that + // lookup searches instead of dropping it — a dropped + // record leaves an already-broadcast lock stuck at + // `Broadcast` after restart, unrecoverable by any later + // promotion path. + let accounts = &mut wallet_info.accounts; + let account = if accounts .standard_bip44_accounts - .get_mut(&rec.account_index) - else { + .contains_key(&rec.account_index) + { + accounts.standard_bip44_accounts.get_mut(&rec.account_index) + } else if accounts + .standard_bip32_accounts + .contains_key(&rec.account_index) + { + accounts.standard_bip32_accounts.get_mut(&rec.account_index) + } else if accounts.coinjoin_accounts.contains_key(&rec.account_index) { + accounts.coinjoin_accounts.get_mut(&rec.account_index) + } else { + // Any receival account works: the proof lookup scans + // them all by txid, ignoring the tracked source index. + accounts.dashpay_receival_accounts.values_mut().next() + }; + let Some(account) = account else { stats.dropped_no_account += 1; tracing::warn!( account_index = rec.account_index, - "load: dropping unresolved-asset-lock tx record — no matching BIP44 account" + "load: dropping unresolved-asset-lock tx record — no account in any \ + family the proof lookup searches" ); continue; }; @@ -6642,6 +6666,123 @@ mod tests { ManagedWalletInfo::from_wallet(&wallet, 0) } + /// Same construction as `test_managed_wallet_info_with_bip44` but with a + /// single account of the given standard/DashPay `account_type` — the + /// pooled-restore tests need wallets whose ONLY account is a non-BIP44 + /// family, because that's exactly the shape the pooled builder produces + /// when it skips absent source families. + fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo { + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + Language::English, + ) + .expect("static BIP-39 vector must parse"); + let seed = mnemonic.to_seed(""); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed) + .expect("master derivation must succeed"); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + let account = Account::from_xpub(None, account_type, xpub, Network::Testnet) + .expect("Account::from_xpub on a valid xpub must succeed"); + let mut accounts = key_wallet::AccountCollection::new(); + accounts + .insert(account) + .expect("inserting the single account must succeed"); + let wallet = Wallet::new_external_signable(Network::Testnet, [0u8; 32], accounts); + ManagedWalletInfo::from_wallet(&wallet, 0) + } + + /// A lock funded EXCLUSIVELY from a BIP32 account (the pooled builder + /// skips absent families, so no BIP44 account exists at the index) must + /// restore into `standard_bip32_accounts` — the pre-fix bridge only + /// consulted BIP44 and dropped the record, leaving an already-broadcast + /// lock unrecoverable after restart. + #[test] + fn restore_routes_to_bip32_when_indexed_bip44_absent() { + let mut wallet_info = test_managed_wallet_info_with_account(AccountType::Standard { + index: 7, + standard_account_type: StandardAccountType::BIP32Account, + }); + let tx = synthetic_minimal_tx(); + let txid = tx.txid(); + let mut tx_buf: Vec = serialize(&tx); + + let rec = UnresolvedAssetLockTxRecordFFI { + account_index: 7, + tx_bytes: tx_buf.as_mut_ptr(), + tx_bytes_len: tx_buf.len(), + context_raw: 2, + block_height: 1475917, + block_hash: [0x42u8; 32], + block_timestamp: 1700000000, + first_seen: 1699999000, + }; + + let stats = restore_unresolved_asset_lock_tx_records(&mut wallet_info, &[rec]) + .expect("restoration should not error"); + assert_eq!( + stats.restored, 1, + "the BIP32-only record must restore, not drop" + ); + assert!( + wallet_info + .accounts + .standard_bip32_accounts + .get(&7) + .expect("BIP32 account 7 must exist") + .transactions() + .contains_key(&txid), + "the restored record must land in the BIP32 account the proof lookup searches" + ); + drop(tx_buf); + } + + /// A lock funded EXCLUSIVELY from a DashPay receiving account (no + /// standard account exists at the tracked index at all) must restore + /// into a receival account — the proof lookup scans them by txid, + /// ignoring the tracked source index, so any receival account makes the + /// record findable again after restart. + #[test] + fn restore_routes_to_dashpay_receival_when_indexed_standard_absent() { + let mut wallet_info = + test_managed_wallet_info_with_account(AccountType::DashpayReceivingFunds { + index: 2, + user_identity_id: [0x11u8; 32], + friend_identity_id: [0x22u8; 32], + }); + let tx = synthetic_minimal_tx(); + let txid = tx.txid(); + let mut tx_buf: Vec = serialize(&tx); + + let rec = UnresolvedAssetLockTxRecordFFI { + // Tracked source index that matches NO standard account. + account_index: 3, + tx_bytes: tx_buf.as_mut_ptr(), + tx_bytes_len: tx_buf.len(), + context_raw: 2, + block_height: 1475917, + block_hash: [0x42u8; 32], + block_timestamp: 1700000000, + first_seen: 1699999000, + }; + + let stats = restore_unresolved_asset_lock_tx_records(&mut wallet_info, &[rec]) + .expect("restoration should not error"); + assert_eq!( + stats.restored, 1, + "the DashPay-only record must restore, not drop" + ); + assert!( + wallet_info + .accounts + .dashpay_receival_accounts + .values() + .any(|account| account.transactions().contains_key(&txid)), + "the restored record must land in a receival account (searched by txid)" + ); + drop(tx_buf); + } + /// Same reproducible testnet xpub as `test_managed_wallet_info_with_bip44`, /// wrapped as a `ProviderOwnerKeys` account so the managed collection /// ends up with a `provider_owner_keys` account carrying its address 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 794ad1eaef6..bcb5d684780 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 @@ -34,10 +34,12 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// → 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. +/// `account_index` addresses the *standard* Core families: the asset +/// lock POOLS the BIP44 and BIP32 accounts at that index together with +/// every DashPay receiving account (change returns to BIP44); the index +/// does not restrict which DashPay receiving accounts contribute. +/// `platform_account_index` selects which platform-payment account the +/// recipient addresses belong to. /// /// # Safety /// - `signer_address_handle` must be a valid, non-destroyed diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c049c2d4339..d5bcb21c9e4 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -896,8 +896,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// by a `MnemonicResolverHandle` — the raw key never crosses the /// FFI boundary. /// -/// `account_index` selects the BIP44 Core account whose UTXOs -/// fund the asset lock. `amount_duffs` is the L1 amount to lock. +/// `account_index` addresses the standard Core families: the asset +/// lock POOLS the BIP44 and BIP32 accounts at that index together +/// with every DashPay receiving account (change returns to BIP44); +/// the index does not restrict which DashPay receiving accounts +/// contribute. `amount_duffs` is the L1 amount to lock. /// The wallet derives the shielded credit amount internally /// (`lock_value − pool_fee`, where `pool_fee = shielded fee + /// asset_lock_base_cost`) — callers don't need to know about @@ -1322,7 +1325,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset /// /// `account` is the shielded BIP44 account whose default address receives /// each real note (must be bound via `bind_shielded`). `funding_account_index` -/// is the Core BIP44 account whose UTXOs fund each per-batch asset lock. +/// is the standard-family source index each per-batch asset lock funds from — +/// it POOLS the BIP44 and BIP32 accounts at that index with every DashPay +/// receiving account (change returns to BIP44) and does not restrict which +/// DashPay receiving accounts contribute. /// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 9f8a8d27065..fdbd641a57f 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -481,11 +481,14 @@ pub struct UtxoRestoreEntryFFI { /// not yet observed IS-lock or block confirmation). #[repr(C)] pub struct UnresolvedAssetLockTxRecordFFI { - /// BIP44 account index the funding tx spent UTXOs from — the - /// same `account_index` the Rust `TrackedAssetLock` carries. - /// Routes the record into the matching - /// `standard_bip44_accounts[account_index].transactions_mut()` - /// bucket at load time. + /// Family-independent source index the funding tx spent UTXOs + /// from — the same `account_index` the Rust `TrackedAssetLock` + /// carries. A pooled lock can be funded from BIP44, BIP32 or + /// DashPay receiving accounts (CoinJoin backs only the drain + /// flow), so load-time routing tries BIP44, then BIP32, then + /// CoinJoin at this index, and finally falls back to a receival + /// account (searched by txid, index-independent) — see + /// `restore_unresolved_asset_lock_tx_records`. pub account_index: u32, /// Consensus-encoded asset-lock transaction body. Same wire /// format `dashcore::consensus::encode::serialize` produces, so diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index dc5f50eb8e5..bbb33caa6a6 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -57,7 +57,9 @@ 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::WalletBalance; -pub use wallet::core::{CoreWallet, SignedCoreTransaction, SEND_FUNDING_SOURCES}; +pub use wallet::core::{ + CoreWallet, SignedCoreTransaction, ASSET_LOCK_FUNDING_SOURCES, SEND_FUNDING_SOURCES, +}; pub use wallet::signed_payment_registry::{ RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, }; 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 1fe80310c2a..ca1b8feb712 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -16,12 +16,14 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{ AssetLockFundingAccount, AssetLockFundingType, CreditOutputFunding, }; use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use crate::changeset::{AccountRegistrationEntry, PlatformWalletChangeSet}; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::ASSET_LOCK_FUNDING_SOURCES; use super::manager::{AssetLockManager, DEFAULT_FEE_PER_KB}; use super::tracked::{AssetLockStatus, TrackedAssetLock}; @@ -61,14 +63,19 @@ 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 + /// Exact-amount form — the historical entry point, now **pooled**: it funds + /// from [`ASSET_LOCK_FUNDING_SOURCES`] (BIP44 + BIP32 + every DashPay + /// contact-receiving account), so the lock no longer needs its whole amount + /// sitting in one account and change returns to BIP44. The /// funding-parameterized form is /// [`Self::build_asset_lock_transaction_with_funding`]. /// /// # Arguments /// /// * `amount_duffs` — Amount to lock in duffs. - /// * `account_index` — BIP44 account index to select UTXOs from. + /// * `account_index` — Index addressing the standard (BIP44/BIP32) + /// families; DashPay contact accounts span their own indices and are + /// pooled in regardless. /// * `funding_type` — Which account to derive the one-time key from /// (e.g., `IdentityRegistration`, `IdentityTopUp`). /// * `identity_index` — Identity index (used by `IdentityTopUp`, ignored by others). @@ -89,27 +96,42 @@ impl AssetLockManager { ) -> Result<(Transaction, DerivationPath), PlatformWalletError> { self.build_asset_lock_transaction_with_funding( AssetLockBuildAmount::Exact(amount_duffs), - AssetLockFundingAccount::Bip44 { account_index }, + &ASSET_LOCK_FUNDING_SOURCES, + 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)) + // Historical callers never had the reservation token or the funding + // account list; the funded pipeline + // (`broadcast_funded_asset_lock_with_funding`) threads both. + .map(|(tx, path, _token, _accounts)| (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. + /// `funding_sources` names the account families to POOL, in order — the + /// first supplies the change address — and `amount` picks exact-amount vs + /// whole-balance drain semantics (see [`AssetLockBuildAmount`]). + /// `source_index` addresses the standard families; DashPay set selectors + /// span their own indices. + /// + /// A single-element list reproduces the old one-account behavior, including + /// its strict account-not-found error; a pooled list skips the sources this + /// wallet has nothing for. CoinJoin funding is drain-only *and* cannot be + /// pooled — the key-wallet builder rejects both a non-drain CoinJoin build + /// and a CoinJoin source combined with any other. + /// + /// Returns the transaction, the credit-output derivation path, the build's + /// reservation token, and the accounts that contributed inputs — the + /// caller's release path needs every one of them, since a pooled build + /// reserves in each contributing account's own set under the one token. + #[allow(clippy::type_complexity)] pub async fn build_asset_lock_transaction_with_funding( &self, amount: AssetLockBuildAmount, - funding_account: AssetLockFundingAccount, + funding_sources: &[AccountTypePreference], + source_index: u32, funding_type: AssetLockFundingType, identity_index: u32, signer: &S, @@ -118,6 +140,7 @@ impl AssetLockManager { Transaction, DerivationPath, Option, + Vec, ), PlatformWalletError, > { @@ -175,13 +198,16 @@ impl AssetLockManager { }; // 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). + // caller's funding sources + drain semantics (the key-wallet side + // pools the sources, enforces that CoinJoin funding is drain-only and + // unpooled, and reserves the selected inputs in each contributing + // account's own set under one owner token). let result = info .core_wallet .build_asset_lock_with_signer( wallet, - funding_account, + funding_sources, + source_index, vec![funding], DEFAULT_FEE_PER_KB, drain, @@ -218,7 +244,12 @@ impl AssetLockManager { } }; - Ok((result.transaction, path, result.reservation_token)) + Ok(( + result.transaction, + path, + result.reservation_token, + result.funding_accounts, + )) } /// Peek at the next unused address from a funding account without @@ -632,7 +663,9 @@ impl AssetLockManager { /// ## Parameters /// /// * `amount_duffs` — Amount to lock. - /// * `account_index` — BIP44 account index to select UTXOs from. + /// * `account_index` — Index addressing the standard (BIP44/BIP32) + /// families of [`ASSET_LOCK_FUNDING_SOURCES`]; DashPay contact accounts + /// span their own indices and are pooled in regardless. /// * `funding_type` — Which account to derive the one-time key from. /// * `identity_index` — HD identity index (for `IdentityTopUp`, this is /// the registration index identifying which identity is being topped up). @@ -646,9 +679,10 @@ impl AssetLockManager { identity_index: u32, signer: &S, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { - self.create_funded_asset_lock_proof_with_funding( + self.create_funded_asset_lock_proof_pooled( AssetLockBuildAmount::Exact(amount_duffs), - AssetLockFundingAccount::Bip44 { account_index }, + &ASSET_LOCK_FUNDING_SOURCES, + account_index, funding_type, identity_index, signer, @@ -656,9 +690,12 @@ impl AssetLockManager { .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`]. + /// Whole-balance drain form of [`Self::create_funded_asset_lock_proof`]: + /// the caller names the ONE account to drain (`AssetLockFundingAccount`), + /// which is how the CoinJoin → shielded path funds a lock directly from + /// mixed coins. A drain has no change output, so the question a pooled + /// source list answers — which account supplies change — does not arise, + /// and CoinJoin must not be pooled with transparent sources anyway. pub async fn create_funded_asset_lock_proof_with_funding( &self, amount: AssetLockBuildAmount, @@ -666,18 +703,42 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + self.create_funded_asset_lock_proof_pooled( + amount, + &[AccountTypePreference::from(funding_account)], + funding_account.account_index(), + funding_type, + identity_index, + signer, + ) + .await + } + + /// Source-list form of [`Self::create_funded_asset_lock_proof`] — same + /// build → broadcast → proof pipeline with the pooled funding and amount + /// semantics of [`Self::build_asset_lock_transaction_with_funding`]. + async fn create_funded_asset_lock_proof_pooled( + &self, + amount: AssetLockBuildAmount, + funding_sources: &[AccountTypePreference], + source_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { let (path, out_point) = self .broadcast_funded_asset_lock_with_funding( amount, - funding_account, + funding_sources, + source_index, funding_type, identity_index, signer, ) .await?; let proof = self - .wait_for_funded_asset_lock_proof(&out_point, funding_account.account_index()) + .wait_for_funded_asset_lock_proof(&out_point, source_index) .await?; Ok((proof, path, out_point)) } @@ -700,7 +761,8 @@ impl AssetLockManager { ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { self.broadcast_funded_asset_lock_with_funding( AssetLockBuildAmount::Exact(amount_duffs), - AssetLockFundingAccount::Bip44 { account_index }, + &ASSET_LOCK_FUNDING_SOURCES, + account_index, funding_type, identity_index, signer, @@ -709,10 +771,12 @@ impl AssetLockManager { } /// Funding-parameterized form of [`Self::broadcast_funded_asset_lock`]. + #[allow(clippy::too_many_arguments)] pub(crate) async fn broadcast_funded_asset_lock_with_funding( &self, amount: AssetLockBuildAmount, - funding_account: AssetLockFundingAccount, + funding_sources: &[AccountTypePreference], + source_index: u32, funding_type: AssetLockFundingType, identity_index: u32, signer: &S, @@ -746,11 +810,15 @@ impl AssetLockManager { }; let build_persist_guard = self.build_persist_serial.lock().await; - // 1. Build the asset lock transaction. - let (tx, path, reservation_token) = self + // 1. Build the asset lock transaction. `funding_accounts` are the + // accounts that actually contributed inputs — a pooled build + // reserves in each of their own sets under the one token, so every + // release below has to reach all of them. + let (tx, path, reservation_token, funding_accounts) = self .build_asset_lock_transaction_with_funding( amount, - funding_account, + funding_sources, + source_index, funding_type, identity_index, signer, @@ -785,21 +853,10 @@ impl AssetLockManager { { 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, + &funding_accounts, &tx, reservation_token, ) @@ -834,6 +891,22 @@ impl AssetLockManager { if let Err(e) = pool_durability { tracing::error!(error = %e, "failed to persist asset-lock funding index"); if funding_type == AssetLockFundingType::IdentityInvitation { + // The pooled build reserved every selected input across its + // contributing accounts under `reservation_token`. Nothing + // was broadcast, so abandon like the drain-floor branch + // above: drop the serialization guard, owner-release across + // every contributor, THEN surface the durability error — + // otherwise an immediate retry cannot reselect the BIP44 / + // BIP32 / DashPay inputs until the TTL sweep frees them. + drop(build_persist_guard); + crate::wallet::reservations::release_reservation_after_rejected_broadcast( + &self.wallet_manager, + &self.wallet_id, + &funding_accounts, + &tx, + reservation_token, + ) + .await; return Err(PlatformWalletError::AssetLockTransaction(format!( "aborted before broadcast: could not durably record the invitation \ funding index (broadcasting anyway would risk voucher-key reuse on \ @@ -852,7 +925,7 @@ impl AssetLockManager { .track_asset_lock(TrackedAssetLock { out_point, transaction: tx.clone(), - account_index: funding_account.account_index(), + account_index: source_index, funding_type, identity_index, amount: locked_amount_duffs, @@ -868,9 +941,9 @@ impl AssetLockManager { ); // 3. Broadcast. On a definitive pre-send rejection, untrack the - // `Built` row BEFORE releasing the funding reservation (the - // asset-lock builder funds from the BIP44 account at - // `account_index`): while the reservation is held the inputs + // `Built` row BEFORE releasing the funding reservation (held in + // every account of `funding_accounts`, under the one owner token): + // while the reservation is held the inputs // cannot be re-selected by a new build, and once the row is gone // `resume_asset_lock` can no longer re-drive the rejected // transaction — so at no point is the row resumable while its @@ -889,23 +962,10 @@ 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, - reserved_account, + &funding_accounts, &tx, reservation_token, ) @@ -970,6 +1030,7 @@ mod tests { use dashcore::OutPoint; use key_wallet::account::account_type::StandardAccountType; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use tokio::sync::Notify; use async_trait::async_trait; @@ -982,7 +1043,8 @@ mod tests { ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; use crate::test_support::{ - funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, + funded_wallet_manager, funded_wallet_manager_dual_standard, + funded_wallet_manager_with_contact, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; use crate::wallet::asset_lock::manager::AssetLockManager; @@ -1113,9 +1175,8 @@ mod tests { // (Σ 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, - }, + &[AccountTypePreference::CoinJoin], + 0, AssetLockFundingType::AssetLockShieldedAddressTopUp, 0, &signer, @@ -1155,9 +1216,8 @@ mod tests { super::AssetLockBuildAmount::DrainAll { minimum_lock_duffs: Some(1), }, - key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingAccount::CoinJoin { - account_index: 0, - }, + &[AccountTypePreference::CoinJoin], + 0, AssetLockFundingType::AssetLockShieldedAddressTopUp, 0, &signer, @@ -1717,6 +1777,34 @@ mod tests { >= 1, "the invitation gate must have driven flush()" ); + + // The abort released the pooled reservations across every + // contributing account: an IMMEDIATE rebuild must get through coin + // selection on the same fixture UTXOs and reach the durability gate + // again (the same "aborted before broadcast" error). Stranded + // reservations would surface here as a selection failure instead, + // stuck until the TTL sweep. + let rebuild = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityInvitation, + 0, + &signer, + ) + .await; + match rebuild { + Err(PlatformWalletError::AssetLockTransaction(msg)) => assert!( + msg.contains("aborted before broadcast"), + "the rebuild must reselect the released inputs and reach the \ + durability gate again — a selection failure means the abort \ + stranded the pooled reservations; got: {msg}" + ), + other => panic!( + "the rebuild must reach the durability gate again (inputs \ + released), got {other:?}" + ), + } } /// Non-invitation funding types stay best-effort: their one-time keys @@ -1884,4 +1972,166 @@ mod tests { } } } + + // -- Pooled asset-lock funding --------------------------------------- + + /// Build an `AssetLockManager` over an already-built wallet manager. + fn asset_lock_manager_over( + wallet_manager: Arc>>, + wallet_id: WalletId, + broadcaster: Arc, + ) -> (Arc>, Arc) { + let persistence = Arc::new(CapturingPersistence::default()); + 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, persistence) + } + + /// THE POINT OF THIS CHANGE: an asset lock larger than either standard + /// family holds is funded from BOTH in one transaction. Before pooling + /// this was `CoreInsufficientFunds` unless the caller first swept the + /// accounts together and locked out of the sweep — an extra on-chain hop + /// and fee. + #[tokio::test] + async fn pooled_asset_lock_spans_the_standard_families() { + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager_dual_standard(&[700_000], &[700_000]).await; + let (manager, _persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + + // 1_000_000 exceeds either family's 700_000, so selection must pool. + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("a lock above either family's balance must pool both"); + + let wm = manager.wallet_manager.read().await; + let (_, info) = wm.get_wallet_and_info(&wallet_id).expect("wallet present"); + let tracked = info + .tracked_asset_locks + .get(&out_point) + .expect("the broadcast lock is tracked"); + assert!( + tracked.transaction.input.len() >= 2, + "a lock above either family's balance needs inputs from both, got {}", + tracked.transaction.input.len() + ); + } + + /// The DashPay half of the pooled set, end to end: a lock larger than + /// BIP44 alone holds reaches into a real contact-receiving account and + /// signs its inputs (DIP-15 `Normal256` path). Without this, every lookup + /// in the pooled path could resolve `None` for contact accounts and the + /// feature would silently degrade to BIP44 + BIP32. + #[tokio::test] + async fn pooled_asset_lock_spends_dashpay_contact_funds() { + let (wallet_manager, wallet_id, _generation, signer, _contact_account) = + funded_wallet_manager_with_contact(&[700_000], &[700_000]).await; + let (manager, _persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("a lock above BIP44's balance must reach the contact account"); + + let wm = manager.wallet_manager.read().await; + let (_, info) = wm.get_wallet_and_info(&wallet_id).expect("wallet present"); + let tracked = info + .tracked_asset_locks + .get(&out_point) + .expect("the broadcast lock is tracked"); + assert!( + tracked.transaction.input.len() >= 2, + "the contact's coin must be spent alongside BIP44's" + ); + } + + /// The reservation hazard pooling introduces, and the one this change had + /// to get right: a rejected broadcast must release the reservation in + /// EVERY contributing account. The pooled build reserves per account under + /// one owner token, so releasing only the first would leave the rest of + /// the inputs held until the 24-block TTL backstop — and an immediate + /// retry would fail with spurious insufficient funds. The rebuild below + /// can only succeed if both families' inputs came back. + #[tokio::test] + async fn rejected_pooled_broadcast_releases_every_contributing_account() { + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager_dual_standard(&[700_000], &[700_000]).await; + let (manager, _persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + ); + + let rejected = manager + .create_funded_asset_lock_proof( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!(rejected, Err(PlatformWalletError::TransactionBroadcast(_))), + "the pooled build must have succeeded and only the broadcast failed, got {rejected:?}" + ); + { + let wm = manager.wallet_manager.read().await; + let (_, info) = wm.get_wallet_and_info(&wallet_id).expect("wallet present"); + assert!( + info.tracked_asset_locks.is_empty(), + "a definitively rejected lock leaves no resumable row" + ); + } + + // Identical rebuild: only possible if BOTH accounts' inputs were + // released. A release that reached only the first funding account + // would strand the other family's coin, leaving 700_000 available + // against a 1_000_000 lock — insufficient funds, not a rebuild. + let (rebuilt, _path) = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("every contributing account's reservation must have been released"); + assert!( + rebuilt.input.len() >= 2, + "the rebuild must reselect inputs from both families, got {}", + rebuilt.input.len() + ); + } } 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 e762ea06ec7..58f3963cc40 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -121,16 +121,24 @@ pub enum AssetLockFunding { /// - `AssetLockAddressTopUp` — for platform-address funding flows /// - others — see [`AssetLockFundingType`] /// - /// `account_index` selects which BIP44 *standard* account (by - /// 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. + /// Funding is POOLED across `ASSET_LOCK_FUNDING_SOURCES`: coin + /// selection draws from the union of the BIP44 and BIP32 accounts at + /// `account_index` and every DashPay contact-receiving account, so + /// the lock does not need its whole amount sitting in one account. + /// Change returns to BIP44, the first source. Sources this wallet has + /// nothing for are skipped. + /// + /// CoinJoin is deliberately not in that set — spending mixed outputs + /// alongside transparent ones links them and undoes the mixing — so + /// CoinJoin funding still exists solely as the whole-balance + /// [`AssetLockFunding::DrainAccountBalance`] form (those accounts + /// also have no change semantics). FromWalletBalance { /// Amount to lock (in duffs). amount_duffs: u64, - /// BIP44 standard-account index to draw the funding UTXOs from. + /// Index addressing the standard (BIP44/BIP32) families of the + /// pooled source set. DashPay contact-receiving accounts span + /// their own indices and are pooled in regardless. account_index: u32, }, 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 074a4dba537..3eb3d83b1cb 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 @@ -45,28 +45,39 @@ pub(super) fn record_or_persister( } /// 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. +/// ChainLock-wait, and recovery path. +/// +/// `TrackedAssetLock.account_index` is family-less — it records the source +/// index, not which accounts ended up funding the lock — while key-wallet files +/// a transaction under *every* account its inputs touch. So the record can sit +/// in any of the families a lock may be funded from, and looking in only some +/// of them leaves it invisible (fatal on hosts running `NoPlatformPersistence`, +/// whose persister fallback always returns `None`, and a burnt proof-wait +/// timeout everywhere else). +/// +/// Two shapes make that a live concern: a whole-balance CoinJoin drain files +/// only under `coinjoin_accounts`, and a POOLED asset lock +/// (`ASSET_LOCK_FUNDING_SOURCES`) may take nothing from BIP44 and be funded +/// entirely out of the BIP32 account or a DashPay contact-receiving one. All +/// four families are therefore checked: the standard pair and CoinJoin at +/// `account_index`, then the DashPay receiving accounts, which span their own +/// indices and so are searched by txid alone. BIP44 stays first — it holds +/// every historical lock. 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()) - }) + let at_index = [ + accounts.standard_bip44_accounts.get(&account_index), + accounts.standard_bip32_accounts.get(&account_index), + accounts.coinjoin_accounts.get(&account_index), + ]; + at_index + .into_iter() + .flatten() + .chain(accounts.dashpay_receival_accounts.values()) + .find_map(|account| account.transactions().get(txid).cloned()) } /// Variant of [`record_or_persister`] that swallows persister errors @@ -645,6 +656,164 @@ mod tests { assert_eq!(found.txid, txid); } + /// BIP32-family regression for [`funding_tx_record`]: a POOLED asset + /// lock (`ASSET_LOCK_FUNDING_SOURCES`) may take nothing from BIP44 and + /// be funded entirely out of the BIP32 account, filing the record only + /// under `standard_bip32_accounts` — the lookup must still see it. + #[test] + fn funding_tx_record_finds_bip32_only_record() { + use key_wallet::test_utils::TestWalletContext; + + let mut ctx = TestWalletContext::new_random(); + let record = bip32_record_with_txid(0x55); + let txid = record.txid; + ctx.managed_wallet + .first_bip32_managed_account_mut() + .expect("default wallet has BIP32 account 0") + .transactions_mut() + .insert(txid, record); + + let found = funding_tx_record(&ctx.managed_wallet.accounts, 0, &txid) + .expect("BIP32-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([0x02; 32])).is_none() + ); + assert!(funding_tx_record(&ctx.managed_wallet.accounts, 9, &txid).is_none()); + } + + /// DashPay-family regression for [`funding_tx_record`]: the receiving + /// accounts span their own indices, so the lookup searches them by txid + /// alone. A record filed only under a contact-receiving account whose + /// OWN index (7) differs from the tracked source `account_index` (0) + /// must still be found. + #[test] + fn funding_tx_record_finds_dashpay_receival_record_across_indices() { + use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::ManagedCoreFundsAccount; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::{DerivationPath, ManagedAccountType, Network}; + + let mut ctx = TestWalletContext::new_random(); + + let user_identity_id = [0xAB; 32]; + let friend_identity_id = [0xCD; 32]; + let addresses = AddressPool::new( + DerivationPath::master(), + AddressPoolType::Absent, + 20, + Network::Testnet, + &KeySource::NoKeySource, + ) + .expect("single DashPay address pool"); + let mut account = ManagedCoreFundsAccount::new( + ManagedAccountType::DashpayReceivingFunds { + index: 7, + user_identity_id, + friend_identity_id, + addresses, + }, + Network::Testnet, + ); + + let record = dashpay_record_with_txid(0x66, 7, user_identity_id, friend_identity_id); + let txid = record.txid; + account.transactions_mut().insert(txid, record); + ctx.managed_wallet + .accounts + .dashpay_receival_accounts + .insert( + DashpayAccountKey { + index: 7, + user_identity_id, + friend_identity_id, + }, + account, + ); + + // The tracked source index (0) does not match the account's own + // index (7), yet the record is found — DashPay receiving accounts + // are searched by txid, not by the tracked source index. + let found = funding_tx_record(&ctx.managed_wallet.accounts, 0, &txid) + .expect("DashPay receival record must be found regardless of account_index"); + assert_eq!(found.txid, txid); + + // Even an account_index matching no account in any family still + // resolves the DashPay record — the search is index-independent. + let found_any_index = funding_tx_record(&ctx.managed_wallet.accounts, 9, &txid) + .expect("DashPay lookup is index-independent"); + assert_eq!(found_any_index.txid, txid); + + // Unknown txid is still a clean miss. + assert!( + funding_tx_record(&ctx.managed_wallet.accounts, 0, &Txid::from([0x03; 32])).is_none() + ); + } + + /// [`record_with_txid`] sibling filed as a BIP32-account record. + fn bip32_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::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ) + } + + /// [`record_with_txid`] sibling filed as a DashPay contact-receiving + /// account record. + fn dashpay_record_with_txid( + seed: u8, + index: u32, + user_identity_id: [u8; 32], + friend_identity_id: [u8; 32], + ) -> 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::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ) + } + /// [`record_with_txid`] sibling filed as a CoinJoin-account record. fn coinjoin_record_with_txid(seed: u8) -> TransactionRecord { let tx = Transaction { diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index dde68216369..7afdd62c26e 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -153,18 +153,23 @@ fn chain_proof( } } -/// Find the fund-bearing account (BIP44 first, then CoinJoin — same -/// family order as `funding_tx_record` in `sync::proof`) that also -/// recorded `txid`, i.e. the account whose UTXOs funded the asset -/// lock. Falls back to 0 when no sibling record exists (the lookup -/// paths that consume `account_index` degrade to the persister -/// fallback on a miss, so a wrong-but-plausible index only costs a -/// round-trip). +/// Find the fund-bearing account (BIP44, then BIP32, then CoinJoin — +/// the same family order as `funding_tx_record` in `sync::proof`) that +/// also recorded `txid`, i.e. the account whose UTXOs funded the asset +/// lock. A pooled build can be funded EXCLUSIVELY from a BIP32 account +/// at a nonzero index; omitting that family here stored the fallback 0, +/// and the pre-final proof wait then queried an index that holds no +/// record — with `NoPlatformPersistence` there is no fallback, so the +/// wait parked forever even after finality. Falls back to 0 only when +/// no sibling record exists in ANY searched family (the lookup paths +/// that consume `account_index` degrade to the persister fallback on a +/// miss, so a wrong-but-plausible index only costs a round-trip). fn funding_account_index(info: &PlatformWalletInfo, txid: &dashcore::Txid) -> u32 { let accounts = &info.core_wallet.accounts; accounts .standard_bip44_accounts .iter() + .chain(accounts.standard_bip32_accounts.iter()) .chain(accounts.coinjoin_accounts.iter()) .find(|(_, account)| account.transactions().contains_key(txid)) .map(|(index, _)| *index) @@ -528,6 +533,78 @@ mod tests { (wallet_manager, wallet_id, tx) } + /// A lock funded EXCLUSIVELY from a BIP32 account at a NONZERO index + /// must recover that index. The pre-fix inference searched only BIP44 + /// and CoinJoin, stored the fallback 0, and the pre-final proof wait + /// then queried an index holding no record — with + /// `NoPlatformPersistence` there is no fallback, so the wait parked + /// forever even after finality. + #[test] + fn funding_account_index_recovers_nonzero_bip32_index() { + use std::collections::BTreeMap; + + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::ManagedCoreFundsAccount; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::{DerivationPath, ManagedAccountType}; + + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + + let mut ctx = TestWalletContext::new_random(); + let pool = || { + AddressPool::new( + DerivationPath::master(), + AddressPoolType::Absent, + 20, + Network::Testnet, + &KeySource::NoKeySource, + ) + .expect("address pool") + }; + let mut account = ManagedCoreFundsAccount::new( + ManagedAccountType::Standard { + index: 7, + standard_account_type: StandardAccountType::BIP32Account, + external_addresses: pool(), + internal_addresses: pool(), + }, + Network::Testnet, + ); + + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[5_000]); + let txid = tx.txid(); + account.transactions_mut().insert( + txid, + record_for( + &tx, + AccountType::Standard { + index: 7, + standard_account_type: StandardAccountType::BIP32Account, + }, + chainlocked_context(9), + ), + ); + ctx.managed_wallet + .accounts + .standard_bip32_accounts + .insert(7, account); + + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: std::sync::Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + }; + assert_eq!( + funding_account_index(&info, &txid), + 7, + "the BIP32 sibling record identifies the true source index; \ + falling back to 0 parks the proof wait forever" + ); + } + fn chainlocked_context(height: u32) -> TransactionContext { TransactionContext::InChainLockedBlock(BlockInfo::new( height, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs index f8b06e93562..e44f85e9da2 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -110,7 +110,12 @@ pub struct TrackedAssetLock { /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, pub transaction: Transaction, - /// BIP44 account index that funded this asset lock (UTXO source). + /// Index the funding sources were resolved at — the standard + /// (BIP44/BIP32) family index handed to the build. NOT a record of + /// which accounts ended up supplying inputs: funding is pooled, so a + /// lock can be funded wholly out of a DashPay contact account, which + /// carries an index of its own. Consumers that need the funding + /// transaction search every family (see `funding_tx_record`). pub account_index: u32, pub funding_type: AssetLockFundingType, pub identity_index: u32, diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 1ecb26a7141..dbc42285aa2 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -10,5 +10,5 @@ pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; pub use generation::WalletGeneration; -pub use transaction::{SignedCoreTransaction, SEND_FUNDING_SOURCES}; +pub use transaction::{SignedCoreTransaction, ASSET_LOCK_FUNDING_SOURCES, SEND_FUNDING_SOURCES}; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 2987d01518b..3ee44e049f6 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -242,6 +242,23 @@ pub const SEND_FUNDING_SOURCES: [AccountTypePreference; 3] = [ AccountTypePreference::AllDashpayReceivingFunds, ]; +/// The funding sources an **asset lock** pools by default — the same set, and +/// for the same reasons, as [`SEND_FUNDING_SOURCES`]: an invitation, identity +/// registration or top-up draws from the union of the standard families and +/// every DashPay contact-receiving account, with change returning to BIP44. +/// +/// Before this, an asset lock could only be funded from one BIP44 account, so a +/// wallet holding its balance across several accounts had to sweep them into +/// that account first and lock out of it — an extra on-chain hop, an extra fee, +/// and a transparent address reused for the privilege. Pooling ends that +/// sweep-then-lock shape. +/// +/// CoinJoin is absent here for a second reason on top of the linkage one: +/// upstream rejects a CoinJoin source pooled with any other, and CoinJoin +/// asset-lock funding is drain-only. That flow keeps naming its single account, +/// through `AssetLockBuildAmount::DrainAll`. +pub const ASSET_LOCK_FUNDING_SOURCES: [AccountTypePreference; 3] = SEND_FUNDING_SOURCES; + /// The concrete accounts `preference` resolves to at `source_index` — the /// platform mirror of key-wallet's private `account_types_for`: the single /// account at `source_index` for the standard families, and every DashPay diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 760b039bec0..a2df4587226 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -265,7 +265,12 @@ impl IdentityWallet { // Build + broadcast the voucher asset lock at the invitation funding // account (the builder auto-selects the next unused funding index and - // returns its derivation path). `identity_index` is unused for the + // returns its derivation path). The DASH backing it is POOLED across + // `ASSET_LOCK_FUNDING_SOURCES` — the BIP44 and BIP32 accounts at + // `funding_account_index` plus every DashPay contact-receiving account + // — so an invitation can be funded from a balance spread across + // accounts, without the sweep-then-lock hop that used to be required. + // `identity_index` is unused for the // `IdentityInvitation` funding type. Only the broadcast half runs here — // the proof wait is deferred until AFTER the invitation record below is // durably persisted, so an interruption during the (potentially long) 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 a8f71bc4775..dffc04aa9ca 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 @@ -30,9 +30,12 @@ impl IdentityWallet { /// /// * `identity_id` - The identifier of the identity to top up. /// * `amount_duffs` - Amount of Dash (in duffs) to add. - /// * `account_index` - BIP44 standard-account index to draw the - /// funding UTXOs from. Only BIP44 standard accounts are - /// supported today (CoinJoin / BIP32 are out of scope). + /// * `account_index` - Index addressing the standard (BIP44/BIP32) + /// families of the pooled funding set. The top-up draws from the + /// union of those two accounts and every DashPay contact-receiving + /// account (which span their own indices); CoinJoin stays out of + /// the pool, since mixing it with transparent coins in one + /// transaction would undo the mixing. /// * `asset_lock_signer` - External ECDSA signer that produces both /// the funding-input P2PKH signatures during asset-lock build and /// the consume-phase outer signature on the IdentityTopUp diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 365d6514229..dc95dce4844 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -1,11 +1,13 @@ //! Broadcast-side UTXO reservation cleanup. //! -//! `TransactionBuilder::build_signed` reserves the selected UTXOs in the -//! funding account's `ReservationSet` and leaves the reservation held on -//! success, expecting the transaction to be broadcast. When the broadcast -//! *fails* the reservation must be reconciled here: released for an immediate -//! retry when Core definitively rejected the transaction, kept (for the -//! reservation-TTL backstop or a later sync) when acceptance is unknown. +//! `TransactionBuilder::build_signed` reserves the selected UTXOs in each +//! contributing funding account's `ReservationSet` and leaves the reservation +//! held on success, expecting the transaction to be broadcast. When the +//! broadcast *fails* the reservation must be reconciled here: released for an +//! immediate retry when Core definitively rejected the transaction, kept (for +//! the reservation-TTL backstop or a later sync) when acceptance is unknown. +//! A pooled build reserves across several accounts under one owner token, so +//! the reconciliation takes the whole contributor list, not one account. //! //! Every build-then-broadcast path must go through //! [`broadcast_releasing_on_rejection`] so the cleanup exists once instead of @@ -16,6 +18,7 @@ use dashcore::{Transaction, Txid}; use key_wallet::account::account_type::StandardAccountType; +use key_wallet::account::AccountType; use key_wallet_manager::WalletManager; use tokio::sync::RwLock; @@ -54,7 +57,10 @@ pub(crate) async fn broadcast_releasing_on_rejection>, wallet_id: &WalletId, - funding_account: ReservedFundingAccount, + funding_accounts: &[AccountType], tx: &Transaction, reservation_token: Option, ) { @@ -99,40 +100,33 @@ pub(crate) async fn release_reservation_after_rejected_broadcast( // untouched, so a read lock suffices — this cleanup does not // serialize concurrent sends. let wm = wallet_manager.read().await; - let account = wm - .get_wallet_and_info(wallet_id) - .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 - .accounts - .coinjoin_accounts - .get(&account_index), - }); - match account { - // 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!( + let Some((_, info)) = wm.get_wallet_and_info(wallet_id) else { + tracing::warn!( wallet_id = %hex::encode(wallet_id), - ?funding_account, - "could not release UTXO reservation after rejected broadcast: \ - wallet or funds account not found" - ), + ?funding_accounts, + "could not release UTXO reservation after rejected broadcast: wallet not found" + ); + return; + }; + for funding_account in funding_accounts { + match info.core_wallet.accounts.funds_account(funding_account) { + // 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), + ?funding_account, + "could not release UTXO reservation after rejected broadcast: \ + funds account not found" + ), + } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 63a8308b0de..43d5b8e3bd2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -658,10 +658,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// /// - 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). + /// - fundingAccountIndex: standard-family source index — the asset + /// lock POOLS the BIP44 and BIP32 accounts at that index together + /// with every DashPay receiving account (change returns to BIP44); + /// the index does not restrict which DashPay receiving accounts + /// contribute. CoinJoin remains drain-only and separate. /// - platformAccountIndex: Platform-payment account containing /// `recipients`. Used for the membership pre-flight on the Rust /// side and the post-success balance write. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 5e0a74c1eaa..2b3692e6344 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3863,13 +3863,13 @@ extension ManagedPlatformWallet { /// `KeychainSigner`. Asset-lock proof is built Rust-side from /// `amountDuffs` (wallet must have spendable Core UTXOs). /// - /// `accountIndex` selects which BIP44 *standard* account (by - /// BIP44 account index) supplies the funding UTXOs. Only BIP44 - /// standard accounts are supported today; the caller is - /// responsible for filtering its account picker accordingly — - /// CoinJoin / BIP32 funding for new-identity registration is not - /// yet wired through `create_funded_asset_lock_proof` on the Rust - /// side. + /// `accountIndex` addresses the *standard* families: the asset + /// lock POOLS the BIP44 and BIP32 accounts at that index together + /// with every DashPay contact-receiving account (change returns + /// to BIP44). The index does NOT restrict which DashPay receiving + /// accounts contribute, so the caller must not present it as an + /// account-scoped funding or privacy choice. CoinJoin funding + /// remains drain-only and is not reachable here. /// /// Caller MUST pre-derive `identityPubkeys` (typically via /// `dash_sdk_derive_identity_keys_from_mnemonic`) AND pre-persist @@ -4091,9 +4091,12 @@ extension ManagedPlatformWallet { /// Simpler than registration: an `IdentityTopUp` creates no identity /// keys, so there is no per-identity-key `KeychainSigner` and no pubkey /// array — the transition is signed entirely by the asset lock's - /// Core-side key via a `MnemonicResolver`. `accountIndex` selects which - /// BIP44 *standard* account supplies the funding UTXOs (same constraint - /// as registration). + /// Core-side key via a `MnemonicResolver`. `accountIndex` addresses the + /// *standard* families: the asset lock POOLS the BIP44 and BIP32 + /// accounts at that index together with every DashPay contact-receiving + /// account (change returns to BIP44), and does NOT restrict which + /// DashPay receiving accounts contribute — the same contract as + /// registration. /// /// `amountDuffs` must meet the Rust-side minimum top-up asset-lock /// balance; a smaller amount is rejected before any lock is broadcast diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 15458b3ca5b..cc1f219e19f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -107,8 +107,10 @@ extension PlatformWalletManager { /// - Parameters: /// - walletId: 32-byte wallet identifier (the same key /// `bindShielded` uses to look up the bound subwallet). - /// - fundingAccountIndex: BIP44 Core account whose UTXOs fund - /// the asset lock. + /// - fundingAccountIndex: standard-family index the asset lock + /// funds from — POOLS the BIP44 and BIP32 accounts at that + /// index with every DashPay receiving account (change returns + /// to BIP44); it does not restrict DashPay contributions. /// - amountDuffs: L1 amount to lock in Core duffs. Must be /// large enough to cover `recipients.credits + Platform fee`; /// undersized locks fail at Platform submission. @@ -383,8 +385,10 @@ extension PlatformWalletManager { /// each batch's real note (must be bound). /// - targetTotalNotes: drive the on-chain pool note count up to (at /// least) this value. A no-op if the pool already has this many. - /// - fundingAccountIndex: Core BIP44 account whose UTXOs fund each - /// per-batch asset lock. + /// - fundingAccountIndex: standard-family index each per-batch + /// asset lock funds from — POOLS the BIP44 and BIP32 accounts at + /// that index with every DashPay receiving account (change + /// returns to BIP44); it does not restrict DashPay contributions. /// - progress: optional live-progress handler (see above). public func seedShieldedPoolNotes( walletId: Data,