Skip to content

refresh_tracked_coins admits hint-matched coins as XCH, so a fabricated coin reaches the spend selector #394

Description

@MichaelTaylor3d

Task

WalletBackend::refresh_tracked_coins admits hint-matched coins straight into coins with
asset_id = NULL, so a coin anybody can fabricate is counted as XCH and reaches the
spend-input selector. This is the same attack dig-node#380 closes on the peer ingest path, on the
coinset oracle path, and it is live on origin/main today with no peer involved.

Context

crates/dig-wallet/src/sage/rpc.rs, in refresh_tracked_coins:

let mut fetched = self.fallback.coin_records_by_puzzle_hashes(&phs).await?;
fetched.extend(self.fallback.coin_records_by_hints(&phs).await?);
let rows: Vec<CoinRow> = fetched.iter().map(fallback_coin_to_row).collect();
self.db.upsert_coins(&rows).await?;

coin_records_by_hints returns coins hinted to the wallet's p2 hashes. A hint is a memo: any
spend may attach any hint to any coin, so this set is attacker-controllable in full, needing only
the victim's public address.

fallback_coin_to_row produces asset_id = None, and asset_id IS NULL means XCH
(db.rs:1021). The row is therefore fed to unspent_coins(None) / unreserved_unspent_coins(None)
— the spend-input selector at rpc.rs:2933 and rpc.rs:3319.

singleton::reconstruct_all runs afterwards and attributes what it can, but it is
best-effort and non-subtractive: a coin it cannot reconstruct is left exactly as admitted, i.e.
as XCH. So the failure direction is a wrong figure, not incompleteness.

What it costs

The same three consequences #380 enumerates, reachable without touching a peer:

  1. a fabricated XCH balance;
  2. a send kill-switch — selection is largest-first, the fabricated coin is unspendable by
    anyone, so it is chosen forever and never leaves the set;
  3. anything downstream that trusts coins as the believed set.

Scope

Deliberately NOT fixed in #393, which closes the peer
ingest path only. Fixing it here would have widened a money-path PR that already carries a
measured acceptance bar, so it is logged rather than folded in.

The remedy is very likely the mechanism #393 builds rather than a new one: route hint-matched
arrivals through cat_admission_pending and let promotion decide, so this tier inherits the same
proof. Worth confirming that shape before implementing — a second, differently-shaped guard on the
oracle tier would be a rival implementation of the same distinction.

Evidence

Read on origin/main @ e094078. Not exploited; no coin was fabricated to demonstrate it.

Parent: #390

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind:businessa person can DO something new, or money moves, or a shipped surface stops lying to themmvpgates the releasable MVP

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions