Skip to content

dig-node drops every CAT coin at ingestion, so $DIG balance reads a confident zero on a funded wallet #380

Description

@MichaelTaylor3d

Task

dig-node's wallet replica drops every CAT coin at ingestion, so control.wallet.balance returns a confident 0 for $DIG on a wallet that holds it, and the arrival ledger never records a CAT receipt. Fix the ingestion so subscribed CAT coins are stored, without widening the guard into accepting arbitrary puzzle hashes.

Parent epic: https://github.com/DIG-Network/dig_ecosystem/issues/3166
Siblings: DIG-Network/dig-app#291 (the user-visible symptom) · DIG-Network/dig-app#292 (blocked by this — CAT arrival notifications can never fire)

The user reported it directly

"i noticed that the dig-app is not displaying the DIG CAT balance, it says zero when i know i have sent DIG to it"

A surface that says zero when money is present is a surface lying about money. It is indistinguishable, to the user, from a wallet that was drained.

Reproduced against real funds, 2026-08-27

Live node on the user's machine, dig-node 0.154.0, phase: synced, peak_height: 9206707:

dign wallet balance xch17s7wd45k6vpmpwcqu26x43x5kac6u3n6pprjl9ssal6qp3dlvmjqf4snk5 --asset dig
  -> {"balance":0,"pending":0,"source":"db","synced":true}
dign wallet balance <same address> --asset xch
  -> {"balance":43348979998,"synced":true}

Mainnet truth for that same address (inner puzzle hash f43ce6d6…c5bf66e4), via api.coinset.org get_coin_records_by_hint: three unspent $DIG coins totalling 1 010 000 CAT mojos = 1 010 $DIG, all at puzzle hash 71e4312fad7361ced8fd7c166b0431f0ae0bd768713d31e0485e877798801dfa — verified independently as curry(CAT2_MOD, CAT2_MOD_HASH, DIG_ASSET_ID, f43ce6d6…), so canonical $DIG and not another CAT.

The node's own replica:

select asset_id, count(*) from coins group by asset_id;  -> (NULL, 959)
select hint,     count(*) from coins group by hint;      -> (NULL, 959)
select puzzle_hash, count(*) from coins where spent_height is null group by puzzle_hash;
  -> only the three BARE p2 hashes
select count(*) from arrivals;                           -> 5, all XCH

Not one of 959 coin rows has ever carried an asset_id or a hint.

Root cause — one line

crates/dig-wallet/src/sage/sync.rs:705, in apply_coin_states:

let rows: Vec<CoinRow> = states
    .iter()
    .filter(|s| subscribed.contains(&s.coin.puzzle_hash))
    .map(coin_state_to_row)
    .collect();

subscribed is the set of bare p2 puzzle hashes passed to request_puzzle_state (sync.rs:875, subscribe_when_finished = true). Chia's puzzle-state subscription returns coins matching a puzzle hash or a hint — which is exactly how every Chia wallet finds its CATs. A $DIG coin arrives with coin.puzzle_hash = 71e4312f…, which is not in subscribed, so it is dropped and counted into the "peer pushed coin states outside the subscribed puzzle-hash set" warning as though the peer were hostile.

The guard's predicate is narrower than the subscription it guards. Because nothing survives it, the CAT attribution pass at sync.rs:719 (design B.6, #407) — which uncurries a candidate's parent spend to fill in asset_id/hint — has never had a candidate to run on. That is why both columns are 100% NULL rather than merely sometimes NULL.

Scope, and what must NOT happen

Keep the guard. SubscribedHashes' own doc-comment states the threat correctly: the socket is untrusted, a co-resident process that binds 127.0.0.1:8444 becomes the chain source on the next connect, and an unfiltered upsert lets it invent coins at hashes the wallet never asked about. Do not relax the filter to "accept anything".

This is not a one-character widening, and there is a real design fork. CoinState carries no hint field, so the node cannot re-derive locally which subscribed hash a hinted coin belongs to. Two shapes worth weighing before building (§1.10 — settle the shape first):

  • Admit on parent uncurry. Accept a coin whose parent spend uncurries to a CAT whose inner puzzle hash is subscribed. This is the strongest predicate — it proves ownership from the chain rather than trusting the peer — and sync.rs:719 already knows how to compute it. The cost is that admission now needs the parent spend, which is a further peer read.
  • Grow subscribed with derived CAT puzzle hashes. For each subscribed p2 hash and each known asset id, add curry(CAT2_MOD, CAT2_MOD_HASH, asset_id, p2_hash). Cheap and local, but it only covers assets the node already knows about, so a CAT the user has never held stays invisible.

Whichever is chosen, say why in the PR, and state what an untrusted peer can still cause.

Evidence (§2.6)

  • The user's own node reporting 1 010 $DIG for that address, on a real machine. That is the bar.
  • A regression test that a CoinState at a CAT-wrapped puzzle hash derived from a subscribed inner hash is stored, and one that a CoinState at an unrelated puzzle hash is still dropped. The second must go RED if the guard is removed — a test that only proves the first pins nothing, because "accept everything" passes it.
  • A test that the stored CAT row gains its asset_id, so the attribution pass is proved to run rather than merely to exist.

Report

Say which shape was chosen and what the admission predicate now proves. If the fix changes how many peer reads a sync performs, say so — the ingestion path runs against every coin state the peer pushes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    mvpgates the releasable MVP

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions