diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 7491999e283..583ccbeb84c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -72,8 +72,8 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, - ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, - UtxoRestoreEntryFFI, WalletRestoreEntryFFI, + ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnconfirmedOutgoingTxRecordFFI, + UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -4972,6 +4972,108 @@ impl Drop for LoadGuard { } } +/// Decode the unconfirmed outgoing sends the host staged for replay. +/// +/// Fail-closed on identity: a record must decode AND hash to the txid of the +/// row it was selected from. The replay applies each transaction through the +/// ordinary state-update path, so bytes that do not belong to that row would +/// move accounting for inputs and outputs unrelated to the send — a stale or +/// partially-written `transactionData` must drop out rather than be applied. +fn decode_unconfirmed_outgoing( + entry: &WalletRestoreEntryFFI, +) -> Vec { + use dashcore::consensus::Decodable; + use dashcore::hashes::Hash; + let recs: &[UnconfirmedOutgoingTxRecordFFI] = if entry.unconfirmed_outgoing_tx_records.is_null() + || entry.unconfirmed_outgoing_tx_records_count == 0 + { + &[] + } else { + unsafe { + slice::from_raw_parts( + entry.unconfirmed_outgoing_tx_records, + entry.unconfirmed_outgoing_tx_records_count, + ) + } + }; + let mut decoded: Vec<(u64, dashcore::blockdata::transaction::Transaction)> = + Vec::with_capacity(recs.len()); + let mut dropped_decode = 0usize; + let mut dropped_identity = 0usize; + for rec in recs { + if rec.tx_bytes.is_null() || rec.tx_bytes_len == 0 { + dropped_decode += 1; + continue; + } + let bytes = unsafe { slice::from_raw_parts(rec.tx_bytes, rec.tx_bytes_len) }; + match dashcore::blockdata::transaction::Transaction::consensus_decode(&mut &bytes[..]) { + // The bytes must be the row they were selected from. The + // replay runs through the ordinary state-update path, so a + // stale or partially-written `transactionData` would apply a + // different transaction and move accounting for inputs and + // outputs unrelated to this send. + Ok(tx) if *tx.txid().as_byte_array() == rec.txid => decoded.push((rec.first_seen, tx)), + Ok(tx) => { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + expected = %hex::encode(rec.txid), + decoded = %tx.txid(), + "load: unconfirmed outgoing record does not hash to its row; dropped" + ); + dropped_identity += 1; + } + Err(_) => dropped_decode += 1, + } + } + if dropped_decode > 0 || dropped_identity > 0 { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + dropped_decode, + dropped_identity, + "load: unconfirmed outgoing tx records were dropped" + ); + } + order_unconfirmed_outgoing(decoded) +} + +/// Put a batch of unconfirmed outgoing sends into replay order. +/// +/// `first_seen` establishes the baseline, but the host records it in whole +/// seconds, so two sends a moment apart share one and their relative order is +/// undefined. A dependency pass then moves any send that spends another send +/// in the same batch behind it — replaying a child first leaves it with no +/// input to spend, so it is discarded as irrelevant and that send's replay is +/// silently lost. +fn order_unconfirmed_outgoing( + mut decoded: Vec<(u64, dashcore::blockdata::transaction::Transaction)>, +) -> Vec { + decoded.sort_by_key(|(first_seen, _)| *first_seen); + + let in_batch: std::collections::HashSet<_> = decoded.iter().map(|(_, tx)| tx.txid()).collect(); + let mut emitted: std::collections::HashSet<_> = std::collections::HashSet::new(); + let mut ordered = Vec::with_capacity(decoded.len()); + let mut queue: std::collections::VecDeque<_> = decoded.into_iter().collect(); + // Bounded: a full lap with nothing emitted means the remainder depends on + // itself, which valid transactions cannot do. Emit in `first_seen` order + // rather than spin. + let mut passed_over = 0usize; + while let Some((first_seen, tx)) = queue.pop_front() { + let waits_on_batch_peer = tx.input.iter().any(|input| { + let parent = input.previous_output.txid; + in_batch.contains(&parent) && !emitted.contains(&parent) + }); + if waits_on_batch_peer && passed_over <= queue.len() { + queue.push_back((first_seen, tx)); + passed_over += 1; + continue; + } + emitted.insert(tx.txid()); + ordered.push(tx); + passed_over = 0; + } + ordered +} + /// Map a provider-account rebuild failure to a load error naming the /// curve-specific constructor or `AccountCollection` insert that failed. fn provider_rebuild_error( @@ -5612,11 +5714,31 @@ fn build_wallet_start_state( // was interrupted by an app kill can resume from the latest // status without rebroadcasting. let unused_asset_locks = build_unused_asset_locks(entry)?; + + // Decode the sends the host still holds as unconfirmed. Decode + // only: applying the spend needs `check_core_transaction`, which is + // async and wants the `Wallet` and the `ManagedWalletInfo` + // together, so the replay happens at the async boundary in + // `manager::load::load_from_persistor`. See + // `ClientWalletStartState::unconfirmed_outgoing_txs`. + // + // Ordered so a parent send is replayed before a child that spends its + // change — a child applied first finds its input absent and is dropped + // as irrelevant, silently losing that send's replay. + // + // `first_seen` alone cannot express this: the host stores it in whole + // seconds, and two sends a moment apart share one. So the `first_seen` + // sort only establishes a stable starting order, and a dependency pass + // then moves any send that spends another send in the same batch behind + // it. + let unconfirmed_outgoing_txs = decode_unconfirmed_outgoing(entry); + let wallet_state = ClientWalletStartState { wallet, wallet_info, identity_manager, unused_asset_locks, + unconfirmed_outgoing_txs, }; let platform_address_state = if per_account.is_empty() @@ -6786,6 +6908,163 @@ mod tests { //! restoration loops that don't need the full FFI plumbing — //! exercising the in-memory mutation against synthetic input. + mod unconfirmed_outgoing_order { + use super::super::order_unconfirmed_outgoing; + use dashcore::blockdata::transaction::Transaction; + use dashcore::{OutPoint, ScriptBuf, TxIn, TxOut}; + + fn tx_spending(parents: &[(dashcore::Txid, u32)], value: u64) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: parents + .iter() + .map(|(txid, vout)| TxIn { + previous_output: OutPoint { + txid: *txid, + vout: *vout, + }, + script_sig: ScriptBuf::new(), + sequence: 0xffff_ffff, + witness: Default::default(), + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: ScriptBuf::new(), + }], + special_transaction_payload: None, + } + } + + fn root(value: u64) -> Transaction { + tx_spending( + &[( + "0000000000000000000000000000000000000000000000000000000000000001" + .parse() + .expect("static txid"), + 0, + )], + value, + ) + } + + /// The host stores `first_seen` in whole seconds, so a parent and the + /// child spending its change can share one. Replaying the child first + /// leaves it with no input and it is dropped as irrelevant — that + /// send's replay is then silently lost, which is the whole failure + /// this ordering exists to prevent. + #[test] + fn a_child_sharing_its_parents_second_is_replayed_after_it() { + let parent = root(50_000); + let child = tx_spending(&[(parent.txid(), 0)], 40_000); + + // Child offered first, identical timestamps: nothing but the + // dependency pass can separate them. + let ordered = order_unconfirmed_outgoing(vec![ + (1_700_000_000, child.clone()), + (1_700_000_000, parent.clone()), + ]); + + assert_eq!( + ordered.iter().map(|tx| tx.txid()).collect::>(), + vec![parent.txid(), child.txid()], + "the parent must be replayed before the child that spends it" + ); + } + + /// A chain of three, offered fully reversed and all in one second. + #[test] + fn a_reversed_chain_is_restored_to_dependency_order() { + let a = root(90_000); + let b = tx_spending(&[(a.txid(), 0)], 80_000); + let c = tx_spending(&[(b.txid(), 0)], 70_000); + + let ordered = order_unconfirmed_outgoing(vec![ + (1_700_000_000, c.clone()), + (1_700_000_000, b.clone()), + (1_700_000_000, a.clone()), + ]); + + assert_eq!( + ordered.iter().map(|tx| tx.txid()).collect::>(), + vec![a.txid(), b.txid(), c.txid()] + ); + } + + /// A record whose bytes do not hash to the txid of the row it came + /// from is dropped, not replayed. + /// + /// The replay runs through the ordinary state-update path, so a stale + /// or partially-written `transactionData` would not merely be ignored + /// — it would move accounting for whatever inputs and outputs those + /// bytes happen to describe. + #[test] + fn a_record_that_does_not_hash_to_its_row_is_dropped() { + use crate::wallet_restore_types::{ + UnconfirmedOutgoingTxRecordFFI, WalletRestoreEntryFFI, + }; + use dashcore::consensus::encode::serialize; + + let honest = root(50_000); + let impostor = root(60_000); + + let mut honest_bytes = serialize(&honest); + let mut impostor_bytes = serialize(&impostor); + let honest_txid = *dashcore::hashes::Hash::as_byte_array(&honest.txid()); + let impostor_txid = *dashcore::hashes::Hash::as_byte_array(&impostor.txid()); + + let records = [ + UnconfirmedOutgoingTxRecordFFI { + txid: honest_txid, + tx_bytes: honest_bytes.as_mut_ptr(), + tx_bytes_len: honest_bytes.len(), + first_seen: 1_700_000_000, + }, + // Same shape, but the bytes belong to a different transaction. + UnconfirmedOutgoingTxRecordFFI { + txid: impostor_txid, + tx_bytes: honest_bytes.as_mut_ptr(), + tx_bytes_len: honest_bytes.len(), + first_seen: 1_700_000_001, + }, + ]; + + let entry = WalletRestoreEntryFFI { + unconfirmed_outgoing_tx_records: records.as_ptr(), + unconfirmed_outgoing_tx_records_count: records.len(), + ..Default::default() + }; + + let decoded = super::super::decode_unconfirmed_outgoing(&entry); + + assert_eq!( + decoded.iter().map(|tx| tx.txid()).collect::>(), + vec![honest.txid()], + "only the record whose bytes match its row may be replayed" + ); + let _ = impostor_bytes.as_mut_ptr(); + } + + /// Sends that do not depend on each other keep the order `first_seen` + /// gave them — the dependency pass must not reshuffle the baseline. + #[test] + fn independent_sends_keep_their_first_seen_order() { + let older = root(10_000); + let newer = root(20_000); + + let ordered = order_unconfirmed_outgoing(vec![ + (1_700_000_050, newer.clone()), + (1_700_000_000, older.clone()), + ]); + + assert_eq!( + ordered.iter().map(|tx| tx.txid()).collect::>(), + vec![older.txid(), newer.txid()] + ); + } + } + use super::*; // --- persists_durably: the fail-closed durability attestation --- 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 c49be7de1b7..b3cc6faadbf 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -573,6 +573,29 @@ pub struct ProviderSpecialTxRestoreEntryFFI { pub first_seen: u64, } +/// One outgoing transaction the host still holds as unconfirmed, +/// replayed at load so its spend effect survives a restart. +#[repr(C)] +pub struct UnconfirmedOutgoingTxRecordFFI { + /// Wire-order txid of the row this record came from. + /// + /// The load path decodes `tx_bytes` and requires the result to hash to + /// this, then drops the record if it does not. The replay applies the + /// transaction through the ordinary state-update path, so bytes that do + /// not belong to the row Swift selected would rewrite accounting for + /// inputs and outputs nobody asked about. Fail closed instead. + pub txid: [u8; 32], + /// Consensus-encoded transaction body, the same wire format + /// `dashcore::consensus::encode::serialize` produces. Swift-owned + /// for the callback window; freed by `LoadWalletListFreeFn`. + pub tx_bytes: *mut u8, + pub tx_bytes_len: usize, + /// Host's `firstSeen` for the row, in seconds. The load path + /// replays in ascending order so a parent send is applied before a + /// child that spends its change. + pub first_seen: u64, +} + /// Per-wallet entry returned by `on_load_wallet_list_fn`. /// /// `accounts` points to a contiguous array of length `accounts_count`. @@ -670,6 +693,21 @@ pub struct WalletRestoreEntryFFI { /// re-apply a fresh chainlock. pub last_applied_chain_lock_bytes: *const u8, pub last_applied_chain_lock_bytes_len: usize, + /// Outgoing transactions the host still holds as unconfirmed + /// (mempool context, no block height), oldest `first_seen` first. + /// + /// Replayed at load through the ordinary mempool check so their + /// spend effect is restored — see + /// [`UnconfirmedOutgoingTxRecordFFI`]. `null` / `0` when the wallet + /// has none. Each entry's `tx_bytes` buffer is Swift-owned and + /// freed by `LoadWalletListFreeFn`. + /// + /// Appended at the end deliberately. This is a `#[repr(C)]` struct + /// shared across the FFI boundary, so a field inserted anywhere else + /// shifts the offsets of everything after it; keeping additions here + /// leaves every existing field where it was. + pub unconfirmed_outgoing_tx_records: *const UnconfirmedOutgoingTxRecordFFI, + pub unconfirmed_outgoing_tx_records_count: usize, } /// Every field named explicitly so that adding a field to this ABI struct @@ -708,6 +746,8 @@ impl Default for WalletRestoreEntryFFI { core_address_pools_count: 0, last_applied_chain_lock_bytes: std::ptr::null(), last_applied_chain_lock_bytes_len: 0, + unconfirmed_outgoing_tx_records: std::ptr::null(), + unconfirmed_outgoing_tx_records_count: 0, } } } diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index ba0c2e6354e..fe491fcc76b 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -163,7 +163,13 @@ windows-native-keyring-store = { version = "=1.0.0", optional = true } [dev-dependencies] # `test-utils` reaches `provider_key_test_wallet`, shared with # `platform-wallet`'s own `rebuild_provider_key_account` tests — see its use -# in `sqlite/provider_accounts.rs`'s test module. +# in `sqlite/provider_accounts.rs`'s test module. It also exposes +# `platform_wallet::test_support::funded_wallet_manager`, which +# `sqlite_sent_payment_verdict_durability.rs` needs to stand up a real +# `WalletManager` and drive the wallet-event adapter against this crate's +# SQLite persister. ONE entry: a second `platform-wallet` key in this table is +# a duplicate key, and cargo refuses to load the manifest at all — which takes +# the whole workspace down, `cargo fmt` included. platform-wallet = { path = "../rs-platform-wallet", features = ["test-utils"] } proptest = "1" assert_cmd = "2" @@ -192,10 +198,6 @@ serial_test = "3" # so `secrets_mock_store_test_util.rs` proves the feature gate from the # outside — `cfg(test)` alone would satisfy it from within the crate. platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers", "test-util"] } -# `test-utils` exposes `platform_wallet::test_support::funded_wallet_manager`, -# which `sqlite_sent_payment_verdict_durability.rs` needs to stand up a real -# `WalletManager` and drive the wallet-event adapter against this crate's -# SQLite persister. Additive over the production dep above. # The adapter is a tokio task; the durability test drives it and awaits its # join handle. Current-thread runtime only — nothing here needs threads. tokio = { version = "1", features = ["rt", "macros", "sync"] } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 03560ac2b89..fd865405121 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1854,6 +1854,11 @@ fn load_one_wallet( wallet_info, identity_manager, unused_asset_locks, + // This backend does not stage unconfirmed outgoing sends for replay + // yet; the FFI persister is the only producer today. Empty leaves the + // replay inert here, which is the behaviour this path had before the + // field existed. + unconfirmed_outgoing_txs: Vec::new(), }) } diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 83b6d860742..6eab3d88780 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; use crate::wallet::asset_lock::tracked::TrackedAssetLock; -use dashcore::OutPoint; +use dashcore::{OutPoint, Transaction}; use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; @@ -33,4 +33,31 @@ pub struct ClientWalletStartState { /// Asset locks that have not yet been consumed by an identity /// registration / top-up, keyed by account index → outpoint. pub unused_asset_locks: BTreeMap>, + /// Outgoing transactions the host still has as unconfirmed, in + /// `first_seen` order (a parent send precedes a child that spends + /// its change). + /// + /// These are decoded here but deliberately NOT applied here: the + /// spend has to travel through the normal + /// `check_core_transaction(.., Mempool, ..)` path so `update_utxos` + /// runs — dropping the input from `utxos` and recording it in + /// `spent_outpoints`. That call is async and needs the `Wallet` and + /// the `ManagedWalletInfo` together, so the replay happens at the + /// async boundary in + /// [`load_from_persistor`](crate::manager::load), not while this + /// snapshot is being built. + /// + /// # Why the replay exists + /// + /// The spend effect of an unconfirmed outgoing transaction is never + /// persisted: `isSpent` deliberately stays `false` on the input row + /// until the spending transaction reaches a block, because a + /// mempool-only sighting is reversible by eviction. A running app is + /// still correct — the effect lives in memory. Across a restart it + /// used to be recovered only by re-observing the transaction on the + /// network, which is impossible for one that never got there: the + /// input came back as spendable and the balance re-counted it, + /// permanently. Replaying the record at load restores exactly the + /// state the live process held. + pub unconfirmed_outgoing_txs: Vec, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index ae04b3b0633..1bbd0330172 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -10,8 +10,26 @@ use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; +use std::time::Duration; + +use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use key_wallet::transaction_checking::transaction_context::TransactionContext; +use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use super::{run_blocking_load, PlatformWalletManager}; +/// How long the load-time re-dispatch waits for the SPV transport before +/// giving up for this launch. Readiness means the client started AND at +/// least one peer is connected; zero peers turns a send into a definitive +/// rejection rather than a retry, so waiting is the cheaper mistake. +/// +/// Generous on purpose: a simulator reaches readiness in seconds, but a +/// cold device on a slow network can take far longer, and giving up early +/// silently defers the send to the next launch — the very delay this whole +/// path exists to remove. Nothing is blocked on the wait; it runs on a +/// detached task. +const RESEND_TRANSPORT_READY_WAIT: Duration = Duration::from_secs(90); + impl PlatformWalletManager

{ /// Load the full [`ClientStartState`] from the configured persister /// and rehydrate the manager's `wallet_manager` and `wallets` maps. @@ -89,6 +107,15 @@ impl PlatformWalletManager

{ // boundary with no Swift-side reset path, so transactional // semantics matter for this hydration API. let mut inserted_in_manager: Vec = Vec::new(); + // Re-dispatches owed by this load, held until the rollback point has + // passed. See the push site for why they cannot be spawned inline. + #[allow(clippy::type_complexity)] + let mut pending_resends: Vec<( + WalletId, + Arc, + Arc, + Vec, + )> = Vec::new(); // The generation travels with the id: a rollback may only remove the // registration THIS call published (see the rollback block below). let mut inserted_in_wallets: Vec<(WalletId, Arc)> = @@ -97,12 +124,63 @@ impl PlatformWalletManager

{ 'load: for (expected_wallet_id, wallet_state) in wallets { let ClientWalletStartState { - wallet, - wallet_info, + mut wallet, + mut wallet_info, identity_manager, unused_asset_locks, + unconfirmed_outgoing_txs, } = wallet_state; + // Replay the sends the host still holds as unconfirmed, before + // anything reads the restored balance. + // + // Their spend effect is never persisted: `isSpent` stays `false` + // on the input row until the spending transaction reaches a + // block, because a mempool-only sighting is reversible by + // eviction. So the UTXO restore above has just handed those + // inputs back as spendable. A live process was still correct — + // it held the effect in memory — and until now a restart + // recovered it only by re-observing the transaction on the + // network. A transaction that never reached the network cannot + // be re-observed, so its input stayed spendable for good and the + // balance re-counted the coin. + // + // Routing each record through the ordinary mempool check (rather + // than inserting it into `transactions_mut()` raw, the way the + // asset-lock record restore does) is the whole point: it runs + // `update_utxos`, which drops the input from `utxos` and records + // it in `spent_outpoints`, reproducing exactly the state the + // live process held. A raw insert would leave `spent_outpoints` + // empty AND make every later re-dispatch a no-op, because + // `has_transaction` would then report the record as not new. + // + // `update_state` and `update_balance` are both on: the balance + // this produces is what `generation.set(..)` mirrors a few lines + // below, and the UI reads that. + if !unconfirmed_outgoing_txs.is_empty() { + let mut replayed = 0usize; + for tx in &unconfirmed_outgoing_txs { + let result = wallet_info + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + if result.is_relevant { + replayed += 1; + } + } + tracing::info!( + wallet_id = %hex::encode(expected_wallet_id), + offered = unconfirmed_outgoing_txs.len(), + replayed, + "load: replayed unconfirmed outgoing sends" + ); + } + // Flatten the (account → outpoint → lock) map into the flat // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` // holds today. @@ -205,6 +283,55 @@ impl PlatformWalletManager

{ let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( &self.spv_manager, ))); + + // Give the replayed sends an owner again on the network side. + // + // dash-spv's rebroadcast timer is the only thing that retries a + // transaction whose broadcast saw no acceptance signal, and its + // `broadcasts` map is process-local: it is filled at the + // broadcast call and never seeded from persisted rows. So a send + // that did not reach the network before the app was closed had + // nobody left to resend it — measured, it never went out again. + // Re-dispatching here hands it back to that timer. + // + // Deliberately fire-and-forget on a detached task: this must not + // hold up the load, and the verdict is only logged. The platform + // broadcaster has one entry point and it waits for acceptance + // (`TransactionBroadcaster::broadcast` → `broadcast_and_wait`), + // which is harmless here — nothing is blocked on this task, and + // dash-spv has already taken ownership by the time the wait ends. + // The app registers no listener for that event, so a late + // `Uncertain` cannot surface a stray dialog. + // + // For the case this exists for — a send that never reached the + // network — `MaybeSent` is the EXPECTED answer, not a failure: + // the transaction goes out, no peer echoes it back inside the + // acceptance window, and the rebroadcast timer takes it from + // there. Logging that at warn would make the healthy path look + // broken. + // + // Safe against double-spending: this re-sends the SAME signed + // bytes, which is idempotent for the network, and `start_broadcast` + // is idempotent per txid. The real hazard would be re-dispatching + // without the accounting replay above — the input would be + // selectable again and this wallet could sign a conflicting + // transaction. That is why the two halves ship together. + if !unconfirmed_outgoing_txs.is_empty() { + // Queued, not spawned: a later iteration can still fail and + // roll this registration back, and a task already waiting on + // transport readiness would outlive it and rebroadcast for a + // wallet that no longer exists. Spawned after the rollback + // point instead, with the generation carried along so the + // task can tell whether the registration it was created for + // is still the live one. + pending_resends.push(( + wallet_id, + Arc::clone(&generation), + Arc::clone(&broadcaster), + unconfirmed_outgoing_txs, + )); + } + let platform_wallet = PlatformWallet::new( Arc::clone(&self.sdk), wallet_id, @@ -350,10 +477,167 @@ impl PlatformWalletManager

{ return Err(err); } + // Past the rollback point: every registration here is one this load + // actually committed, so the transactions now have a wallet to belong + // to for as long as it stays registered. + // + // Detached on purpose — nothing may block the load on transport + // readiness — which is why each task re-checks that its wallet is + // still the live registration before putting anything on the wire. A + // wallet removed while the task waits leaves the generation pointer + // pointing at nothing the map holds any more, and the re-dispatch is + // abandoned rather than broadcasting on behalf of a wallet that is + // gone. + for (wallet_id, generation, broadcaster, txs) in pending_resends { + let wallet_manager = Arc::clone(&self.wallet_manager); + tokio::spawn(async move { + if !broadcaster + .wait_until_ready(RESEND_TRANSPORT_READY_WAIT) + .await + { + tracing::warn!( + pending = txs.len(), + "load: broadcast transport not ready; leaving unconfirmed \ + sends for the next launch" + ); + return; + } + let total = txs.len(); + for (position, tx) in txs.into_iter().enumerate() { + let mut outcome = resend_one( + &wallet_id, + &generation, + &wallet_manager, + broadcaster.as_ref(), + &tx, + ) + .await; + + // Peers can go away between the readiness gate and the + // send. Nothing owns a transaction that never left, so + // wait for the transport once more and try again rather + // than deferring it to the next launch. + if outcome == ResendOutcome::NotSent + && broadcaster + .wait_until_ready(RESEND_TRANSPORT_READY_WAIT) + .await + { + outcome = resend_one( + &wallet_id, + &generation, + &wallet_manager, + broadcaster.as_ref(), + &tx, + ) + .await; + } + + match outcome { + ResendOutcome::Dispatched => {} + ResendOutcome::Abandoned => return, + // Stop the batch. A later transaction may spend this + // one's change, and dispatching it against an output + // the network has never seen would put a transaction + // on the wire that cannot be accepted. The remainder + // is offered again at the next launch. + ResendOutcome::NotSent => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + deferred = total - position, + "load: transport refused the send; leaving the rest of the \ + batch for the next launch" + ); + return; + } + } + } + }); + } + Ok(()) } } +/// What one re-dispatch attempt did, so a caller — and a test — can tell the +/// two apart without reading logs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResendOutcome { + /// The transaction was handed to the broadcaster. Whether the network + /// accepted it is deliberately not part of this answer: an unconfirmed + /// send with no acceptance signal is the case this path exists for. + Dispatched, + /// The wallet this transaction belongs to is no longer the live + /// registration, so nothing was sent and the rest of the batch is moot. + Abandoned, + /// The broadcaster proved no bytes reached the network — peers went away + /// between the readiness gate and the send. Nothing is tracking this + /// transaction, so the rest of the batch must not proceed: anything + /// spending its change would be built on an output the network has never + /// seen. + NotSent, +} + +/// Re-dispatch one unconfirmed send, under the wallet's lifecycle gate. +/// +/// Split out of the spawn loop so the part that has to be right can be tested +/// directly: the gate is held across BOTH the liveness check and the network +/// step, which is the whole contract of +/// [`WalletGeneration::payment_guard`](crate::wallet::core::WalletGeneration::payment_guard). +/// A bare `Arc::ptr_eq` is a point-in-time observation, and teardown can take +/// the exclusive side between it and the broadcast, so a removed wallet's +/// transaction would still go out. +/// +/// Taken per transaction rather than once around a batch: each broadcast waits +/// for an acceptance signal, and holding the gate across a whole batch would +/// stall a removal for as long as the batch takes. +/// +/// Lock order is the one the gate documents — gate first, wallet-manager read +/// lock second, never the reverse. +pub(super) async fn resend_one( + wallet_id: &WalletId, + generation: &Arc, + wallet_manager: &Arc< + tokio::sync::RwLock>, + >, + broadcaster: &dyn TransactionBroadcaster, + tx: &dashcore::Transaction, +) -> ResendOutcome { + let _payment = generation.payment_guard().await; + let still_live = { + let wm = wallet_manager.read().await; + wm.get_wallet_info(wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, generation)) + }; + if !still_live { + tracing::info!( + wallet_id = %hex::encode(wallet_id), + "load: wallet no longer registered; abandoning the re-dispatch" + ); + return ResendOutcome::Abandoned; + } + + let txid = tx.txid(); + match broadcaster.broadcast(tx).await { + Ok(_) => tracing::info!(%txid, "load: re-dispatched unconfirmed send, accepted"), + // Expected for the orphaned case: sent, no acceptance signal, now + // owned by the rebroadcast timer. + Err(BroadcastError::MaybeSent { reason }) => tracing::info!( + %txid, + %reason, + "load: re-dispatched unconfirmed send, no acceptance signal yet — \ + handed to the rebroadcast timer" + ), + // Provably never sent, so the timer never took ownership. Matched by + // name rather than as a catch-all: a variant added later should make + // this a compile error, not silently inherit "nothing was sent". + Err(e @ BroadcastError::Rejected { .. }) => { + tracing::warn!(%txid, error = ?e, "load: re-dispatch was not sent"); + return ResendOutcome::NotSent; + } + } + ResendOutcome::Dispatched +} + /// Of the registrations this load published, the ones a rollback may still /// take back: those whose map entry is *still the same generation* this call /// inserted. @@ -402,6 +686,70 @@ mod idempotent_load_tests { use crate::wallet::platform_wallet::WalletId; use crate::PlatformWalletManager; + /// Persister that hands back one wallet plus the outgoing sends the + /// host still holds as unconfirmed — the shape `loadWalletList` + /// produces for a send whose broadcast got no acceptance signal. + struct PendingSendPersister { + wallet: Wallet, + managed: ManagedWalletInfo, + pending: Vec, + } + + impl PlatformWalletPersistence for PendingSendPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + let mut wallets = BTreeMap::new(); + wallets.insert( + self.wallet.compute_wallet_id(), + ClientWalletStartState { + wallet: self.wallet.clone(), + wallet_info: self.managed.clone(), + identity_manager: IdentityManagerStartState::default(), + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: self.pending.clone(), + }, + ); + Ok(ClientStartState { + wallets, + ..Default::default() + }) + } + } + + /// A transaction spending `previous_output` to somewhere that is not + /// this wallet — enough for the mempool check to see the input leave. + fn spend_to(previous_output: dashcore::OutPoint, value: u64) -> dashcore::Transaction { + dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output, + script_sig: dashcore::ScriptBuf::new(), + sequence: 0xffff_ffff, + witness: Default::default(), + }], + output: vec![dashcore::TxOut { + value, + script_pubkey: dashcore::ScriptBuf::from_hex( + "76a914000000000000000000000000000000000000000088ac", + ) + .expect("static foreign p2pkh script"), + }], + special_transaction_payload: None, + } + } + /// Persister whose `load()` returns a single-wallet snapshot rebuilt /// fresh on every call — `load_from_persistor` moves `wallets` out of /// the returned state, so each hydration needs its own copy. Mirrors a @@ -435,6 +783,7 @@ mod idempotent_load_tests { wallet_info: self.managed.clone(), identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), }, ); Ok(ClientStartState { @@ -473,6 +822,7 @@ mod idempotent_load_tests { wallet_info: self.managed.clone(), identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), }; let mut wallets = BTreeMap::new(); wallets.insert(self.wallet.compute_wallet_id(), entry()); @@ -497,6 +847,301 @@ mod idempotent_load_tests { )) } + use crate::broadcaster::BroadcastError; + use std::time::Duration; + + /// Broadcaster that records what it was asked to send, so a test can + /// assert on the absence of a broadcast rather than on log output. + struct CountingBroadcaster { + sent: Arc>>, + } + + impl CountingBroadcaster { + fn new() -> Self { + Self { + sent: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } + } + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for CountingBroadcaster { + async fn broadcast( + &self, + transaction: &dashcore::Transaction, + ) -> Result { + let txid = transaction.txid(); + self.sent.lock().expect("sent mutex").push(txid); + // The answer the orphaned case actually gets: the bytes went out + // and nothing echoed them back. `resend_one` must treat this as a + // dispatch, not a failure. + Err(BroadcastError::MaybeSent { + reason: "test: no acceptance signal".to_string(), + }) + } + + async fn wait_until_ready(&self, _timeout: Duration) -> bool { + true + } + } + + /// A wallet still registered under the generation the resend was created + /// for gets its transaction put on the wire. `MaybeSent` is the expected + /// answer here and still counts as dispatched — treating it as a failure + /// is what made the healthy path look broken in the logs. + #[tokio::test] + async fn resend_dispatches_while_the_wallet_is_still_registered() { + let ctx = TestWalletContext::new_random(); + let wallet_id = ctx.wallet.compute_wallet_id(); + let manager = make_manager(SingleWalletPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let generation = Arc::clone( + manager + .get_wallet(&wallet_id) + .await + .expect("registered") + .generation(), + ); + + let broadcaster = CountingBroadcaster::new(); + let sent = Arc::clone(&broadcaster.sent); + let tx = spend_to( + dashcore::OutPoint { + txid: "0000000000000000000000000000000000000000000000000000000000000001" + .parse() + .expect("static txid"), + vout: 0, + }, + 10_000, + ); + + let outcome = super::resend_one( + &wallet_id, + &generation, + &manager.wallet_manager, + &broadcaster, + &tx, + ) + .await; + + assert_eq!(outcome, super::ResendOutcome::Dispatched); + assert_eq!( + *sent.lock().expect("sent mutex"), + vec![tx.txid()], + "the transaction must reach the broadcaster" + ); + } + + /// A broadcaster that proves nothing was sent must not be reported as a + /// dispatch. + /// + /// `Rejected` means no bytes reached the network, so dash-spv never took + /// the transaction into its rebroadcast set and nothing will retry it on + /// its own. Reporting it as dispatched would also let the caller continue + /// a dependent batch against an output the network has never seen. + #[tokio::test] + async fn resend_reports_not_sent_when_the_broadcaster_refuses() { + let ctx = TestWalletContext::new_random(); + let wallet_id = ctx.wallet.compute_wallet_id(); + let manager = make_manager(SingleWalletPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let generation = Arc::clone( + manager + .get_wallet(&wallet_id) + .await + .expect("registered") + .generation(), + ); + + let broadcaster = RejectingBroadcaster; + let tx = spend_to( + dashcore::OutPoint { + txid: "0000000000000000000000000000000000000000000000000000000000000003" + .parse() + .expect("static txid"), + vout: 0, + }, + 10_000, + ); + + let outcome = super::resend_one( + &wallet_id, + &generation, + &manager.wallet_manager, + &broadcaster, + &tx, + ) + .await; + + assert_eq!( + outcome, + super::ResendOutcome::NotSent, + "a provably unsent transaction has no owner and must say so" + ); + } + + /// Broadcaster that refuses every send the way zero connected peers does. + struct RejectingBroadcaster; + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for RejectingBroadcaster { + async fn broadcast( + &self, + _transaction: &dashcore::Transaction, + ) -> Result { + Err(BroadcastError::Rejected { + reason: "test: no connected peers".to_string(), + }) + } + + async fn wait_until_ready(&self, _timeout: Duration) -> bool { + true + } + } + + /// The guard this exists for: a wallet removed while the resend was + /// waiting must not have its transaction broadcast. + /// + /// Without the liveness check — or with it taken outside the lifecycle + /// gate, where teardown can slip past it — this sends on behalf of a + /// wallet the manager no longer has. Asserting on the broadcaster rather + /// than on a log line is the point: the check can be deleted and every + /// other test here still passes. + #[tokio::test] + async fn resend_is_abandoned_once_the_wallet_is_gone() { + let ctx = TestWalletContext::new_random(); + let wallet_id = ctx.wallet.compute_wallet_id(); + let manager = make_manager(SingleWalletPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let generation = Arc::clone( + manager + .get_wallet(&wallet_id) + .await + .expect("registered") + .generation(), + ); + + // The registration the resend was created for is gone. + manager + .wallet_manager + .write() + .await + .remove_wallet(&wallet_id) + .expect("the wallet this test just registered must be removable"); + + let broadcaster = CountingBroadcaster::new(); + let sent = Arc::clone(&broadcaster.sent); + let tx = spend_to( + dashcore::OutPoint { + txid: "0000000000000000000000000000000000000000000000000000000000000002" + .parse() + .expect("static txid"), + vout: 0, + }, + 10_000, + ); + + let outcome = super::resend_one( + &wallet_id, + &generation, + &manager.wallet_manager, + &broadcaster, + &tx, + ) + .await; + + assert_eq!(outcome, super::ResendOutcome::Abandoned); + assert!( + sent.lock().expect("sent mutex").is_empty(), + "nothing may be broadcast for a wallet that is no longer registered" + ); + } + + /// A send the host still holds as unconfirmed has to be replayed at + /// load, or the coin it spent comes back as spendable. + /// + /// The spend effect is never persisted — `isSpent` stays false on the + /// input row until the spending transaction reaches a block, because a + /// mempool-only sighting is reversible by eviction — so the restored + /// UTXO set hands that input straight back. A running app is still + /// correct, holding the effect in memory; across a restart it used to + /// be recovered only by re-observing the transaction on the network, + /// which never happens for a send that did not reach the network. The + /// balance then re-counted the coin, permanently (support ticket + /// 32189). + /// + /// Asserting on `balance()` rather than on the account internals is + /// deliberate: that is the number the UI reads, and it is mirrored + /// from the replayed state a few lines after the replay runs. + #[tokio::test] + async fn load_replays_an_unconfirmed_outgoing_send() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let funded_outpoint = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + + // Without a replay this is what the restore alone would leave + // standing, so it is also the failure the assertion below catches. + let spend = spend_to(funded_outpoint, 74_000); + let manager = make_pending_manager(PendingSendPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + pending: vec![spend], + }); + + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("the loaded wallet must be registered"); + let balance = wallet.balance(); + let total = balance.confirmed() + balance.unconfirmed(); + assert_eq!( + total, 0, + "the replayed send spends the only coin, so nothing may remain \ + spendable; {} duffs left means the input came back", + total + ); + } + + fn make_pending_manager( + persister: PendingSendPersister, + ) -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(persister), + event_handler, + )) + } + /// The app re-activates its per-network manager on every SDK emission, /// which re-runs `load_from_persistor` against a manager that already /// holds the persisted wallet. The second (and every later) call must diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index 30b67703db3..04c07d50832 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -1841,6 +1841,7 @@ mod tests { wallet_info: self.managed.clone(), identity_manager: crate::changeset::IdentityManagerStartState::default(), unused_asset_locks: std::collections::BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), }, ); Ok(crate::changeset::ClientStartState { diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 732534d25f0..333a719d2ea 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2582,6 +2582,11 @@ fn build_wallet_restore_entry( tracked_asset_locks_count: 0, unresolved_asset_lock_tx_records: ptr::null(), unresolved_asset_lock_tx_records_count: 0, + // Android does not stage unconfirmed outgoing sends yet: the replay + // that consumes them is wired on the iOS path only. Null/0 leaves it + // inert here, exactly as it was before the field existed. + unconfirmed_outgoing_tx_records: ptr::null(), + unconfirmed_outgoing_tx_records_count: 0, core_address_pools: ptr::null(), core_address_pools_count: 0, last_applied_chain_lock_bytes: ptr::null(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index dbda3c8fefe..e2dc2234eb1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -7142,6 +7142,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { entry.unresolved_asset_lock_tx_records = unresolvedBuf.map { UnsafePointer($0) } entry.unresolved_asset_lock_tx_records_count = UInt(unresolvedCount) + // Sends still unconfirmed on the host. Replayed Rust-side so + // their spend effect survives the restart; without it the + // input comes back spendable and the balance re-counts the + // coin — permanently, for a send that never reached the + // network. Asset-lock funding rows are excluded here because + // they already ride the array above and `resume_asset_lock` + // owns them. + let (unconfirmedBuf, unconfirmedCount) = + buildUnconfirmedOutgoingTxRecordBuffer( + rows: unspentBuckets[w.walletId] ?? [], + allocation: allocation, + excludingTxids: unresolvedAssetLockFundingTxids(walletId: w.walletId) + ) + entry.unconfirmed_outgoing_tx_records = unconfirmedBuf.map { UnsafePointer($0) } + entry.unconfirmed_outgoing_tx_records_count = UInt(unconfirmedCount) + // Provider special transactions (ProRegTx / ProUpServTx / // ProUpRegTx / ProUpRevTx) re-staged onto the provider-key // accounts so #876 retention keeps them and the masternode @@ -7643,6 +7659,127 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// transaction table) are skipped — the Rust side has no way to /// reconstruct a transaction without its consensus bytes, so /// projecting an empty row would just bloat the FFI surface. + /// Project the sends this wallet still holds as unconfirmed into the + /// FFI restore array, so the Rust load path can replay their spend + /// effect (see `ClientWalletStartState::unconfirmed_outgoing_txs`). + /// + /// Why this is needed at all: `spendIsInBlock` deliberately withholds + /// `isSpent` from an input whose spender is only in the mempool, + /// because that sighting is reversible by eviction. The UTXO restore + /// therefore hands the input back as spendable, and the balance + /// re-counts the coin. A running app never showed this — it held the + /// spend in memory — and a restart used to recover it only by + /// re-observing the transaction on the network, which never happens + /// for a send that did not reach the network in the first place. + /// + /// The selection is driven from the TXO side rather than the + /// transaction side, which makes the liveness rule fall out for free: + /// a row is offered only while one of *our* outputs still points at it + /// as its spender and is still unspent. A send that already lost a + /// conflict has had its inputs flipped by the winning spender, so it + /// drops out on its own — important, because the FFI restore does not + /// rebuild `observed_spent`, so Rust could not make that judgement. + /// + /// Asset-lock funding transactions are excluded: they ride + /// `unresolved_asset_lock_tx_records` and already have an owner in + /// `resume_asset_lock`. One owner per transaction. + /// + /// Takes the bucketed `isSpent == false` rows the caller already + /// fetched rather than querying by `walletId` again: that bucketing + /// routes a legacy row whose `walletId` was never backfilled through + /// `account.wallet.walletId`, and it prefetches `spendingTransaction`, + /// which this pass reads for every row. + /// Wire-order txids of the funding transactions already carried by + /// `unresolved_asset_lock_tx_records`. Read from the same rows that + /// buffer selects from, through the same decoder, rather than + /// re-deriving a txid from the serialized bytes. + private func unresolvedAssetLockFundingTxids(walletId: Data) -> Set? { + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.walletId == walletId && entry.statusRaw < 2 + } + ) + // `nil`, not an empty set, when the fetch fails: an empty exclusion + // set reads as "this wallet has no unresolved asset locks", which + // would let a funding transaction into the ordinary replay even + // though `resume_asset_lock` owns it. The caller offers nothing at + // all instead — one launch without a replay, rather than a + // transaction applied through the wrong path. + guard let locks = try? backgroundContext.fetch(descriptor) else { return nil } + return Set(locks.compactMap { Self.assetLockFundingTxid(outPointHex: $0.outPointHex) }) + } + + private func buildUnconfirmedOutgoingTxRecordBuffer( + rows txos: [PersistentTxo], + allocation: LoadAllocation, + excludingTxids excluded: Set? + ) -> (UnsafeMutablePointer?, Int) { + // Fail closed: without a trustworthy exclusion set we cannot tell an + // asset-lock funding transaction from an ordinary send. + guard let excluded else { + SDKLogger.event( + "persistence_unconfirmed_outgoing_skipped", + category: .persistence, + severity: .error, + fields: ["reason": .publicText("asset_lock_exclusion_fetch_failed")] + ) + return (nil, 0) + } + guard !txos.isEmpty else { return (nil, 0) } + + // Distinct spenders, still unconfirmed, still ours to replay. + var candidates: [Data: PersistentTransaction] = [:] + for txo in txos { + guard let spender = txo.spendingTransaction else { continue } + // Mirror `spendIsInBlock` exactly: it withholds `isSpent` for + // every context below `inBlock`, so an InstantSend-locked send + // (context 1) leaves its input unspent in the store too and needs + // the same replay. Filtering on `== 0` covered only half of that. + guard spender.context < TransactionContextType.inBlock.rawValue, + spender.blockHeight == 0 + else { continue } + guard !spender.transactionData.isEmpty else { continue } + guard !excluded.contains(spender.txid) else { continue } + candidates[spender.txid] = spender + } + guard !candidates.isEmpty else { return (nil, 0) } + + // Ascending `firstSeen`: a parent send must be replayed before a + // child that spends its change, or the child finds no input and is + // discarded as irrelevant. + let ordered = candidates.values.sorted { $0.firstSeen < $1.firstSeen } + + var entries: [UnconfirmedOutgoingTxRecordFFI] = [] + entries.reserveCapacity(ordered.count) + for row in ordered { + let txBytes = row.transactionData + // Carry the row's identity so Rust can refuse bytes that do not + // hash to it. The replay applies the transaction through the + // ordinary state-update path, so a stale or partially-written + // `transactionData` would move accounting for inputs and outputs + // that have nothing to do with this send. + guard row.txid.count == 32 else { continue } + let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) + txBytes.copyBytes(to: txBuf, count: txBytes.count) + allocation.scalarBuffers.append((txBuf, txBytes.count)) + var entry = UnconfirmedOutgoingTxRecordFFI() + withUnsafeMutableBytes(of: &entry.txid) { raw in + raw.copyBytes(from: row.txid) + } + entry.tx_bytes = txBuf + entry.tx_bytes_len = UInt(txBytes.count) + entry.first_seen = row.firstSeen + entries.append(entry) + } + + let buf = UnsafeMutablePointer.allocate( + capacity: entries.count + ) + buf.initialize(from: entries, count: entries.count) + allocation.unconfirmedOutgoingTxRecordArrays.append((buf, entries.count)) + return (buf, entries.count) + } + private func buildUnresolvedAssetLockTxRecordBuffer( walletId: Data, allocation: LoadAllocation @@ -8782,6 +8919,10 @@ private final class LoadAllocation { /// so the next chain-lock event can cascade-promote them. The /// `tx_bytes` buffer each row references lives in `scalarBuffers`. var unresolvedAssetLockTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] + /// `UnconfirmedOutgoingTxRecordFFI` arrays per wallet. The `tx_bytes` + /// each entry points at are staged on `scalarBuffers`, like the + /// asset-lock records above. + var unconfirmedOutgoingTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] /// Per-wallet `ProviderSpecialTxRestoreEntryFFI` arrays — provider /// special txs re-staged so #876 retention keeps them resident after a /// restart. The `tx_bytes` buffer each row references lives in @@ -8862,6 +9003,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in unconfirmedOutgoingTxRecordArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, count) in providerSpecialTxRecordArrays { ptr.deinitialize(count: count) ptr.deallocate() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/UnconfirmedOutgoingSendRestoreTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/UnconfirmedOutgoingSendRestoreTests.swift new file mode 100644 index 00000000000..18be5e43ffd --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/UnconfirmedOutgoingSendRestoreTests.swift @@ -0,0 +1,210 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the selection rule behind `unconfirmed_outgoing_tx_records`. +/// +/// The spend effect of an unconfirmed outgoing send is never persisted — +/// `spendIsInBlock` withholds `isSpent` from an input whose spender is only +/// in the mempool, because that sighting is reversible by eviction. The UTXO +/// restore therefore hands the input back as spendable, and unless the send +/// is replayed at load the balance re-counts the coin. For a send that never +/// reached the network there is nothing to re-observe, so it stays wrong. +/// +/// What these tests pin down is *which* rows may be offered for that replay. +/// The rule is driven from the TXO side on purpose: a send is offered only +/// while one of our own outputs still names it as its spender and is itself +/// still unspent. That makes liveness fall out for free — a send that lost a +/// conflict has had its input flipped by the winner and drops out on its own, +/// which matters because the FFI restore never rebuilds `observed_spent` and +/// Rust cannot make that judgement for itself. +@MainActor +final class UnconfirmedOutgoingSendRestoreTests: XCTestCase { + + private let walletId = Data(repeating: 0x07, count: 32) + private let fundingTxid = Data(repeating: 0x51, count: 32) + private let sendTxid = Data(repeating: 0x52, count: 32) + private let rivalTxid = Data(repeating: 0x53, count: 32) + private let fundingVout: UInt32 = 0 + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// A plain version-2 transaction spending `input` — the shape + /// `TransactionDecoder` parses, so the fixture cannot drift from what the + /// load path actually reads. + private func serializedSpend(of input: (txid: Data, vout: UInt32)) -> Data { + var bytes = Data() + bytes.append(contentsOf: withUnsafeBytes(of: UInt32(2).littleEndian) { Data($0) }) + bytes.append(0x01) + bytes.append(input.txid) + bytes.append(contentsOf: withUnsafeBytes(of: input.vout.littleEndian) { Data($0) }) + bytes.append(0x00) + bytes.append(contentsOf: [0xff, 0xff, 0xff, 0xff]) + bytes.append(0x01) + bytes.append(contentsOf: withUnsafeBytes(of: UInt64(9_000).littleEndian) { Data($0) }) + bytes.append(0x00) + bytes.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) + return bytes + } + + /// One funded coin, and one transaction recorded as spending it. + /// + /// - `sendContext`/`sendHeight`: the spender's settlement state. + /// - `inputStillOurs`: whether the coin still points at that spender and + /// is still unspent — `false` models a send that lost a conflict, where + /// the winning spender flipped the row. + /// - `legacyTxoWalletId`: a row migrated from the schema that never + /// backfilled `walletId`, whose ownership resolves through the account. + private func seed( + in container: ModelContainer, + sendContext: UInt32 = 0, + sendHeight: UInt32 = 0, + inputStillOurs: Bool = true, + legacyTxoWalletId: Bool = false + ) throws { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, + accountType: 0, + accountIndex: 0, + accountTypeName: "Standard" + ) + // A wallet only reaches the restore path with at least one account + // carrying an xpub — that is what Rust rebuilds the watch-only + // wallet from. + account.accountExtendedPubKeyBytes = Data(repeating: 0x30, count: 78) + context.insert(account) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x01, count: 10), + context: 2, + blockHeight: 100, + netAmount: 10_000 + ) + context.insert(funding) + + let send = PersistentTransaction( + txid: sendTxid, + transactionData: serializedSpend(of: (txid: fundingTxid, vout: fundingVout)), + context: sendContext, + blockHeight: sendHeight, + netAmount: -10_000 + ) + context.insert(send) + + let coin = PersistentTxo( + transaction: funding, + vout: fundingVout, + amount: 10_000, + address: "yFundAddr", + height: 100 + ) + coin.account = account + coin.walletId = legacyTxoWalletId ? Data() : walletId + + if inputStillOurs { + coin.isSpent = false + coin.spendingTransaction = send + } else { + // A different, settled transaction took the coin first. The + // winner's flip is what retires our send. + let rival = PersistentTransaction( + txid: rivalTxid, + transactionData: Data(repeating: 0x02, count: 10), + context: 2, + blockHeight: 101, + netAmount: -10_000 + ) + context.insert(rival) + coin.isSpent = true + coin.spendingTransaction = rival + } + context.insert(coin) + + try context.save() + } + + /// Drive the real load path and report how many sends were offered. + private func offeredCount(_ handler: PlatformWalletPersistenceHandler) -> Int { + let loaded = handler.loadWalletList() + XCTAssertFalse(loaded.errored, "the load must not fail") + guard let entries = loaded.entries, loaded.count > 0 else { return -1 } + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entries)) } + return Int(entries[0].unconfirmed_outgoing_tx_records_count) + } + + /// The case the fix exists for: an unconfirmed send whose input is still + /// ours and still unspent is offered for replay. + func testUnconfirmedSendIsOffered() throws { + let (handler, container) = try makeHandler() + try seed(in: container) + + XCTAssertEqual(offeredCount(handler), 1) + } + + /// A send that already lost a conflict must never be offered: replaying + /// it would re-spend a coin this wallet no longer owns, and + /// re-dispatching it would put a dead transaction back on the wire. + /// Nothing else can catch this — the FFI restore does not rebuild + /// `observed_spent`. + func testSendThatLostAConflictIsNotOffered() throws { + let (handler, container) = try makeHandler() + try seed(in: container, inputStillOurs: false) + + XCTAssertEqual( + offeredCount(handler), + 0, + "the winning spender flipped the input; our send is dead and must not be replayed" + ) + } + + /// An InstantSend-locked send is still unconfirmed as far as the store is + /// concerned — `spendIsInBlock` withholds `isSpent` for every context + /// below `inBlock`, so its input is handed back as spendable exactly like + /// a mempool send's. Filtering on `context == 0` covered only half of + /// that rule and left IS-locked sends out of the replay. + func testInstantSendLockedSendIsOffered() throws { + let (handler, container) = try makeHandler() + try seed(in: container, sendContext: 1) + + XCTAssertEqual( + offeredCount(handler), + 1, + "an IS-locked send has not reached a block, so its spend is not persisted either" + ) + } + + /// A settled send needs no replay: the chain already carries the spend, + /// and the ordinary restore path reconstructs it. + func testConfirmedSendIsNotOffered() throws { + let (handler, container) = try makeHandler() + try seed(in: container, sendContext: 2, sendHeight: 101) + + XCTAssertEqual(offeredCount(handler), 0) + } + + /// A row migrated from the older schema, where `walletId` was never + /// backfilled. Comparing that column raw would discard exactly these + /// rows and silently leave the balance wrong for the wallets most likely + /// to be carrying history — ownership has to resolve through the account, + /// which is why this pass consumes the caller's bucketed rows rather than + /// running its own `walletId` query. + func testLegacyTxoWithNoWalletIdIsStillOffered() throws { + let (handler, container) = try makeHandler() + try seed(in: container, legacyTxoWalletId: true) + + XCTAssertEqual( + offeredCount(handler), + 1, + "a legacy TXO resolving to this wallet through its account must not be discarded" + ) + } +}