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/2] 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 fa425fbde5..fdf9b09b5d 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 df1b4701cf..aafcc965a5 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/2] 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 fdf9b09b5d..a9ad46a3dc 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 aafcc965a5..ea4a4e8091 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