Skip to content

fix(wallet): scope the chain-fallback CAT read to its asset id - #224

Merged
MichaelTaylor3d merged 4 commits into
mainfrom
loop/2879-fallback-asset-filter
Aug 14, 2026
Merged

fix(wallet): scope the chain-fallback CAT read to its asset id#224
MichaelTaylor3d merged 4 commits into
mainfrom
loop/2879-fallback-asset-filter

Conversation

@MichaelTaylor3d

@MichaelTaylor3dMichaelTaylor3d commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — draft. The orchestrator gates and merges.

Closes DIG-Network/dig_ecosystem#2879.

TLDR

The $DIG balance row reported a holding the user does not have. Both balance_for_address
and coins_for_address computed the CAT asset id, applied it in their DB branch, and
dropped it in their chain-fallback branch — summing every coin hinted to the address as
$DIG. Fixed by scoping the hint read to the asset via the canonical CAT puzzle hash, in
one helper both branches share.

Root cause — confirmed, and how

Confirmed at source level, and measured in a test. A hint is not an asset:
get_coin_records_by_hints takes no asset id and answers with every coin hinted to a puzzle
hash — any CAT of any TAIL, and any plain XCH coin whose spend carried a hint memo. It
reads like "the CAT read" and is not one.

The arithmetic corroborates the report exactly: 0.0001 XCH is 10^8 mojos, and 10^8 base
units rendered at $DIG's 3 decimals is precisely 100000 — the figure the user saw beside
it. One hinted XCH coin, counted twice, once at each asset's scale.

Measured before/after on the fixture (revert-proof: the fix reverted in place, tests re-run,
then restored — the tree is byte-identical to the committed state):

$DIG balance$DIG coin list
before107012345["foreign-cat", "hinted-xch", "pending-dig", "real-dig"]
after12345["pending-dig", "real-dig"]
truth12345the two $DIG coins

dig-app is innocent and untouched. Its two per-asset reads, per-asset wire mapping and
per-asset formatting are all correct; a divisor change there would have made the screenshot
look right and hidden this permanently.

The fix

A CAT coin does not sit at its owner's p2 puzzle hash — it sits at the OUTER hash currying its
TAIL around that hash, and is merely hinted to it. So keeping only coins at
digstore_chain::cat::cat_puzzle_hash(owner_ph, asset_id) is the exact fallback-tier
equivalent of the DB tier's hint IN (…) AND asset_id = ?, and the two tiers of one read now
agree about which asset they are reading.

That curry is the canonical construction the crate's CAT balance, coin reconstruction and send
paths already use — not hand-rolled here; a second spelling of a curry that decides whether
money is counted is a future byte-drift bug.

Both branches call one shared asset_scoped_fallback_coins, because duplicating the
scoping is how one copy came to be missing.

Both directions are tested, deliberately

Over-filtering is the same money lie mirrored — a real $DIG holder shown a zero. The fixture
is built to fail both wrong implementations:

  • a hinted XCH coin at the owner's p2 hash, 100_000_000 mojos — the reported case; must not count;
  • a genuine $DIG CAT coin, 12_345 — must still count (significant digits low down on
    purpose: a round fixture passes under several scales, and this defect is a scale confusion);
  • a foreign CAT coin hinted to the same address, 7_000_000 — a second asset, so a filter at
    the wrong layer changes the answer instead of preserving it;
  • a genuine pending$DIG coin, 678 — the pending figure is asset-scoped too, not only the sum.

Plus an XCH control over the same fixture returning a real non-zero 100_000_000, proving the
filter did not simply suppress the fallback tier.

MockFallback could not host this test at all: its hint read returns an empty list, so it cannot
express a multi-asset hint set. A double faithful to the tier (EveryHintedCoin) replaces it.

Sweep — the same defect shape, repo-wide

Every caller of the asset-blind hint read, and every asset-scoped DB read whose fallback peer
could disagree:

