Summary
WalletEvent::TransactionsSwept names the outpoints a sweep releases (added in #962) but not the ones it leaves held. Persistence backends therefore cannot tell a loser's foreign input from a loser's wallet-owned-but-unclassified input, and must treat every absent non-released input as potentially ours. In dashpay/platform that means one permanent zero-value placeholder row per foreign input of a swept incoming payment.
Why storage cannot decide this locally
A transaction can be wallet-relevant purely because it pays a wallet output while every one of its inputs belongs to the sender. When a final replacement consumes that same input set, those inputs are (correctly) excluded from released_outpoints. Downstream sees "absent from the UTXO table, not released" — identical to the case the placeholder exists for.
Record metadata does not break the tie either. input_details is built from self.utxos.get(&input.previous_output) at record time (key-wallet/src/managed_account/managed_core_funds_account.rs:829-848) and direction derives from it. So a coin that is genuinely ours but spent before its funding output was classified — gap-limit address, or a second device on the same seed — produces a record whose input is absent from input_details and whose direction reads Incoming: byte-for-byte what an attacker's fan-in payment produces.
Why it cannot be decided at the sweep site either, today
At the sweep site the account holds the winner's inputs, each loser's inputs, self.utxos, and self.spent_outpoints. None of them carries ownership for this coin:
freed collects every input of each removed loser with no ownership filter (managed_core_funds_account.rs:499).
rebuild_spent_outpoints (managed_core_funds_account.rs:1293-1299) takes every input of every held record, also unfiltered — so membership there does not imply the wallet owns the funding output.
- The unclassified-ours coin is in neither
utxos nor input_details.
An intersection with the winner's inputs does not help: in the abuse case the winner consumes the same set the loser did.
Proposal
Carry per-wallet held outpoints on TransactionsSwept, symmetric to released_outpoints — the outpoints the sweep leaves claimed and which this wallet can attest are its own (loser_inputs ∩ (utxos ∪ ownership-filtered spent marks)). Persisters then create absent-input rows only for attested outpoints.
This is additive: WalletEvent crosses no ABI, and #[serde(default)] keeps old payloads decoding. Roughly ConflictSweep, the two construction sites in key-wallet-manager/src/process_block.rs, events.rs, and the per-wallet plumbing that already mirrors per_wallet_released_outpoints.
Known trade-off
Gating on attested ownership drops the hold for the unclassified-ours coin, which would read spendable from sweep until the winner confirms in a block (where #649's persisted observed_spent map covers it). That narrows behavior currently pinned on three backends, so the semantics need a decision before implementation — recording it here rather than assuming it.
Downstream reference
dashpay/platform#4406 documents the exposure at the placeholder INSERT (packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs, KNOWN EXPOSURE block) and ships the funds-safe status quo: the placeholders are zero-value spent = 1 rows excluded from every restore path, growable only by an attacker repeatedly losing intentional double-spend races against the victim.
Summary
WalletEvent::TransactionsSweptnames the outpoints a sweep releases (added in #962) but not the ones it leaves held. Persistence backends therefore cannot tell a loser's foreign input from a loser's wallet-owned-but-unclassified input, and must treat every absent non-released input as potentially ours. Indashpay/platformthat means one permanent zero-value placeholder row per foreign input of a swept incoming payment.Why storage cannot decide this locally
A transaction can be wallet-relevant purely because it pays a wallet output while every one of its inputs belongs to the sender. When a final replacement consumes that same input set, those inputs are (correctly) excluded from
released_outpoints. Downstream sees "absent from the UTXO table, not released" — identical to the case the placeholder exists for.Record metadata does not break the tie either.
input_detailsis built fromself.utxos.get(&input.previous_output)at record time (key-wallet/src/managed_account/managed_core_funds_account.rs:829-848) anddirectionderives from it. So a coin that is genuinely ours but spent before its funding output was classified — gap-limit address, or a second device on the same seed — produces a record whose input is absent frominput_detailsand whose direction readsIncoming: byte-for-byte what an attacker's fan-in payment produces.Why it cannot be decided at the sweep site either, today
At the sweep site the account holds the winner's inputs, each loser's inputs,
self.utxos, andself.spent_outpoints. None of them carries ownership for this coin:freedcollects every input of each removed loser with no ownership filter (managed_core_funds_account.rs:499).rebuild_spent_outpoints(managed_core_funds_account.rs:1293-1299) takes every input of every held record, also unfiltered — so membership there does not imply the wallet owns the funding output.utxosnorinput_details.An intersection with the winner's inputs does not help: in the abuse case the winner consumes the same set the loser did.
Proposal
Carry per-wallet held outpoints on
TransactionsSwept, symmetric toreleased_outpoints— the outpoints the sweep leaves claimed and which this wallet can attest are its own (loser_inputs ∩ (utxos ∪ ownership-filtered spent marks)). Persisters then create absent-input rows only for attested outpoints.This is additive:
WalletEventcrosses no ABI, and#[serde(default)]keeps old payloads decoding. RoughlyConflictSweep, the two construction sites inkey-wallet-manager/src/process_block.rs,events.rs, and the per-wallet plumbing that already mirrorsper_wallet_released_outpoints.Known trade-off
Gating on attested ownership drops the hold for the unclassified-ours coin, which would read spendable from sweep until the winner confirms in a block (where #649's persisted
observed_spentmap covers it). That narrows behavior currently pinned on three backends, so the semantics need a decision before implementation — recording it here rather than assuming it.Downstream reference
dashpay/platform#4406 documents the exposure at the placeholder INSERT (
packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs,KNOWN EXPOSUREblock) and ships the funds-safe status quo: the placeholders are zero-valuespent = 1rows excluded from every restore path, growable only by an attacker repeatedly losing intentional double-spend races against the victim.