diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 462d5c2bdd1..400fd8b87c9 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -317,24 +317,79 @@ impl WalletChangeSetFFI { // order (matters for the `inserted` -> `updated` transition // ordering inside a single BlockProcessed event). // + // Two record sources fill each account's bucket: + // - transaction rows come from `records` — wallet-level, + // same-txid slices folded (dashpay/platform#4387). Each + // folded row is emitted into the bucket of EVERY account + // that owns a slice of its txid, not just the funding + // account's: the Swift/Kotlin per-account transaction + // callback is the sole writer of the tx↔account involvement + // join (`involvedAccounts` / `transaction_account_ + // involvements`), which payload-only matches — a ProReg/ + // ProUp payload hitting a provider owner or voting key, no + // TXO in the account — depend on for restart restoration. + // Funding-bucket-only emission dropped that involvement and + // provider transactions vanished from restoration until a + // rescan. The row's VALUES are identical in every bucket + // (the persisted row is txid-keyed and account-agnostic), + // so duplicate upserts converge; only the enclosing bucket + // differs, which is exactly what the involvement join + // records. + // - TXO deltas come from `account_records` — the raw + // per-account slices — so every UTXO lands in its OWNING + // account's bucket. Deriving TXOs from the folded record + // filed a sibling account's change under the funding + // account (`OutputDetail` carries no owning account), and + // the Swift/Kotlin stores then restored it into the wrong + // account's map. Changesets whose producer doesn't + // populate `account_records` fall back to `records`. + // // `AccountType` doesn't implement `Ord` upstream (the // 256-bit `[u8; 32]` fields on the Dashpay variants would make - // a derived ordering arbitrary), so a `Vec<(key, bucket)>` + // a derived ordering arbitrary), so a `Vec<(key, rows, slices)>` // with a linear "find or insert" walk is the path of least // resistance. Wallets typically have well under a hundred // accounts, so the linear search is cheap. + let utxo_source: &Vec = + if cs.account_records.is_empty() { + &cs.records + } else { + &cs.account_records + }; + #[allow(clippy::type_complexity)] let mut by_account: Vec<( AccountType, Vec<&key_wallet::managed_account::transaction_record::TransactionRecord>, + Vec<&key_wallet::managed_account::transaction_record::TransactionRecord>, )> = Vec::new(); for rec in &cs.records { + // Every account with a slice of this txid is involved; the + // record's own account (the funder) is a target even in + // the no-slices fallback. Dedup keeps a bucket from + // receiving the same row twice if a producer ever carries + // a duplicate slice. + let mut targets: Vec = vec![rec.account_type]; + for slice in utxo_source.iter().filter(|s| s.txid == rec.txid) { + if !targets.contains(&slice.account_type) { + targets.push(slice.account_type); + } + } + for target in targets { + if let Some(bucket) = by_account.iter_mut().find(|(at, _, _)| at == &target) { + bucket.1.push(rec); + } else { + by_account.push((target, vec![rec], Vec::new())); + } + } + } + for rec in utxo_source { if let Some(bucket) = by_account .iter_mut() - .find(|(at, _)| at == &rec.account_type) + .find(|(at, _, _)| at == &rec.account_type) { - bucket.1.push(rec); + bucket.2.push(rec); } else { - by_account.push((rec.account_type, vec![rec])); + by_account.push((rec.account_type, Vec::new(), vec![rec])); } } @@ -347,31 +402,32 @@ impl WalletChangeSetFFI { // category. Without an empty bucket the watermark would be // silently dropped below. for account_type in cs.account_highest_used.keys() { - if !by_account.iter().any(|(at, _)| at == account_type) { - by_account.push((*account_type, Vec::new())); + if !by_account.iter().any(|(at, _, _)| at == account_type) { + by_account.push((*account_type, Vec::new(), Vec::new())); } } let mut ffi_accounts = Vec::with_capacity(by_account.len()); - for (account_type, recs) in by_account { + for (account_type, tx_rows, utxo_slices) in by_account { let type_name = CString::new(format!("{:?}", account_type)) .unwrap_or_else(|_| CString::new("Unknown").unwrap()); let account_index = account_index_of(&account_type); - // Derive UTXO add/spend lists from this account's records. - // Each record carries its own input_details and + // Derive UTXO add/spend lists from this account's SLICES. + // Each slice carries its own account's input_details and // output_details; we walk them once per record to project // the UTXOs the persister should add or remove. let mut utxos_added: Vec = Vec::new(); let mut utxos_spent: Vec = Vec::new(); - for rec in &recs { + for rec in &utxo_slices { utxos_added.extend(record_new_utxos_ffi(rec)); utxos_spent.extend(record_spent_outpoints_ffi(rec)); } - // Transactions for this account. + // Transaction rows for this account (wallet-level, + // folded — see the bucketing comment above). let transactions: Vec = - recs.into_iter().map(tx_record_to_ffi).collect(); + tx_rows.into_iter().map(tx_record_to_ffi).collect(); let utxos_added_count = utxos_added.len(); let utxos_spent_count = utxos_spent.len(); @@ -1499,6 +1555,260 @@ mod tests { unsafe { free_wallet_changeset_ffi(&ffi) }; } + /// A folded wallet-level record files the transaction row under the + /// FUNDING account while carrying the sibling account's owned + /// outputs (dashpay/platform#4387), and `OutputDetail` has no + /// owning-account field — so deriving TXOs from the folded record + /// persisted the sibling's change under the funding account, and + /// the Swift/Kotlin stores restored it into the wrong account's + /// map. TXO deltas must instead come from `account_records` (the + /// raw per-account slices), with only the transaction rows read + /// from the folded `records`. + #[test] + fn txos_route_to_their_owning_accounts_bucket() { + use dashcore::{Address, Network, OutPoint, ScriptBuf, TxIn, TxOut, Witness}; + use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::TransactionContext; + + let coinjoin = AccountType::CoinJoin { index: 0 }; + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let dest = Address::dummy(Network::Testnet, 1); + let change_addr = Address::dummy(Network::Testnet, 2); + let funded_addr = Address::dummy(Network::Testnet, 3); + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::default(), + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + }], + output: vec![ + TxOut { + value: 900_000, + script_pubkey: dest.script_pubkey(), + }, + TxOut { + value: 99_000, + script_pubkey: change_addr.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let our_input = InputDetail { + index: 0, + value: 1_000_000, + address: funded_addr.clone(), + }; + let sent = OutputDetail { + index: 0, + role: OutputRole::Sent, + address: Some(dest.clone()), + value: 900_000, + }; + let change = OutputDetail { + index: 1, + role: OutputRole::Change, + address: Some(change_addr.clone()), + value: 99_000, + }; + let rec = |account, direction, inputs: Vec, outputs, net| { + TransactionRecord::new( + tx.clone(), + account, + TransactionContext::Mempool, + TransactionType::Standard, + direction, + inputs, + outputs, + net, + ) + }; + // The CoinJoin slice funds the spend; its account-local view of + // the sibling's change is `Sent`. The BIP44 slice owns the + // change. The folded row carries the union with the owned role. + let coinjoin_slice = rec( + coinjoin, + TransactionDirection::Outgoing, + vec![our_input.clone()], + vec![ + sent.clone(), + OutputDetail { + role: OutputRole::Sent, + ..change.clone() + }, + ], + -1_000_000, + ); + let bip44_slice = rec( + bip44, + TransactionDirection::Incoming, + vec![], + vec![change.clone()], + 99_000, + ); + let folded = rec( + coinjoin, + TransactionDirection::Outgoing, + vec![our_input], + vec![sent, change], + -901_000, + ); + + let cs = CoreChangeSet { + records: vec![folded], + account_records: vec![coinjoin_slice, bip44_slice], + ..CoreChangeSet::default() + }; + let ffi = WalletChangeSetFFI::from_changeset(&cs); + assert_eq!(ffi.accounts_count, 2, "one bucket per involved account"); + let buckets = unsafe { std::slice::from_raw_parts(ffi.accounts, ffi.accounts_count) }; + let coinjoin_bucket = buckets + .iter() + .find(|b| b.type_tag == account_type_to_tags(&coinjoin).type_tag) + .expect("coinjoin bucket"); + let bip44_bucket = buckets + .iter() + .find(|b| b.type_tag == account_type_to_tags(&bip44).type_tag) + .expect("bip44 bucket"); + + assert_eq!( + coinjoin_bucket.transactions_count, 1, + "the folded wallet-level row files under the funding account" + ); + assert_eq!( + coinjoin_bucket.utxos_added_count, 0, + "the funding slice owns no outputs — the sibling's change \ + must NOT be derived from the folded record into this bucket" + ); + assert_eq!( + coinjoin_bucket.utxos_spent_count, 1, + "the spend stays with the account that owned the coin" + ); + assert_eq!( + bip44_bucket.transactions_count, 1, + "every involved account's bucket carries the folded row — the \ + per-account transaction callback is the sole writer of the \ + tx↔account involvement join" + ); + let coinjoin_row = unsafe { &*coinjoin_bucket.transactions }; + let bip44_row = unsafe { &*bip44_bucket.transactions }; + assert_eq!( + coinjoin_row.net_amount, bip44_row.net_amount, + "the row's wallet-level values are identical in every bucket" + ); + assert_eq!(coinjoin_row.net_amount, -901_000); + assert_eq!( + bip44_bucket.utxos_added_count, 1, + "the change TXO lands in its OWNING account's bucket" + ); + unsafe { free_wallet_changeset_ffi(&ffi) }; + } + + /// The exact shape behind the provider-restoration P1: a ProReg-like + /// transaction funded by a Standard account whose payload ALSO + /// matches a provider owner-keys account. The provider slice is + /// payload-only — no TXO in the account — so the tx↔account + /// involvement join written by the per-bucket transaction callback + /// is the ONLY thing linking the tx to the provider account, and + /// restart restoration selects provider transactions through it. + /// The provider bucket must therefore receive the folded row even + /// though it contributes no TXO deltas. + #[test] + fn payload_only_provider_account_still_receives_the_transaction_row() { + use dashcore::{Address, Network, OutPoint, ScriptBuf, TxIn, TxOut, Witness}; + use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::TransactionContext; + + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let provider = AccountType::ProviderOwnerKeys; + let funded_addr = Address::dummy(Network::Testnet, 4); + let dest = Address::dummy(Network::Testnet, 5); + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::default(), + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + }], + output: vec![TxOut { + value: 100_000_000, + script_pubkey: dest.script_pubkey(), + }], + special_transaction_payload: None, + }; + let rec = + |account, direction, inputs: Vec, outputs: Vec, net| { + TransactionRecord::new( + tx.clone(), + account, + TransactionContext::Mempool, + TransactionType::Standard, + direction, + inputs, + outputs, + net, + ) + }; + let funding_slice = rec( + bip44, + TransactionDirection::Outgoing, + vec![InputDetail { + index: 0, + value: 100_001_000, + address: funded_addr, + }], + vec![OutputDetail { + index: 0, + role: OutputRole::Sent, + address: Some(dest), + value: 100_000_000, + }], + -100_001_000, + ); + // Payload-only provider match: no input details, no output + // details — the owner key appears in the special-tx payload. + let provider_slice = rec(provider, TransactionDirection::Outgoing, vec![], vec![], 0); + let folded = funding_slice.clone(); + + let cs = CoreChangeSet { + records: vec![folded], + account_records: vec![funding_slice, provider_slice], + ..CoreChangeSet::default() + }; + let ffi = WalletChangeSetFFI::from_changeset(&cs); + assert_eq!(ffi.accounts_count, 2); + let buckets = unsafe { std::slice::from_raw_parts(ffi.accounts, ffi.accounts_count) }; + let provider_bucket = buckets + .iter() + .find(|b| b.type_tag == account_type_to_tags(&provider).type_tag) + .expect("provider bucket"); + assert_eq!( + provider_bucket.transactions_count, 1, + "the payload-only provider account must receive the folded row, \ + or its involvement join is never written and the transaction \ + disappears from provider restoration after restart" + ); + assert_eq!(provider_bucket.utxos_added_count, 0); + assert_eq!(provider_bucket.utxos_spent_count, 0); + unsafe { free_wallet_changeset_ffi(&ffi) }; + } + /// The FFI entry carries the platform HTTP port gated by /// `has_platform_http_port`, and releases its heap C strings through the /// public free routine. diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index fa425fbde56..bea9b3d95aa 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -62,10 +62,12 @@ use crate::wallet::identity::{ /// `WalletEvent` bus delivers. /// /// Built by the platform-wallet event adapter from `WalletEvent` variants -/// emitted by `WalletManager`. Every field is purely additive — the -/// merge implementation uses last-write-wins for the height watermarks -/// (monotonic-max), `extend` for the records / utxos vecs, and -/// last-write-wins for the IS-lock map. +/// emitted by `WalletManager`. The merge implementation coalesces the +/// record vecs newest-wins (by txid for the wallet-level `records`, by +/// `(txid, account)` for `account_records` — see +/// [`fold_same_txid_records`]), uses monotonic-max for the height +/// watermarks, `extend` for the utxo vecs, and last-write-wins for the +/// IS-lock map. /// /// # Why a projection instead of the upstream type /// @@ -83,16 +85,48 @@ use crate::wallet::identity::{ #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct CoreChangeSet { - /// Transaction records produced by this batch. + /// Transaction records produced by this batch — one WALLET-LEVEL + /// record per txid (dashpay/platform#4387). /// /// Includes records first stored (`TransactionDetected`, /// `BlockProcessed.inserted`), records whose context advanced /// (`BlockProcessed.updated` — e.g. a mempool tx that just confirmed), /// and coinbase records that crossed the maturity threshold - /// (`BlockProcessed.matured`). All persisted; the persister's - /// `txid` uniqueness constraint handles dedup on replay. + /// (`BlockProcessed.matured`). The event bridge folds a + /// transaction's per-account slices into a single record whose + /// `net_amount` / details describe the wallet (see + /// [`fold_same_txid_records`]), so each record here is a complete + /// snapshot of its transaction at one observation — merge coalesces + /// same-txid records newest-wins rather than combining them. All + /// persisted; the persister's `txid` uniqueness constraint handles + /// dedup on replay. pub records: Vec, + /// The per-account record SLICES behind [`Self::records`], exactly + /// as upstream emitted them (one record per matched account, + /// contact-watch-only slices filtered out). + /// + /// The wallet-level fold above is right for the txid-keyed + /// `transactions` row but destroys account attribution: a sibling + /// account's `Change` output rides a record whose `account_type` + /// names the funding account, and `OutputDetail` carries no owning + /// account. Persisters that route per-account state read the + /// slices from here instead: the FFI projection buckets + /// `utxos_added` / `utxos_spent` by each slice's account so + /// Swift/Kotlin store each TXO under its owning account, and it + /// emits the folded transaction row into EVERY slice-owning + /// account's bucket so the per-account transaction callback still + /// writes the tx↔account involvement join for payload-only + /// matches (provider owner/voting keys) that restart restoration + /// depends on. Persisters that resolve accounts another way + /// (SQLite looks the address up in `core_derived_addresses`) can + /// ignore this field. + /// + /// Merge coalesces by `(txid, account_type)` newest-wins, mirroring + /// the wallet-level coalesce on `records`. + #[cfg_attr(feature = "serde", serde(default))] + pub account_records: Vec, + /// UTXOs to remove — outpoints that records in this batch spent. /// The full `Utxo` is carried (not just `OutPoint`) so a persister /// audit trail / spent-output history can keep the original metadata @@ -230,14 +264,257 @@ impl HighestUsedIndexes { } } +/// Fold same-txid [`TransactionRecord`]s into ONE wallet-level record — +/// the dashpay/platform#4387 fix at the batch seam. +/// +/// Upstream `check_core_transaction` emits one record PER MATCHED ACCOUNT +/// for a single transaction, each carrying only its account's slice +/// (`net_amount` is documented "Net amount for this account"). The +/// persisted `transactions` row is keyed by txid alone, so without this +/// fold whichever record drained last defined the row — a multi-account +/// sweep persisted one slice as the whole wallet's net (S22 field case: +/// −0.005 stored for a −2.61920199 spend). +/// +/// The fold, per txid group of 2+ records: +/// - `net_amount` — the SUM of the slices: each account's +/// `received − spent` over disjoint detail sets, so the sum is the +/// wallet's `Σreceived − Σspent` by construction. +/// - `input_details` / `output_details` — the union (deduped by input +/// index / output index): the slices are disjoint per account, and the +/// union is exactly the wallet-relevant view downstream consumers +/// (`derive_new_utxos`, usage sweeps) expect of a single record. +/// - `fee` — the first `Some` (only the funding account's record carries +/// one, and disjoint accounts cannot disagree); left `None` when no +/// record knew it. +/// - `direction` — recomputed over the MERGED details with the same rule +/// upstream applies per account (`record_transaction`): `CoinJoin` +/// transaction type wins outright; otherwise no `Sent` output + our +/// inputs + our outputs → `Internal` (a cross-account move whose +/// account-local slices said `Outgoing`/`Incoming` is, wallet-level, a +/// self-transfer); otherwise our inputs → `Outgoing`, else `Incoming`. +/// Deriving from the net's sign instead erased `Internal` and +/// `CoinJoin`: an internal transfer nets −fee and would relabel +/// `Outgoing`. +/// - `context` — the most advanced in the group (`Mempool` < +/// `InstantSend` < `InBlock` < `InChainLockedBlock`), so a group mixing +/// a stale mempool observation with a confirmed one keeps the +/// confirmation. +/// - identity fields (`transaction`, `txid`, `transaction_type`, +/// `label`, `account_type`) — from the FUNDING record (the one with +/// input details) so the row's account attribution names the spender, +/// else the first record. +/// +/// Order-preserving for untouched records; a fold lands at the group's +/// FIRST position (`group[0]`) regardless of which record supplied the +/// funding metadata, so unrelated records between two slices never move +/// ahead of the folded transaction. Contact-watch-only records never +/// reach here (filtered at projection — see +/// `core_bridge::is_contact_watch_only`). +pub(crate) fn fold_same_txid_records(records: &mut Vec) { + use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; + use key_wallet::transaction_checking::transaction_router::TransactionType; + + if records.len() < 2 { + return; + } + let mut by_txid: BTreeMap> = BTreeMap::new(); + for (i, r) in records.iter().enumerate() { + by_txid.entry(r.txid).or_default().push(i); + } + if by_txid.values().all(|g| g.len() < 2) { + return; + } + + let mut drop_idx: BTreeSet = BTreeSet::new(); + let mut folded: BTreeMap = BTreeMap::new(); + for group in by_txid.values().filter(|g| g.len() >= 2) { + // Base: the funding record (has input details), else the first. + let base_pos = group + .iter() + .copied() + .find(|&i| !records[i].input_details.is_empty()) + .unwrap_or(group[0]); + let mut merged = records[base_pos].clone(); + let mut net: i64 = 0; + let mut seen_inputs: BTreeSet = merged.input_details.iter().map(|d| d.index).collect(); + let mut seen_outputs: BTreeSet = + merged.output_details.iter().map(|d| d.index).collect(); + for &i in group { + let r = &records[i]; + net = net.saturating_add(r.net_amount); + if merged.fee.is_none() { + merged.fee = r.fee; + } + if i != base_pos { + for d in &r.input_details { + if seen_inputs.insert(d.index) { + merged.input_details.push(d.clone()); + } + } + for d in &r.output_details { + if seen_outputs.insert(d.index) { + merged.output_details.push(d.clone()); + } else if matches!(d.role, OutputRole::Received | OutputRole::Change) { + // Index collision across account slices: the slices + // are only detail-disjoint for details the accounts + // AGREE on. An output owned by account B appears in + // funding account A's slice too — as `Sent`, because + // A's account-local view cannot attribute B's + // address. Keeping the base's entry on collision let + // that `Sent` win, and every consumer deriving UTXOs + // from the folded record (record_new_utxos_ffi, + // derive_new_utxos filter on Received|Change) then + // silently dropped the owned output — the store lost + // the wallet's own change while the folded net_amount + // stayed correct (2026-08-19 device run: records + // landed corrected, TXOs never arrived, the reconcile + // tripwire healed 4). Ownership is account-scoped + // knowledge: exactly one slice can carry + // Received/Change for an index, so on collision the + // owned role wins unconditionally. + if let Some(existing) = merged + .output_details + .iter_mut() + .find(|o| o.index == d.index) + { + if !matches!(existing.role, OutputRole::Received | OutputRole::Change) { + *existing = d.clone(); + } + } + } + } + drop_idx.insert(i); + } + // Context: keep the most advanced observation in the group. + // The funding slice is not necessarily the newest one — a + // group can pair a stale `Mempool` sighting with the + // confirmed snapshot of the same transaction. + if context_rank(&r.context) > context_rank(&merged.context) { + merged.context = r.context.clone(); + } + } + // The base record's own index was skipped by the `i != base_pos` + // guard above; drop every group member except the fold's output + // position (the group's FIRST slot, which the reassembly below + // fills with the merged record). + drop_idx.insert(base_pos); + let first_pos = group[0]; + drop_idx.remove(&first_pos); + merged.net_amount = net; + // Wallet-level direction over the merged details — same rule + // upstream applies per account (see the doc comment). The sign + // of the net cannot express `Internal` or `CoinJoin`. + merged.direction = if merged.transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else { + let has_inputs = !merged.input_details.is_empty(); + let has_sent = merged + .output_details + .iter() + .any(|d| d.role == OutputRole::Sent); + let has_our_outputs = merged + .output_details + .iter() + .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); + if !has_sent && has_inputs && has_our_outputs { + TransactionDirection::Internal + } else if has_inputs { + TransactionDirection::Outgoing + } else { + TransactionDirection::Incoming + } + }; + folded.insert(first_pos, merged); + } + + let old = std::mem::take(records); + for (i, r) in old.into_iter().enumerate() { + if let Some(merged) = folded.remove(&i) { + records.push(merged); + } else if !drop_idx.contains(&i) { + records.push(r); + } + } +} + +/// Replace-or-append fold shared by the record vecs in +/// [`CoreChangeSet`]'s merge: each incoming record either SUPERSEDES the +/// existing record with the same key (in place, keeping the earlier +/// record's position so unrelated records never reorder) or appends. +/// `other` is by the `Merge` contract the later changeset, so incoming +/// records are the newer observations. +/// +/// One linear pass over each side per merge — the adapter's drain calls +/// merge once per buffered event, so this deliberately avoids the +/// full-vec re-fold a `fold_same_txid_records` call here used to cost. +fn coalesce_newest_wins( + existing: &mut Vec, + incoming: Vec, + key: impl Fn(&TransactionRecord) -> K, +) { + use std::collections::hash_map::Entry; + use std::collections::HashMap; + + if incoming.is_empty() { + return; + } + if existing.is_empty() { + *existing = incoming; + return; + } + let mut index: HashMap = existing + .iter() + .enumerate() + .map(|(i, r)| (key(r), i)) + .collect(); + for r in incoming { + match index.entry(key(&r)) { + Entry::Occupied(slot) => existing[*slot.get()] = r, + Entry::Vacant(slot) => { + slot.insert(existing.len()); + existing.push(r); + } + } + } +} + +/// Rank a [`TransactionContext`](key_wallet::transaction_checking::TransactionContext) +/// by how far along the confirmation lifecycle the observation is. +/// Used by [`fold_same_txid_records`] so a fold never regresses a +/// confirmed context to a stale mempool one. +fn context_rank(context: &key_wallet::transaction_checking::TransactionContext) -> u8 { + use key_wallet::transaction_checking::TransactionContext; + match context { + TransactionContext::Mempool => 0, + TransactionContext::InstantSend(_) => 1, + TransactionContext::InBlock(_) => 2, + TransactionContext::InChainLockedBlock(_) => 3, + } +} + impl Merge for CoreChangeSet { fn merge(&mut self, other: Self) { - // Records / utxo deltas: append-only. The event adapter never - // produces duplicates within a single batch (each event covers - // a distinct moment); cross-batch dedup is the persister's - // responsibility (txid uniqueness for records, outpoint - // uniqueness for utxos). - self.records.extend(other.records); + // Records: coalesce by txid, NEWEST-WINS (dashpay/platform#4387). + // + // The event bridge already folded each event's per-account + // slices into one wallet-level record per txid (see + // `fold_same_txid_records` and the `TransactionDetected` + // rebuild in `core_bridge::build_core_changeset`), so two + // same-txid records meeting here are the same transaction at + // two OBSERVATIONS — e.g. a `TransactionDetected` mempool + // snapshot and its `BlockProcessed.updated` confirmation. + // Summing those doubled the persisted net (−100 detected + + // −100 confirmed = −200) and could keep the stale mempool + // context; the later snapshot simply supersedes the earlier + // one, at the earlier record's position so unrelated records + // never reorder around it. + coalesce_newest_wins(&mut self.records, other.records, |r| r.txid); + // Account slices: same discipline, keyed by (txid, account) — + // a slice supersedes the previous observation of the SAME + // account's slice, while slices of sibling accounts coexist. + coalesce_newest_wins(&mut self.account_records, other.account_records, |r| { + (r.txid, r.account_type) + }); self.spent_utxos.extend(other.spent_utxos); self.new_utxos.extend(other.new_utxos); @@ -336,6 +613,7 @@ impl Merge for CoreChangeSet { fn is_empty(&self) -> bool { self.records.is_empty() + && self.account_records.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 2ce63111cc5..1e5092a3a12 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -790,23 +790,60 @@ async fn build_core_changeset( addresses_derived, .. } => { - // Derive UTXO deltas before moving the record into `records` - // so the per-record borrows are still live. + // Live mempool matching emits ONE event per matched + // account, each carrying only that account's slice — and + // nothing marks a transaction's last slice. Folding + // whatever slices happened to share an adapter drain made + // the persisted row depend on scheduling: a drain that + // caught one slice stored that slice as the wallet's row + // (the dashpay/platform#4387 bug, reintroduced + // nondeterministically). The MANAGER, not the drain, is + // the boundary where a transaction's slices are complete: + // by the time this event is projected the manager already + // holds every so-far-matched account's record for the + // txid (mempool records are never pruned — only + // chain-locked ones are). Rebuild the wallet-level row + // from that snapshot; a sibling account matching later + // re-runs this rebuild through its own event, so the + // persisted row converges on the full fold no matter how + // events land in drains. + // + // `None` = the manager doesn't know the wallet (removed + // mid-flight, or a bare test manager): fall back to the + // event's own record. `Some([])` = the wallet exists but + // the record is gone (chain-locked and pruned between + // emit and drain): emit NO row rather than let a lone + // stale slice supersede a complete fold earlier in this + // drain's batch — the chainlock's own events carry the + // row's finality forward. + let slices: Vec = + match wallet_slices_for_txid(wallet_manager, wallet_id, &record.txid).await { + Some(slices) => slices, + None => vec![(**record).clone()], + }; + // A contact's watch-only chain never defines the wallet's + // transaction row or its TXOs (see `is_contact_watch_only`); + // the usage deltas below are still emitted, so the event + // is not dropped and the contact's address pool still + // advances. + let owned: Vec = slices + .iter() + .filter(|r| !is_contact_watch_only(r)) + .cloned() + .collect(); let (addresses_marked_used, account_highest_used) = collect_usage_deltas(wallet_manager, wallet_id, vec![&**record]).await; + let mut folded = owned.clone(); + crate::changeset::changeset::fold_same_txid_records(&mut folded); CoreChangeSet { - new_utxos: derive_new_utxos(record), - spent_utxos: derive_spent_utxos(record), - // A contact's watch-only chain never defines the - // wallet's transaction row (see `is_contact_watch_only`). - // The usage deltas below are still emitted, so the - // event is not dropped and the contact's address pool - // still advances. - records: if is_contact_watch_only(record) { - Vec::new() - } else { - vec![(**record).clone()] - }, + // New UTXOs from the owned slices only (a watch-only + // chain's outputs are the contact's coins); spends + // from ALL slices, so a contact spending an output a + // pre-fix build persisted still clears the stale row. + new_utxos: owned.iter().flat_map(derive_new_utxos).collect(), + spent_utxos: slices.iter().flat_map(derive_spent_utxos).collect(), + records: folded, + account_records: owned, // Mirror the upstream-emitted derived addresses // through to the persister so newly-extended pool // rows are written transactionally with the tx that @@ -862,7 +899,15 @@ async fn build_core_changeset( // funding account's row with an incoming/positive // classification just as the first sighting did (see // `is_contact_watch_only`). - cs.records.extend( + // Keep the raw per-account slices for persisters that + // route per-account state (the FFI projection buckets + // TXOs by owning account from these), then fold the + // wallet-level `records` copy: one block can insert + // SEVERAL per-account records for one transaction (a + // multi-account spend), and the txid-keyed row needs the + // one wallet-level record (dashpay/platform#4387 — see + // fold_same_txid_records). + cs.account_records.extend( inserted .iter() .chain(updated.iter()) @@ -870,6 +915,8 @@ async fn build_core_changeset( .filter(|r| !is_contact_watch_only(r)) .cloned(), ); + cs.records = cs.account_records.clone(); + crate::changeset::changeset::fold_same_txid_records(&mut cs.records); cs.last_processed_height = Some(*height); // Pool extensions triggered by any record in this block. // Already deduped upstream by `project_derived_addresses`; @@ -1137,6 +1184,28 @@ async fn is_chain_locked( false } +/// Every account slice the manager currently holds for `txid` in +/// `wallet_id` — the authoritative "all accounts matched so far" +/// snapshot behind the wallet-level fold (see the `TransactionDetected` +/// arm of [`build_core_changeset`]). Returns `None` when the manager +/// doesn't know the wallet at all, `Some(vec![])` when it does but no +/// account holds a record for the txid (e.g. pruned at chain-lock). +async fn wallet_slices_for_txid( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + txid: &dashcore::Txid, +) -> Option> { + let guard = wallet_manager.read().await; + let info = guard.get_wallet_info(wallet_id)?; + let mut slices = Vec::new(); + for account in info.core_wallet.accounts.all_accounts() { + if let Some(record) = account.transactions().get(txid) { + slices.push(record.clone()); + } + } + Some(slices) +} + /// Is this record owned by a contact's watch-only DashPay chain? /// /// A `DashpayExternalAccount` derives its addresses from the @@ -1302,6 +1371,7 @@ impl CoreChangeSet { /// circuits on the common case. fn is_empty_no_records(&self) -> bool { self.records.is_empty() + && self.account_records.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -1444,8 +1514,8 @@ mod contact_watch_only_projection_tests { output_details: Vec, net_amount: i64, ) -> TransactionRecord { - TransactionRecord::new( - tx.clone(), + record_with( + tx, account_type, in_block(1_000), TransactionType::Standard, @@ -1456,6 +1526,31 @@ mod contact_watch_only_projection_tests { ) } + /// [`record`] with caller-chosen context and transaction type, for + /// the lifecycle-coalescing and direction-preservation tests. + #[allow(clippy::too_many_arguments)] + fn record_with( + tx: &Transaction, + account_type: AccountType, + context: TransactionContext, + transaction_type: TransactionType, + direction: TransactionDirection, + input_details: Vec, + output_details: Vec, + net_amount: i64, + ) -> TransactionRecord { + TransactionRecord::new( + tx.clone(), + account_type, + context, + transaction_type, + direction, + input_details, + output_details, + net_amount, + ) + } + /// The record pair a payment to a contact really produces: the /// funding account sees `Outgoing` with a negative net, and the /// contact's watch-only chain independently sees `Incoming` with a @@ -1526,6 +1621,111 @@ mod contact_watch_only_projection_tests { } } + /// dashpay/platform#4387: a multi-account spend's per-account records + /// must fold into ONE wallet-level row. Models the S22 field sweep in + /// miniature: the BIP44 slice spends 2.0, the receival slice spends + /// 0.62 with 0.005 change — the persisted row must carry the summed + /// −2.615 net, the union of the details, and Outgoing. + #[tokio::test] + async fn multi_account_spend_folds_to_one_wallet_level_record() { + // Destination the sweep pays; both spending accounts see it as + // `Sent` (upstream marks every non-own output `Sent` whenever + // the account owns an input). + let dest = DashAddress::dummy(Network::Testnet, 7); + let tx = tx_with(&[(&dest, 261_493_912), (&our_change_address(), 500_000)]); + let bip44_slice = record( + &tx, + bip44_account_0(), + TransactionDirection::Outgoing, + vec![InputDetail { + index: 0, + value: 200_000_000, + address: our_change_address(), + }], + vec![ + output(0, OutputRole::Sent, &dest, 261_493_912), + // The sibling account's change is not this account's + // address either — account-locally it reads `Sent`. + output(1, OutputRole::Sent, &our_change_address(), 500_000), + ], + -200_000_000, + ); + let receival_slice = record( + &tx, + AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id: [2u8; 32], + }, + TransactionDirection::Outgoing, + vec![InputDetail { + index: 1, + value: 62_000_000, + address: our_change_address(), + }], + vec![ + output(0, OutputRole::Sent, &dest, 261_493_912), + output(1, OutputRole::Change, &our_change_address(), 500_000), + ], + -61_500_000, + ); + let cs = build_core_changeset( + &test_manager(), + &block_processed(vec![bip44_slice, receival_slice]), + ) + .await; + + assert_eq!( + cs.records.len(), + 1, + "same-txid per-account records must fold into one wallet-level record" + ); + let persisted = &cs.records[0]; + assert_eq!(persisted.net_amount, -261_500_000); + assert_eq!(persisted.direction, TransactionDirection::Outgoing); + assert_eq!(persisted.input_details.len(), 2, "input details must union"); + assert_eq!(persisted.output_details.len(), 2); + assert_eq!( + cs.account_records.len(), + 2, + "the raw per-account slices ride along for account-scoped persisters" + ); + } + + /// Records for DISTINCT transactions are never folded. + #[tokio::test] + async fn distinct_txids_stay_separate_records() { + let tx_a = tx_with(&[(&our_change_address(), 1_000)]); + let rec_a = record( + &tx_a, + bip44_account_0(), + TransactionDirection::Outgoing, + vec![InputDetail { + index: 0, + value: 1_000, + address: our_change_address(), + }], + Vec::new(), + -1_000, + ); + let mut tx_b = tx_with(&[(&our_change_address(), 2_000)]); + tx_b.lock_time = 999; // distinct txid + let rec_b = record( + &tx_b, + bip44_account_0(), + TransactionDirection::Outgoing, + vec![InputDetail { + index: 0, + value: 2_000, + address: our_change_address(), + }], + Vec::new(), + -2_000, + ); + let cs = build_core_changeset(&test_manager(), &block_processed(vec![rec_a, rec_b])).await; + assert_eq!(cs.records.len(), 2); + } + /// (1) A payment to a contact must persist as the outgoing, /// negative row — not the contact chain's incoming, positive one. /// @@ -1738,6 +1938,419 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); } + /// A cross-account spend (CoinJoin-funded send with BIP44 change) + /// emits one record per matched account, and the two slices DISAGREE + /// on the change output's role: the funding account's slice carries it + /// as `Sent` (its account-local view cannot attribute the sibling + /// account's address), the change account's slice as `Change`. The + /// fold seeds its output union from the FUNDING record, so keeping the + /// base entry on index collision let `Sent` win — and every UTXO + /// projection over the folded record (record_new_utxos_ffi, + /// derive_new_utxos) then dropped the wallet's own change while the + /// folded net stayed correct. 2026-08-19 device run: corrected record + /// rows landed, TXOs never arrived, the reconcile tripwire healed 4. + /// On collision the owned role must win. + #[tokio::test] + async fn fold_prefers_owned_output_role_on_index_collision() { + const CHANGE_BACK: u64 = FUNDING - PAID_TO_CONTACT - 227; + let tx = tx_with(&[ + (&contact_address(), PAID_TO_CONTACT), + (&our_change_address(), CHANGE_BACK), + ]); + // Funding account's slice: knows the input; sees BOTH outputs as + // counterparty payments. + let funding_slice = record( + &tx, + AccountType::CoinJoin { index: 0 }, + TransactionDirection::Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &contact_address(), PAID_TO_CONTACT), + output(1, OutputRole::Sent, &our_change_address(), CHANGE_BACK), + ], + -(FUNDING as i64), + ); + // Change account's slice: no inputs of its own; owns output 1. + let change_slice = record( + &tx, + bip44_account_0(), + TransactionDirection::Incoming, + vec![], + vec![output( + 1, + OutputRole::Change, + &our_change_address(), + CHANGE_BACK, + )], + CHANGE_BACK as i64, + ); + + let event = WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_001, + chain_lock: None, + inserted: vec![funding_slice, change_slice], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&test_manager(), &event).await; + + assert_eq!(cs.records.len(), 1, "same-txid slices fold to one row"); + let folded = &cs.records[0]; + assert_eq!( + folded.net_amount, + CHANGE_BACK as i64 - FUNDING as i64, + "net is the sum of the slices" + ); + let change_detail = folded + .output_details + .iter() + .find(|o| o.index == 1) + .expect("folded record keeps output 1"); + assert_eq!( + change_detail.role, + OutputRole::Change, + "the owned role must win the index collision — a lingering Sent role \ + makes every UTXO projection drop the wallet's own change" + ); + } + + /// A detection snapshot and its confirmation snapshot for the SAME + /// transaction are one account contribution observed twice, not two + /// account slices. Merging their changesets must coalesce to the + /// newest snapshot — summing them doubled the persisted net + /// (−100 detected + −100 confirmed = −200) and folding could keep + /// the stale `Mempool` context over the confirmed one. + #[tokio::test] + async fn detection_then_confirmation_coalesces_to_the_confirmed_snapshot() { + let tx = tx_with(&[(&contact_address(), PAID_TO_CONTACT)]); + let mempool_slice = record_with( + &tx, + bip44_account_0(), + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + vec![our_input()], + vec![output( + 0, + OutputRole::Sent, + &contact_address(), + PAID_TO_CONTACT, + )], + -(FUNDING as i64), + ); + let mut confirmed_slice = mempool_slice.clone(); + confirmed_slice.context = in_block(1_001); + + let manager = test_manager(); + let mut merged = build_core_changeset(&manager, &transaction_detected(mempool_slice)).await; + let confirmation = WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_001, + chain_lock: None, + inserted: vec![], + updated: vec![confirmed_slice], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + merged.merge(build_core_changeset(&manager, &confirmation).await); + + assert_eq!( + merged.records.len(), + 1, + "two observations of one transaction must coalesce to one row" + ); + let row = &merged.records[0]; + assert_eq!( + row.net_amount, + -(FUNDING as i64), + "coalescing must not sum repeated snapshots" + ); + assert!( + matches!(row.context, TransactionContext::InBlock(_)), + "the newest (confirmed) snapshot must win, got {:?}", + row.context + ); + } + + /// Wallet-level direction cannot be derived from the net's sign: a + /// cross-account transfer nets −fee but is, wallet-level, a + /// self-transfer. After the owned-role collision fix flips the + /// funding slice's `Sent` view of the sibling-owned output to the + /// sibling's `Received`, no `Sent` output remains — the fold must + /// label the row `Internal`, exactly as upstream labels a + /// single-account self-transfer. + #[tokio::test] + async fn cross_account_transfer_folds_to_internal_direction() { + const MOVED: u64 = FUNDING - 1_000; // everything minus fee + let tx = tx_with(&[(&our_receive_address(), MOVED)]); + let funding_slice = record( + &tx, + AccountType::CoinJoin { index: 0 }, + TransactionDirection::Outgoing, + vec![our_input()], + // The sibling account's address is not this account's — + // account-locally the output reads `Sent`. + vec![output(0, OutputRole::Sent, &our_receive_address(), MOVED)], + -(FUNDING as i64), + ); + let receiving_slice = record( + &tx, + bip44_account_0(), + TransactionDirection::Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + MOVED, + )], + MOVED as i64, + ); + let cs = build_core_changeset( + &test_manager(), + &block_processed(vec![funding_slice, receiving_slice]), + ) + .await; + + assert_eq!(cs.records.len(), 1); + let row = &cs.records[0]; + assert_eq!(row.net_amount, -1_000, "wallet net is just the fee"); + assert_eq!( + row.direction, + TransactionDirection::Internal, + "a cross-account move is a wallet-level self-transfer, \ + not an Outgoing spend" + ); + } + + /// `CoinJoin` is assigned from the transaction TYPE upstream, never + /// from amounts — a multi-account CoinJoin round with a nonzero + /// wallet net must keep the `CoinJoin` direction through the fold. + #[tokio::test] + async fn coinjoin_fold_keeps_coinjoin_direction() { + let tx = tx_with(&[(&our_receive_address(), FUNDING - 500)]); + let slice_a = record_with( + &tx, + AccountType::CoinJoin { index: 0 }, + in_block(1_000), + TransactionType::CoinJoin, + TransactionDirection::CoinJoin, + vec![our_input()], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + FUNDING - 500, + )], + -500, + ); + let slice_b = record_with( + &tx, + bip44_account_0(), + in_block(1_000), + TransactionType::CoinJoin, + TransactionDirection::CoinJoin, + vec![], + vec![], + 0, + ); + let cs = + build_core_changeset(&test_manager(), &block_processed(vec![slice_a, slice_b])).await; + + assert_eq!(cs.records.len(), 1); + assert_eq!( + cs.records[0].direction, + TransactionDirection::CoinJoin, + "a nonzero net must not rewrite a CoinJoin row as Outgoing/Incoming" + ); + } + + /// A fold lands at the group's FIRST position even when the funding + /// record (the metadata source) appears later — unrelated records + /// between the slices must not move ahead of the folded transaction. + #[tokio::test] + async fn fold_lands_at_the_groups_first_position() { + let tx = tx_with(&[(&our_change_address(), CHANGE)]); + let no_input_slice = record( + &tx, + bip44_account_0(), + TransactionDirection::Incoming, + vec![], + vec![output(0, OutputRole::Change, &our_change_address(), CHANGE)], + CHANGE as i64, + ); + let mut unrelated_tx = tx_with(&[(&our_receive_address(), 1_000)]); + unrelated_tx.lock_time = 77; // distinct txid + let unrelated = record( + &unrelated_tx, + bip44_account_0(), + TransactionDirection::Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + 1_000, + )], + 1_000, + ); + let funding_slice = record( + &tx, + AccountType::CoinJoin { index: 0 }, + TransactionDirection::Outgoing, + vec![our_input()], + vec![output(0, OutputRole::Sent, &our_change_address(), CHANGE)], + -(FUNDING as i64), + ); + + let mut records = vec![no_input_slice, unrelated, funding_slice]; + crate::changeset::changeset::fold_same_txid_records(&mut records); + + assert_eq!(records.len(), 2); + assert_eq!( + records[0].txid, + tx.txid(), + "the folded record must keep the group's first position" + ); + assert_eq!( + records[0].account_type, + AccountType::CoinJoin { index: 0 }, + "funding metadata still comes from the funding slice" + ); + assert_eq!(records[1].txid, unrelated_tx.txid()); + } + + /// The adapter drain is NOT where a multi-account transaction's + /// mempool slices reliably meet: live matching emits one + /// `TransactionDetected` per account, and a drain can commit + /// between them. The projection must therefore rebuild the + /// wallet-level row from the MANAGER's slices — an event carrying + /// one account's slice still yields the full fold, so the + /// persisted row converges no matter how events land in drains. + #[tokio::test] + async fn mempool_slice_event_rebuilds_the_full_fold_from_the_manager() { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use key_wallet::test_utils::TestWalletContext; + + // A wallet funded on TWO standard accounts (mirrors + // `test_support::funded_wallet_manager_dual_standard`, kept + // inline because the spend must be checked before the managed + // wallet moves into the manager). + let mut ctx = TestWalletContext::new_random(); + let bip44_address = ctx.receive_address.clone(); + let bip32_address = { + let xpub = ctx + .wallet + .accounts + .standard_bip32_accounts + .get(&0) + .expect("bip32 account") + .account_xpub; + ctx.managed_wallet + .first_bip32_managed_account_mut() + .expect("bip32 managed account") + .next_receive_address(Some(&xpub), true) + .expect("bip32 receive address") + }; + let bip44_funding = Transaction::dummy(&bip44_address, 0..1, &[100_000_000]); + let bip32_funding = Transaction::dummy(&bip32_address, 1..2, &[50_000_000]); + for funding in [&bip44_funding, &bip32_funding] { + let result = ctx + .check_transaction( + funding, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::from_slice(&[4u8; 32]).expect("valid block hash"), + 1_700_000_000, + )), + ) + .await; + assert!(result.is_relevant, "funding tx should be relevant"); + } + + // One transaction spending BOTH accounts' coins — the manager + // records one slice per account for it. + let spend = Transaction { + version: 2, + lock_time: 0, + input: [&bip44_funding, &bip32_funding] + .iter() + .map(|funding| TxIn { + previous_output: OutPoint { + txid: funding.txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + }) + .collect(), + output: vec![TxOut { + value: 149_999_000, + script_pubkey: DashAddress::dummy(Network::Testnet, 7).script_pubkey(), + }], + special_transaction_payload: None, + }; + let result = ctx + .check_transaction(&spend, TransactionContext::Mempool) + .await; + assert!(result.is_relevant, "spend should match both accounts"); + + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + }; + let mut wm = WalletManager::::new(dashcore::Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + let manager = Arc::new(RwLock::new(wm)); + + let slices = wallet_slices_for_txid(&manager, &wallet_id, &spend.txid()) + .await + .expect("manager knows the wallet"); + assert_eq!(slices.len(), 2, "both funding accounts hold a slice"); + let lone_slice = slices + .iter() + .find(|r| r.account_type == bip44_account_0()) + .expect("bip44 slice") + .clone(); + assert_eq!( + lone_slice.net_amount, -100_000_000, + "the lone slice carries only its own account's net" + ); + + // The event delivers ONE slice — as live mempool matching does. + let lone_event = WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(lone_slice), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&manager, &lone_event).await; + + assert_eq!(cs.records.len(), 1, "one wallet-level row"); + assert_eq!( + cs.records[0].net_amount, -150_000_000, + "the row is rebuilt from ALL of the manager's slices, \ + not the one slice the event happened to carry" + ); + assert_eq!( + cs.account_records.len(), + 2, + "both account slices ride along for account-scoped persisters" + ); + } + /// A contact spending an output that a *pre-fix* build already /// persisted must still clear that stale row, so `derive_spent_utxos` /// stays deliberately unfiltered. Only the transaction row and the