siteverdict
rpc.rsbalance_for_addressthe defect — fixed
rpc.rscoins_for_addressthe same defect, second instance — fixed. Sharper: a coin list is spend INPUTS
rpc.rs:2636refresh_tracked_coinsnot this defect. Uses the hint answer whole and attributes TAILs afterwards via lineage — it never claims one asset
chain.rs:257plain trait delegate, no asset claim
rpc.rswallet_coins fallback CAT arma different defect, filed as a follow-up. Returns Ok(vec![]) for any CAT — an under-report, not an over-report
db.rspending_scopednamed in the brief as sharing the pattern; measured, it does not. It takes asset_id and filters on it, and is only reached from the DB branch
token_record, get_sync_status selectable balanceDB-only, no fallback branch — no divergence possible

Coherence

  • SPEC.mdcontrol.wallet.balance/.coins now state that the asset scoping binds both
    tiers, name the CAT-puzzle-hash filter as the fallback equivalent of the DB predicate, and call
    out over-filtering as the mirrored lie. The old text claimed $DIG "scopes by the canonical CAT
    asset id" — true of one tier only, which is exactly the bug.
  • ChainFallback::coin_records_by_hints documents its asset-blindness on the method itself, so the
    next caller reads the rule at the callsite. Both legitimate shapes are named, so the sync pass
    that uses the answer whole still reads as correct.
  • The canonical skill gains "a hint is not an asset" (§2.0 append-on-discovery).

Blast radius

gitnexus was not available (fresh worktree, no index; §2.0 permits the fallback and requires
saying so), so the radius was established by exhaustive caller enumeration + direct reads:

  • balance_for_addressone production caller, dig-node-service/src/control.rs:1337
    (control.wallet.balance), plus one e2e test that reads XCH (unaffected).
  • coins_for_addressone production caller, control.rs:1595 (control.wallet.coins).
  • The three new symbols are private to sage/rpc.rs with no other callers. Nothing was renamed.
    (dig-wallet/src/lib.rs has its own unrelated cat_asset_id/parse_asset_id_hex free functions
    — a name coincidence in a different module, which is why a find-and-replace was not used.)
  • No public API, wire shape, parameter, field or type changes. Consumers need no adaptation;
    only the figure changes, which is the point. Risk: low, and confined to what the two
    control.wallet.* reads report when served by the fallback tier.

Evidence

  • cargo test -p dig-wallet --lib549 passed, 0 failed, 1 ignored.
  • cargo check --workspace --all-targets → clean; cargo clippy -p dig-wallet --lib --all-features → no warnings.
  • Revert-proof measured above; the four new tests fail for the right reason without the fix.
  • Not measured: the user's own address. The local replica is empty (derivations and coins
    both 0 rows), which is why the read fell back in the first place, so no live figure could be
    taken. The claim that this is the reported defect rests on the source-level proof plus the exact
    arithmetic match — stated as such, not as a live reproduction.

Version

0.118.00.118.1, patch: a read that reported the wrong figure now reports the right one,
with no API, wire or type change and no caller adaptation.

