From 83264aa3338f78a4f0051020a31f7247aff403f0 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:28:19 -0400 Subject: [PATCH 1/4] fix(platform-wallet): fold per-account records into one wallet-level row (#4387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream check_core_transaction emits ONE TransactionRecord PER MATCHED ACCOUNT for a single transaction — net_amount is documented "Net amount for this account" — while the persisted `transactions` row is keyed by txid alone. Whichever record drained last therefore defined the row: a multi-account spend persisted one account's slice as the whole wallet's net (field case: a 15-input full-balance sweep stored as −0.005 instead of −2.61920199 — every duff of the S22 ZenLedger reconciliation's residual). fold_same_txid_records() merges same-txid record groups at the two seams where siblings co-occur — the BlockProcessed projection (one block inserts several per-account records) and CoreChangeSet::merge (per-event records folded across a drain batch): net = Σ slices (disjoint per-account detail sets, so the sum is the wallet's Σreceived − Σspent), details unioned by index, fee from the funding record, direction recomputed from the merged net, identity fields from the funding record. Order-preserving; groups of one untouched; contact-watch-only records are already filtered upstream of both seams. Cross-batch stragglers keep the persister's txid-uniqueness semantics — the Android-side OUTGOING mirror heal covers rows persisted before this fix (or split across batches), and goes inert on rows this fold writes. 67/67 changeset tests green, including the #4247-era contact-watch-only projections unchanged, plus new coverage for the S22 sweep shape and the distinct-txid no-fold contract. Co-Authored-By: Claude Opus 4.8 --- .../src/changeset/changeset.rs | 118 +++++++++++++++++- .../src/changeset/core_bridge.rs | 98 +++++++++++++++ 2 files changed, 211 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index fa425fbde56..fdf9b09b5d9 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -230,14 +230,122 @@ 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 from the merged net: negative → `Outgoing`, +/// positive → `Incoming`, zero → the funding record's own direction +/// (a zero-net multi-account event is a wallet-internal move). +/// - identity fields (`transaction`, `txid`, `context`, +/// `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 keeps the group's first +/// position. 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::TransactionDirection; + + 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()); + } + } + drop_idx.insert(i); + } + } + merged.net_amount = net; + merged.direction = match net.cmp(&0) { + std::cmp::Ordering::Less => TransactionDirection::Outgoing, + std::cmp::Ordering::Greater => TransactionDirection::Incoming, + std::cmp::Ordering::Equal => records[base_pos].direction, + }; + folded.insert(base_pos, merged); + } + + let old = std::mem::take(records); + for (i, r) in old.into_iter().enumerate() { + if drop_idx.contains(&i) { + continue; + } + records.push(folded.remove(&i).unwrap_or(r)); + } +} + 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). + // Records: append, then FOLD same-txid records into one + // wallet-level record (dashpay/platform#4387). The old comment here + // claimed the adapter "never produces duplicates within a single + // batch" — false for a multi-account spend: upstream + // `check_core_transaction` emits ONE record PER MATCHED ACCOUNT, + // each carrying only its account's `net_amount` slice, and the + // txid-keyed persisted row was whichever record landed last + // (field case: a 15-input full-balance sweep stored as −0.005 + // instead of −2.61920199 — the S22 reconciliation). Folding at the + // batch seam makes the persisted row describe the WALLET whenever + // the per-account events drain together, which is how detection + // emits them. Cross-batch stragglers remain the persister's + // txid-uniqueness concern, unchanged. self.records.extend(other.records); + fold_same_txid_records(&mut self.records); self.spent_utxos.extend(other.spent_utxos); self.new_utxos.extend(other.new_utxos); diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index df1b4701cf9..aafcc965a51 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -691,6 +691,11 @@ async fn build_core_changeset( .filter(|r| !is_contact_watch_only(r)) .cloned(), ); + // One block can insert SEVERAL per-account records for one + // transaction (a multi-account spend); fold them into the one + // wallet-level record the txid-keyed row needs + // (dashpay/platform#4387 — see fold_same_txid_records). + 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`; @@ -1340,6 +1345,99 @@ 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() { + let tx = tx_with(&[(&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::new(), + -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::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(), 1); + } + + /// 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. /// From 6eae9145fbb4dceb9f362ead57035e657a63d1ca Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 19 Aug 2026 23:06:37 -0700 Subject: [PATCH 2/4] fix(platform-wallet): owned output role wins index collisions in the same-txid record fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-account spend (CoinJoin-funded send with BIP44 change) emits one record per matched account, and the 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 owning account's slice as Change. fold_same_txid_records seeded its output union from the funding record and kept the base entry on index collision, so Sent won — and every UTXO projection over the folded record (record_new_utxos_ffi ignores the changeset's new_utxos by design and re-derives from record output_details, filtering to Received|Change) silently dropped the wallet's own change while the folded net_amount stayed correct. Observed on-device 2026-08-19: the corrected record rows landed in the store, the TXO rows never arrived, and the Layer-1 reconcile tripwire healed 4 missing TXOs at SYNCED. On collision the owned role now wins unconditionally: ownership is account-scoped knowledge, so exactly one slice can carry Received/Change for a given output index. Co-Authored-By: Claude Fable 5 --- .../src/changeset/changeset.rs | 30 +++++++- .../src/changeset/core_bridge.rs | 77 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index fdf9b09b5d9..a9ad46a3dcb 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -264,7 +264,7 @@ impl HighestUsedIndexes { /// position. 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::TransactionDirection; + use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; if records.len() < 2 { return; @@ -306,6 +306,34 @@ pub(crate) fn fold_same_txid_records(records: &mut Vec) { 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); diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index aafcc965a51..ea4a4e8091c 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1650,6 +1650,83 @@ 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 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 From 8b074a472ec4872cffb740fb6a19d6dfb1b677a8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 28 Aug 2026 23:36:19 +0200 Subject: [PATCH 3/4] fix(platform-wallet): complete the wallet-level fold at every observation boundary Addresses the five blocking review findings on the same-txid record fold: - Mempool slices no longer depend on drain scheduling: the TransactionDetected projection rebuilds the wallet-level row from ALL account slices the manager holds for the txid (mempool records are never pruned), so an event carrying one slice still yields the full fold and the persisted row converges no matter how events land in adapter batches. - Repeated lifecycle snapshots are no longer summed: records entering CoreChangeSet::merge are complete wallet-level snapshots, so merge coalesces same-txid records NEWEST-WINS (position-stable) instead of folding, and a detection followed by its confirmation keeps the unchanged net and the confirmed context. This also removes the per-merge full-vec re-fold (the O(N^2 log N) drain cost). - Direction is recomputed over the merged details with upstream's own rule (CoinJoin from the transaction type; no Sent output + our inputs + our outputs = Internal), instead of the net's sign, which erased Internal and CoinJoin. - The fold keeps the most advanced context in the group and lands at the group's FIRST position, so unrelated records never reorder and a confirmed context never regresses to a stale mempool one. - Output ownership survives the FFI boundary: the changeset now carries the raw per-account slices in `account_records`, and WalletChangeSetFFI::from_changeset derives utxos_added / utxos_spent from those slices (transaction rows still come from the folded records), so a sibling account's change TXO lands in its owning account's bucket instead of the funding account's. SQLite is unaffected (it resolves accounts by address lookup). Co-Authored-By: Claude Fable 5 --- .../src/core_wallet_types.rs | 199 ++++++- .../src/changeset/changeset.rs | 229 ++++++-- .../src/changeset/core_bridge.rs | 506 ++++++++++++++++-- 3 files changed, 843 insertions(+), 91 deletions(-) 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 900a5b07e73..1195a8df986 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -316,24 +316,55 @@ 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, attributed to the funding + // account (dashpay/platform#4387); + // - 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 { if let Some(bucket) = by_account .iter_mut() - .find(|(at, _)| at == &rec.account_type) + .find(|(at, _, _)| at == &rec.account_type) { bucket.1.push(rec); } else { - by_account.push((rec.account_type, vec![rec])); + by_account.push((rec.account_type, vec![rec], Vec::new())); + } + } + for rec in utxo_source { + if let Some(bucket) = by_account + .iter_mut() + .find(|(at, _, _)| at == &rec.account_type) + { + bucket.2.push(rec); + } else { + by_account.push((rec.account_type, Vec::new(), vec![rec])); } } @@ -346,31 +377,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(); @@ -1843,6 +1875,151 @@ 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, 0); + assert_eq!( + bip44_bucket.utxos_added_count, 1, + "the change TXO lands in its OWNING account's bucket" + ); + unsafe { free_wallet_changeset_ffi(&ffi) }; + } + /// ProRegTx provider payload is lifted from the DIP-3 special-tx /// body for the UI. Fixture is the testnet /// collateral-provider-registration transaction from rust-dashcore's diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index a9ad46a3dcb..c7dfec25f76 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,43 @@ 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 — the FFI + /// projection buckets `utxos_added` / `utxos_spent` by account so + /// Swift/Kotlin store each TXO under its owning account — read the + /// slices from here instead. 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 @@ -252,19 +281,33 @@ impl HighestUsedIndexes { /// - `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 from the merged net: negative → `Outgoing`, -/// positive → `Incoming`, zero → the funding record's own direction -/// (a zero-net multi-account event is a wallet-internal move). -/// - identity fields (`transaction`, `txid`, `context`, -/// `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. +/// - `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 keeps the group's first -/// position. Contact-watch-only records never reach here (filtered at -/// projection — see `core_bridge::is_contact_watch_only`). +/// 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; @@ -324,13 +367,12 @@ pub(crate) fn fold_same_txid_records(records: &mut Vec) { // 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 let Some(existing) = merged + .output_details + .iter_mut() + .find(|o| o.index == d.index) { - if !matches!( - existing.role, - OutputRole::Received | OutputRole::Change - ) { + if !matches!(existing.role, OutputRole::Received | OutputRole::Change) { *existing = d.clone(); } } @@ -338,42 +380,136 @@ pub(crate) fn fold_same_txid_records(records: &mut Vec) { } 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; - merged.direction = match net.cmp(&0) { - std::cmp::Ordering::Less => TransactionDirection::Outgoing, - std::cmp::Ordering::Greater => TransactionDirection::Incoming, - std::cmp::Ordering::Equal => records[base_pos].direction, + // 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(base_pos, merged); + folded.insert(first_pos, merged); } let old = std::mem::take(records); for (i, r) in old.into_iter().enumerate() { - if drop_idx.contains(&i) { - continue; + 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); + } } - records.push(folded.remove(&i).unwrap_or(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: append, then FOLD same-txid records into one - // wallet-level record (dashpay/platform#4387). The old comment here - // claimed the adapter "never produces duplicates within a single - // batch" — false for a multi-account spend: upstream - // `check_core_transaction` emits ONE record PER MATCHED ACCOUNT, - // each carrying only its account's `net_amount` slice, and the - // txid-keyed persisted row was whichever record landed last - // (field case: a 15-input full-balance sweep stored as −0.005 - // instead of −2.61920199 — the S22 reconciliation). Folding at the - // batch seam makes the persisted row describe the WALLET whenever - // the per-account events drain together, which is how detection - // emits them. Cross-batch stragglers remain the persister's - // txid-uniqueness concern, unchanged. - self.records.extend(other.records); - fold_same_txid_records(&mut self.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); @@ -472,6 +608,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 ea4a4e8091c..7b973d07195 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -611,23 +611,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 @@ -683,7 +720,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()) @@ -691,10 +736,7 @@ async fn build_core_changeset( .filter(|r| !is_contact_watch_only(r)) .cloned(), ); - // One block can insert SEVERAL per-account records for one - // transaction (a multi-account spend); fold them into the one - // wallet-level record the txid-keyed row needs - // (dashpay/platform#4387 — see fold_same_txid_records). + 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. @@ -963,6 +1005,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 @@ -1121,6 +1185,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() @@ -1263,8 +1328,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, @@ -1275,6 +1340,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 @@ -1352,7 +1442,11 @@ mod contact_watch_only_projection_tests { /// −2.615 net, the union of the details, and Outgoing. #[tokio::test] async fn multi_account_spend_folds_to_one_wallet_level_record() { - let tx = tx_with(&[(&our_change_address(), 500_000)]); + // 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(), @@ -1362,7 +1456,12 @@ mod contact_watch_only_projection_tests { value: 200_000_000, address: our_change_address(), }], - Vec::new(), + 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( @@ -1378,12 +1477,10 @@ mod contact_watch_only_projection_tests { value: 62_000_000, address: our_change_address(), }], - vec![output( - 0, - OutputRole::Change, - &our_change_address(), - 500_000, - )], + 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( @@ -1401,7 +1498,12 @@ mod contact_watch_only_projection_tests { 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(), 1); + 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. @@ -1673,9 +1775,7 @@ mod contact_watch_only_projection_tests { // counterparty payments. let funding_slice = record( &tx, - AccountType::CoinJoin { - index: 0, - }, + AccountType::CoinJoin { index: 0 }, TransactionDirection::Outgoing, vec![our_input()], vec![ @@ -1690,7 +1790,12 @@ mod contact_watch_only_projection_tests { bip44_account_0(), TransactionDirection::Incoming, vec![], - vec![output(1, OutputRole::Change, &our_change_address(), CHANGE_BACK)], + vec![output( + 1, + OutputRole::Change, + &our_change_address(), + CHANGE_BACK, + )], CHANGE_BACK as i64, ); @@ -1727,6 +1832,339 @@ mod contact_watch_only_projection_tests { ); } + /// 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 From 2fbc7b68843d4e4cb97880e1b2d7a17805e54907 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 29 Aug 2026 00:46:33 +0200 Subject: [PATCH 4/4] fix(platform-wallet): emit the folded row into every involved account bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swift/Kotlin per-account transaction callback is the sole writer of the tx-to-account involvement join (involvedAccounts / transaction_account_involvements), and payload-only matches — a ProReg/ProUp payload hitting a provider owner or voting key with no TXO in the account — depend on that join for restart restoration. Emitting the folded wallet-level row only in the funding account's bucket dropped the sibling accounts' involvement, so a provider transaction funded by a Standard account vanished from provider restoration and masternode aggregation after restart until a rescan. from_changeset now emits each folded row into the bucket of every account that owns a slice of its txid (funder included). The row's values are identical in every bucket — the persisted row is txid-keyed and account-agnostic, so the duplicate upserts converge — and only the enclosing bucket differs, which is exactly what the involvement join records. TXO deltas stay slice-bucketed as before. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet_types.rs | 153 ++++++++++++++++-- .../src/changeset/changeset.rs | 17 +- 2 files changed, 154 insertions(+), 16 deletions(-) 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 c0607310133..400fd8b87c9 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -319,8 +319,22 @@ impl WalletChangeSetFFI { // // Two record sources fill each account's bucket: // - transaction rows come from `records` — wallet-level, - // same-txid slices folded, attributed to the funding - // account (dashpay/platform#4387); + // 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 @@ -349,13 +363,23 @@ impl WalletChangeSetFFI { Vec<&key_wallet::managed_account::transaction_record::TransactionRecord>, )> = Vec::new(); for rec in &cs.records { - if let Some(bucket) = by_account - .iter_mut() - .find(|(at, _, _)| at == &rec.account_type) - { - bucket.1.push(rec); - } else { - by_account.push((rec.account_type, vec![rec], Vec::new())); + // 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 { @@ -1668,7 +1692,19 @@ mod tests { coinjoin_bucket.utxos_spent_count, 1, "the spend stays with the account that owned the coin" ); - assert_eq!(bip44_bucket.transactions_count, 0); + 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" @@ -1676,6 +1712,103 @@ mod tests { 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 c7dfec25f76..bea9b3d95aa 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -110,12 +110,17 @@ pub struct CoreChangeSet { /// `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 — the FFI - /// projection buckets `utxos_added` / `utxos_spent` by account so - /// Swift/Kotlin store each TXO under its owning account — read the - /// slices from here instead. Persisters that resolve accounts - /// another way (SQLite looks the address up in - /// `core_derived_addresses`) can ignore this field. + /// 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`.