-
Notifications
You must be signed in to change notification settings - Fork 57
fix(platform-wallet): fold per-account records into one wallet-level row, owned roles winning collisions #4438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.2-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -230,14 +230,150 @@ 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<TransactionRecord>) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if records.len() < 2 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let mut by_txid: BTreeMap<Txid, Vec<usize>> = 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<usize> = BTreeSet::new(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let mut folded: BTreeMap<usize, TransactionRecord> = 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<u32> = merged.input_details.iter().map(|d| d.index).collect(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let mut seen_outputs: BTreeSet<u32> = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+342
to
+347
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Net-sign recomputation erases Internal and CoinJoin directions
Suggested change
source: ['codex'] |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| folded.insert(base_pos, merged); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+339
to
+348
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Keep the folded record at the first group position. If the first record has no inputs and the funding record appears later, this code drops the first record and inserts the folded result at Use Proposed fix- drop_idx.insert(i);
}
}
+ let first_pos = group[0];
+ for &i in group {
+ if i != first_pos {
+ drop_idx.insert(i);
+ }
+ }
merged.net_amount = net;
- folded.insert(base_pos, merged);
+ folded.insert(first_pos, merged);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+289
to
+348
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Repeated lifecycle snapshots are summed as if they were account slices
source: ['codex']
Comment on lines
+339
to
+348
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Keep the folded record at the first group position The function documents that a fold keeps the group's first position, but when the first slice has no inputs and a later slice is selected as source: ['coderabbit'] |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
375
to
+376
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Do not sum re-emitted account records.
Coalesce repeated 🤖 Prompt for AI Agents
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The doubling is confirmed. In addition, because both snapshots can contain inputs,
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fix must first coalesce each 🐇 ✏️ Learnings added
You are interacting with an AI system.
Comment on lines
375
to
+376
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Each buffered event rebuilds the complete txid index The adapter calls source: ['codex'] |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.spent_utxos.extend(other.spent_utxos); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.new_utxos.extend(other.new_utxos); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+694
to
+698
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Mempool account slices are folded only when scheduling puts them in one adapter batch
source: ['codex'] |
||
| 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. | ||
| /// | ||
|
|
@@ -1552,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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Blocking: The fold erases output ownership required by the C/Swift persistence boundary
The merged record keeps the funding record's
account_typewhile moving sibling-account output details into it, andOutputDetailhas no owning-account field.WalletChangeSetFFI::from_changesetthen buckets records solely byrec.account_typeand derives every added UTXO inside that bucket. Swift stores the enclosing account onPersistentTxo, and the restart path emits that account's tags before Rust inserts the UTXO into the corresponding account map. In the regression test's CoinJoin-funded/BIP44-change shape, the owned output is now retained but persisted and restored as a CoinJoin UTXO rather than a BIP44 UTXO, corrupting per-account balances and fund-selection state. Preserve each owned output's original account association through a separate per-account persistence projection while folding only the wallet-level transaction row.source: ['codex']