MichaelTaylor3dand others added 3 commits August 14, 2026 01:54
The $DIG balance row reports a holding the user does not have: the chain-fallback
branch of the balance read drops the asset id and sums every coin hinted to the
address. Stub commit to open the lane (dig_ecosystem#2879).
Co-Authored-By: Claude <noreply@anthropic.com>
`control.wallet.balance` reported a $DIG holding the user does not have. Both
`balance_for_address` and `coins_for_address` computed the CAT asset id, applied it in
their DB branch, and DROPPED it in their chain-fallback branch: they called
`coin_records_by_hints` and treated the whole answer as $DIG.
A hint is not an asset. `get_coin_records_by_hints` takes no asset argument and answers
with every coin hinted to a puzzle hash -- any CAT, and any plain XCH coin whose spend
carried a hint memo. The reported figure is that sum rendered at the wrong scale: one
hinted XCH coin of 10^8 mojos (0.0001 XCH) shows as 100000 $DIG at $DIG's 3 decimals.
Scope the hint read to the asset by keeping only coins that live where that asset's coins
live -- `cat_puzzle_hash(owner_ph, asset_id)`, the canonical curry `digstore-chain`
already uses for CAT balance, coin reconstruction and sends. That is the exact fallback
equivalent of the DB tier's `hint IN (...) AND asset_id = ?`, so the two tiers of one
read now agree about which asset they are reading. The two branches share ONE helper,
since duplicating the scoping is how one copy came to be missing.
Refs dig_ecosystem#2879.
Co-Authored-By: Claude <noreply@anthropic.com>
Puts the rule where the next caller will read it, since the defect was a correct-looking
call to a method whose name implies a scoping it does not do:
* `ChainFallback::coin_records_by_hints` documents that it answers with every coin hinted
to an address -- any CAT of any TAIL, and any plain XCH coin carrying a hint memo -- and
that a caller wanting ONE asset must filter by that asset's CAT puzzle hash. Both
legitimate shapes are named, so the sync pass that uses the answer whole still reads as
correct.
* SPEC.md sharpens `control.wallet.balance`/`.coins`: the asset scoping binds BOTH tiers,
the fallback tier's equivalent of `hint IN (...) AND asset_id = ?` is the canonical CAT
puzzle-hash filter, and over-filtering is called out as the same money lie mirrored.
Patch, not minor: a read that reported the wrong figure now reports the right one. No
method, parameter, field or type changes, and no caller has to adapt.
Refs dig_ecosystem#2879.
Co-Authored-By: Claude <noreply@anthropic.com>

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES-REQUIRED (recorded as a comment review — GitHub refuses REQUEST_CHANGES on a PR sharing the review identity, HTTP 422; the inline thread below is the blocker and bars merge under required_conversation_resolution.)

Correctness gate — CHANGES-REQUIRED (one gating finding, mechanical)

Head reviewed: 4d860c102dd3bed3e4edcf85165396139d25863d (resolved from the remote, not from the dispatch prompt).

The money correctness of this fix is CLEAN, and I verified it by execution, not by reading. The single blocker below is a documentation-placement regression the diff introduces; it has no behavioural effect and is one block-move to fix. Nothing about the fix itself needs to change.

Verified BY EXECUTION (own worktree, no shared checkout mutated)

proberesult
full suite at head549 passed; 0 failed; 1 ignored — matches the PR body
revert-proof: filter removed, everything else intacta_fallback_dig_balance_counts_only_dig_coins -> left: 107012345, right: 12345; a_fallback_dig_coin_list_... -> left: ["foreign-cat","hinted-xch","pending-dig","real-dig"]. Both fail for exactly the right reason, and the figures are the PR body's, independently reproduced
over-filter direction (the one that gates): curry arguments SWAPPED to cat_puzzle_hash(asset_id, owner) — the wrong-but-plausible curryboth tests fail with left: 0, right: 12345 and left: [], right: ["pending-dig","real-dig"]

That third probe is the answer to "is this a fix, or is the number merely smaller?" The tests discriminate both inequalities: an under-filter answers 107012345, an over-filter answers 0, and only the correct curry answers 12345. A fixture of 12_345 / 678 / 100_000_000 also means a scale confusion cannot pass — significant digits sit low in the range, which is right for a defect that is a scale confusion.

Verified BY READING

  1. The curry is the canonical one, applied in the right order.digstore-chain/src/cat.rs:39 is cat_puzzle_hash(owner_puzzle_hash: Bytes32, asset_id: Bytes32); the call passes (owner, asset_id). Same helper the crate's own CAT paths use — cat_balance (cat.rs:57) and reconstruct_cat_coins (cat.rs:91) both enumerate unspent_coins(cat_puzzle_hash(owner_ph, asset_id)). Nothing is hand-rolled.
  2. No spend path can now fail to fund itself.coins_for_address has one production consumer, wallet_coins (crates/dig-node-service/src/control.rs:1589) — an RPC read; the node builds no spend from it. The set it now returns is exactly the set the canonical CAT spend builder enumerates (same puzzle hash, item 1), so the list is now consistent with what is actually spendable. Pre-fix it was a superset carrying hinted-XCH and foreign-TAIL coins — a $DIG spend built from that list would have been built on foreign inputs. The change strictly removes unspendable inputs; it removes none that a spend could have used.
  3. Case/prefix cannot cause an accidental over-filter in production. The filter compares normalize_ph(&c.puzzle_hash) (rpc.rs:4415 — strips 0x, lowercases) against hex::encode(...); and the real tier's CoinsetFallback::map_record (fallback.rs:285) already normalizes via norm_hex (fallback.rs:210, identical rule). Double and production agree.
  4. The double is faithful, and the old one genuinely could not host this.MockFallback::coin_records_by_hints returns Ok(vec![]) (fallback.rs:1102) — it cannot express a multi-asset hint set, so every pre-existing $DIG-fallback test was blind to the hint path by construction. EveryHintedCoin overstates nothing: the real read is get_coin_records_by_hints(&hints, None, None, true) (fallback.rs:324), asset-blind and spent-inclusive, which is what the double models.
  5. Sweep re-done independently (not taken on the lane's word): pending_scoped (db.rs:1245) does take asset_id and filters AND asset_id = ? / AND asset_id IS NULL — the brief's named suspect is genuinely clean, and it is DB-only. refresh_tracked_coins (rpc.rs:2637) uses both reads whole, writes rows with asset_id/hint unset, and attributes TAILs afterwards via singleton::reconstruct_all — it never claims one asset, so it is the other legitimate shape. chain.rs:256 is a bare delegate.

§2.0: gitnexus had no index for this worktree, so blast radius was established by caller enumeration + direct reads — stated, as the fallback requires.

The one blocker

See the inline thread on crates/dig-wallet/src/sage/rpc.rs. Two non-gating notes are posted separately and I resolve those myself so they do not bar merge.

Re-gate after the fix will re-verify only that delta.

Comment threadcrates/dig-wallet/src/sage/rpc.rs

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-gating note (see inline) — posted for the record and resolved by the reviewer so it does not bar merge.

Comment threadcrates/dig-wallet/src/sage/rpc.rs
@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

CORRECTNESS GATE: CHANGES-REQUIRED

Head reviewed: 4d860c102dd3bed3e4edcf85165396139d25863d (resolved from the remote, not from the dispatch prompt). Independent re-derivation — the previous round's verdict was never posted, so this is a fresh read, not a restatement.

The money fix itself is CORRECT. The one blocker is a documentation defect the diff introduced on the very read it fixes; it is already handed to @copilot on its own thread, so no implementer should be dispatched onto it.


1. GATING — crates/dig-wallet/src/sage/rpc.rs:1105 — the new helper was inserted between balance_for_address's doc comment and its signature

Confirmed present at 4d860c10. asset_scoped_fallback_coins sits immediately after the doc block that opens "The confirmed + pending balance held at ONE address, for XCH or $DIG (#1851)" and closes with the #2233 tier-honesty rule. Consequences, both real:

  • pub async fn balance_for_address (rpc.rs:1155) now has no doc comment at all — a public money read, against §6.2's doc-comment-on-every-public-API rule.
  • The helper now wears a merged comment that describes two different functions and contradicts itself in the first and last paragraph. Per the contract-split rule, a doc that survives a shape change is a gate item, not a nit — and this is the documented contract of the exact read #2879 is about.

coins_for_address (rpc.rs:1302) kept its own doc, so the loss is one-sided and easy to miss on a re-read. Already delegated on the inline thread; that thread stays open.

2. Non-gating, resolved by me — rpc.rs:1134let Some(_) = … else

Readability preference only; behaviour identical, clippy clean. Thread resolved so it cannot bar merge.

3. Non-gating note — the cat_coin_puzzle_hashErr path is unreachable in production

owner_puzzle_hash always arrives via decode_addresshex::encode, i.e. always 32 bytes, so parse_puzzle_hash's failure arm cannot fire from either callsite and is untested. Fail-closed and correct as written; recorded, not a blocker.


The three deciding questions — measured

1. Both filter directions — PROVEN, and the tests are non-vacuous.

The fixture (rpc.rs:4822 hinted_multi_asset_fixture) is built to fail both wrong implementations, which is the property, not an outcome:

  • real-dig 12_345 at cat_puzzle_hash(owner, DIG_ASSET_ID) — must count. An over-strict filter answers 0 for a funded holder and this test goes red.
  • pending-dig 678 — proves the pending figure is asset-scoped too, not only the sum.
  • hinted-xch 100_000_000 at the owner's p2 hash — the reported case; must not count.
  • foreign-cat 7_000_000 at a second TAIL's curry — so a filter at the wrong layer changes the answer instead of preserving it.
  • An XCH control over the same fixture returning a real non-zero 100_000_000 — proves the filter did not simply suppress the fallback tier.

Vacuity check by reasoning on the pre-fix branch: the old code summed the raw hint answer, so a_fallback_dig_balance_counts_only_dig_coins would assert 12_345 against 107_012_345 and fail, and a_fallback_dig_coin_list_contains_only_dig_coins asserts the coin IDs["pending-dig","real-dig"] against all four — a count assertion would have passed for a keep-the-wrong-one implementation, and this one does not. The nearest three wrong implementations (sum-all, filter-all-out, keep-one) each fail at least one of the four tests.

The filter keys on digstore_chain::cat::cat_puzzle_hash(owner, asset_id) — the canonical curry already used by the crate's CAT balance and send paths, not a second spelling — and compares through normalize_ph (rpc.rs:4415, strips 0x + lowercases) on both sides, so a case/prefix mismatch cannot silently exclude a genuine coin. That was the over-filtering hazard here and it is closed.

2. coins_for_address and spend selection — no starvation path. Its only production caller is crates/dig-node-service/src/control.rs:1595 (control.wallet.coins), a read surface. The internal spend path selects over the wallet DB, not this read, so a filter change cannot starve internal coin selection. An external caller building a $DIG spend from this read is served correctly in both directions per (1) — and previously it was handed XCH and foreign-CAT coins as $DIG inputs, which is the sharper half of the bug.

3. The sweep count — grepped myself, and "both instances" holds.git grep coin_records_by_hints at 4d860c10 yields exactly two production callsites: rpc.rs:1148 (the new shared helper, serving both reads) and rpc.rs:2639 (refresh_tracked_coins). The second uses the hint answer whole and attributes TAILs afterwards via lineage — it never claims one asset, so it is not this defect. chain.rs:257 and fallback.rs:320 are the trait delegate and the coinset impl; the rest are test doubles. The mirrored under-report in the wallet_coins CAT arm is genuinely a different defect and is filed as #2902, verified open.

SPEC.md — the 2+/2- edit describes the new behaviour accurately: it states the scoping binds both tiers, names cat_puzzle_hash(owner_p2_hash, asset_id) as the fallback-tier equivalent of the DB tier's hint IN (…) AND asset_id = ?, reproduces the 10^8-mojos-as-100000 arithmetic, and names over-filtering as the mirrored lie. The old text's $DIG"scopes by the canonical CAT asset id" was true of one tier only, which was the bug.

Gates — required checks asserted BY NAME via check-merge-preconditions.sh: Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage all SUCCESS. Version 0.118.00.118.1, patch, correct: a read's figure changes with no API, wire or type change.

Not measured: I did not re-run the suite locally (no local build; CI's green Test + coverage on this exact head is the evidence), and no live figure from the user's address — the replica is empty, which is why the read fell back. The non-vacuity claim above is by reasoning on the pre-fix branch, not by a local revert.

Verdict: CHANGES-REQUIRED on finding 1 only. Fix that doc placement and this passes.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

SECURITY GATE: PASS

Head audited: 4d860c102dd3bed3e4edcf85165396139d25863d (resolved from the remote via gh pr view 224 --json headRefOid; merge-base 39966e9997f8dd55339030608052a422896e5e6a). Read entirely from git objects -- no shared checkout was mutated. Note: the primary dig-node checkout holds an unrelated uncommitted rpc.rs edit from another lane; I did not touch it.

No BLOCKING security defect in the diff. The change strictly reduces the money-lie surface it was written to fix, and I could not construct a path by which an attacker turns it into a false zero.

The three threat questions

Q1 -- can an attacker make the filter EXCLUDE real $DIG? No path found.

The filter key set is derived only from (the requested address p2 hash, the compile-time DIG_ASSET_ID) -- nothing attacker-influenced enters it (crates/dig-wallet/src/sage/rpc.rs:1141-1147). Duplicate hints are absorbed by the HashSet. The comparison is prefix- and case-normalised on both sides (rpc.rs:4415-4417 and sage/fallback.rs:210-212), so a 0x-prefixed or upper-case answer still matches. A real $DIG coin record carries its own OUTER puzzle hash, not the hint -- measured at chia-query/src/peer/translate.rs:40-50 and sage/fallback.rs:282-292 (map_record uses r.coin.puzzle_hash).

I specifically hunted the hint-flood truncation vector, since a hint is a permissionless write and truncation would silently drop the real coin: the peer leg PAGINATES to is_finished (chia-query/src/peer/mod.rs:597-619), so flooding an address with hinted junk cannot push the genuine $DIG coin out of the answer. The unparseable-input path is unreachable (finding 5).

Q2 -- is the fix complete? Yes for this surface.

Both fallback consumers now route through the single helper (rpc.rs:1256, rpc.rs:1382). The only other production coin_records_by_hints caller is refresh_tracked_coins (rpc.rs:2639), which is the legitimate asset-BLIND shape: it upserts with asset_id: None and TAIL-attributes via singleton::reconstruct_all, after which every DB read filters on asset_id (rpc.rs:1207-1218, rpc.rs:1339-1343). Spend selection is DB-based (select_cats over unspent_coins(Some(asset))), so it was never served the unfiltered set. Residual: finding 1.

Q3 -- fail open or fail closed? CLOSED, and that is the right direction here.

BalanceAsset::cat_coin_puzzle_hash (rpc.rs:113-121) propagates the parse_puzzle_hash error (rpc.rs:128-134) rather than degrading to "no filter", so an unusable scoping hash FAILS the read as WALLET_READ_FAILED instead of restoring the original bug. Fail-open is exactly dig_ecosystem#2879, so closed is correct for this surface -- and see finding 5 for why the branch is unreachable in production anyway.

Boundaries

  • CLAUDE.md 908 / node-user-app identity boundary: intact. Read-only path; zero signing, no key/seed/mnemonic material anywhere in the diff.
  • chia-peer trust: untouched. No config.trusted, no TrustLevel, no peer-priority change. Grepping added lines for trusted / sign / secret / seed / mnemonic / token / unsafe returns only doc prose and #[cfg(test)]unwrap() calls.
  • Amplification: unchanged, and the limiter still runs ahead of the expensive step. One chain call per read, same as before; fallback_rate.try_acquire() still precedes asset_scoped_fallback_coins in both arms (rpc.rs:1250-1253, rpc.rs:1376-1379). The filter only shrinks the response. Reachability of control.wallet.balance / control.wallet.coins is unchanged (open token-less reads, network-reachable only under DIG_NODE_ALLOW_REMOTE), and the diff adds no new entry point.
  • Dependencies: none added. The Cargo.lock delta is the 0.118.0 -> 0.118.1 version line only.

Findings -- all NON-GATING (phase-1 posture, CLAUDE.md 2.6)

1. MEDIUM (pre-existing, narrowed not closed) -- the filter is a puzzle-hash filter, not a lineage proof, so an unlineaged "fake CAT" still inflates the fallback $DIG figure.crates/dig-wallet/src/sage/rpc.rs:1148-1152.

Scenario: an attacker spends their own XCH creating a coin with puzzle_hash = cat_puzzle_hash(victim_p2, DIG_ASSET_ID) and a hint memo to victim_p2. It passes the filter (the hash IS the canonical curry) but has no valid CAT lineage, so it is unspendable. Because $DIG has 3 decimals and the mojo amount is counted 1:1 as base units, 10^9 mojos -- 0.001 XCH, well under a cent -- displays as 1,000,000 $DIG the victim does not have. Same class and same direction (inflation) as #2879. Not introduced here: pre-fix ANY hinted coin counted, so this PR strictly narrows the vector. The DB tier is unaffected (asset_id comes from singleton::reconstruct_all, which walks the parent spend). Follow-up ticket: attribute the TAIL on the fallback tier too, or mark fallback CAT figures as lineage-unverified.

2. LOW / gate-integrity -- the three new tests cannot catch a WRONG curry.rpc.rs:4818-4824 builds the fixture $DIG puzzle hash with the same digstore_chain::cat::cat_puzzle_hash the filter uses at rpc.rs:118, so they prove self-consistency, not correctness of the hash. If that curry were ever wrong (or $DIG became revocable), every $DIG holder answered by the fallback tier would see a false zero and all three tests would stay green. The independent evidence I measured instead:

  • chia-sdk-driver-0.30.0/src/primitives/cat/cat_info.rs:92-93 -- CatInfo::puzzle_hash() is literally CatArgs::curry_tree_hash(asset_id, inner_puzzle_hash()), the same primitive digstore-chain/src/cat.rs:39-41 calls, and inner_puzzle_hash() equals the p2 hash for a non-revocable CAT (cat_info.rs:80-89).
  • digstore-chain/src/wallet.rs:76 -- the production $DIG scan already locates real $DIG by exactly this hash; that is the hub working $DIG path today.
  • digstore-chain/src/cat.rs:799-823 -- a CAT send is asserted to create the coin at cat_puzzle_hash(recipient_ph, asset_id).
  • rpc.rs:8366 -- this repo own simulator test derives the hinted CAT coin puzzle_hash from the SDK Cat::child.

Cheap hardening, one line: add an assert_eq! in hinted_multi_asset_fixture comparing cat_puzzle_hash(...) against an SDK-derived Cat::child(...).coin.puzzle_hash. No such cross-check exists in this repo today (grepped).

3. LOW -- a revocable CAT, or any non-p2 inner puzzle, is excluded by construction.rpc.rs:113-121. Not live: BalanceAsset admits only xch / dig, and $DIG is a plain CAT2 (evidence in finding 2). It becomes a false zero the day an arbitrary caller-supplied assetId reaches this path -- worth a note where that widening would land.

4. INFO -- tier divergence, and SPEC slightly overstates the equivalence. DB tier = hint IN (p2) AND asset_id = dig (TAIL-attributed, inner-puzzle agnostic); fallback tier = puzzle_hash == cat_puzzle_hash(p2, dig) (curry-exact, lineage-blind). They differ for (i) a $DIG coin hinted to us with a non-standard inner puzzle (DB includes, fallback excludes) and (ii) an unlineaged fake at the canonical hash (fallback includes, DB excludes). SPEC.md:1508 now calls the filter "the exact equivalent of the DB tier hint IN (...) AND asset_id = ?" -- equivalent for a standard wallet, not literally. Doc nit.

5. INFO -- shared error code checked, NOT live.parse_puzzle_hash returns Error::internal, mapped by read_err to BalanceError::ReadFailed -> WALLET_READ_FAILED, sharing one code with a genuine chain-read failure. That is the shared-error-code-hides-a-bypass shape, so I chased it: the branch is unreachable from both RPC entry points -- puzzle_hash is normalize_ph(hex::encode(Bytes32)) out of decode_address (rpc.rs:1156-1157, rpc.rs:1307-1308), hence always 64 lowercase hex. No attacker-reachable route to a false zero through a malformed asset id.

Measured vs reasoned

MEASURED (read at source): the two call sites and the single helper; normalize_ph / norm_hex symmetry; map_record carrying the coin real puzzle hash; include_hinted = false on the puzzle-hash read (chia-query/src/peer/mod.rs:160-178) -- which is what makes the new XCH control test a valid double rather than an optimistic one; the peer pagination loop; the rate-limiter ordering; the empty dependency delta in Cargo.lock; CatInfo::puzzle_hash and digstore_chain::cat::cat_puzzle_hash resolving to the same primitive.

REASONED (arithmetic, not compiled): the new tests are non-vacuous -- the fixture sums to 107,012,345 unfiltered against an asserted 12,345 / 678, so deleting the filter cannot leave them green. I did not build a worktree; the claim most at risk (Q1 false zero) is not settleable by a unit test that reuses the production curry, which is finding 2.

Merge preconditions (for the orchestrator, not part of this verdict)

Required checks asserted BY NAME at 4d860c1 -- Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage: all SUCCESS. unresolvedReviewThreads = 1 (the correctness leg GATING doc-placement finding at rpc.rs:1107 -- balance_for_address lost its doc comment because the new helper doc block was inserted between the doc and the fn). PR is DRAFT and stays draft.

…ress
Relocate the asset_scoped_fallback_coins function with its own doc comment
to precede the balance_for_address doc comment, restoring the direct
association between the documentation and the function it describes.
This fixes the misplaced doc comment that was inserted between
balance_for_address's documentation and its function definition.
Fixes dig_ecosystem#2879.
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 14, 2026 11:32
@MichaelTaylor3d
MichaelTaylor3d merged commit ace459c into mainAug 14, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/2879-fallback-asset-filter branch August 14, 2026 11:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@MichaelTaylor3d