Skip to content

Carry a sweep's wallet-owned held outpoints, symmetric to released_outpoints #968

Description

@romchornyi

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions