fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG - #391
Conversation
2e58d61 to
a1360ed
Compare
Real-machine measurement — and the finding: PR-A alone does NOT restore the balanceMeasured 2026-08-28 on this machine, against live mainnet, at head The brief asked me to reproduce 1. BEFORE — the installed node, which is
|
| query | result |
|---|---|
SELECT COUNT(*) FROM coins |
959 |
rows at the $DIG CAT outer hash 6ce1cdf8… |
0 |
| rows at the wallet's literal p2 hash | 948 |
rows with a non-null asset_id |
0 |
COUNT(DISTINCT puzzle_hash) |
3 — the three p2 hashes, and nothing else |
And on chain, by hint:
get_coin_records_by_hint 0xbac5d2ab… (our p2)
-> 136 unspent coins, across 51 DISTINCT puzzle hashes, including 0x6ce1cdf8…
So the peer really does return the hinted coins, and the replica keeps only the 948 whose puzzle hash
is literally a subscribed p2 hash. apply_coin_states filters on
subscribed.contains(&s.coin.puzzle_hash), and subscribed is the p2 set — so all 50 other hinted
puzzle hashes, the $DIG CAT hash among them, are dropped at ingest. That is #380, and it is upstream
of everything PR-A does.
3. PR-A's own pass, run over the real replica against real mainnet reads
I ran the production CatAttributor::attribute — the exact call the supervisor now makes — over a copy
of the real replica, with a real ChiaQueryLineage hitting mainnet:
[BEFORE] coins=959 at_dig_cat_hash=0 attributed=0 dig_balance=0
[AFTER-PASS] coins=959 at_dig_cat_hash=0 attributed=0 dig_balance=0
4. The calibration — the instrument is not broken
A measured zero is worthless unless the instrument can produce a non-zero. So I seeded the wallet's
eight real $DIG coins, read from api.coinset.org immediately beforehand, into the same replica as
unattributed rows (exactly what apply_coin_states would have written had it not dropped them), and ran
the same pass, same code, same chain:
[chain] unspent coins at the DIG CAT hash: 8 totalling 3856455 mojos
[CALIB-BEFORE] coins=967 at_dig_cat_hash=8 attributed=0 dig_balance=0
[CALIB-AFTER] coins=967 at_dig_cat_hash=8 attributed=8 dig_balance=3856455
All eight attributed, and the balance is 3,856,455 — matching api.coinset.org exactly.
That is the acceptance figure this family has been chasing, and it lands through PR-A's pass, including
the coin-id binding check and the placeholder repair (the parent is 567d481d…, the very coin the peer
tier answers with a zeroed placeholder for). So the attributor wiring works, end to end, on real
money. What it lacks is input.
5. What this means for the split boundary
- The named defect in CAT asset_id attribution never runs in production, so $DIG balance reads zero on a funded wallet #382 — the supervisor passing a hard-coded
None— is genuinely fixed here,
and the calibration is the proof. It is not a paper fix. - The user-visible symptom in CAT asset_id attribution never runs in production, so $DIG balance reads zero on a funded wallet #382's title — "a funded wallet reports a $DIG balance of zero" — is
NOT fixed by PR-A alone, and cannot be, because the coins never reachcoins. - This does not contradict the round-6 decision, which explicitly assigns ingestion to PR-B (§6) and
states the one-pass delay for the combined shape (§5). But it does meanCloses #382on PR-A is
too strong. I have left the keyword off and referenced CAT asset_id attribution never runs in production, so $DIG balance reads zero on a funded wallet #382 instead, so the observable is only
declared fixed when a person can see it.
6. On the decider's stated cost — "one out-of-band pass of delay"
Not what I observe on PR-A alone. For a newly arrived CAT coin the delay is not one pass; it is
unbounded, because the coin is dropped at the frame and never becomes a candidate for any pass. The
one-pass delay is a property of the combined shape and holds only once PR-B's ingestion lands. On
PR-A alone the honest statement is: a CAT row already in the replica is attributed within one pass
after the catch-up; a CAT coin arriving from a peer is never attributed, because it is never
admitted.
7. Scope confirmation
No derived-hash or staging code rode along. git grep over crates/ at a1360ed:
derived_cats 0 · DerivedCats 0 · CatIdentity 0 · cat_admission_pending 0 · admit_hinted 0 ·
BoundedLineage 0 · AdmissionOutcome 0 · IncompleteBatch 0 · KNOWN_CAT_ASSET_IDS 0 ·
LineageAnswer::Deferred 0.
(TokenBucket matches four files, all pre-existing rate-limiting in dig-node-core and
sage/rate_limit.rs; it appears 0 times in git diff origin/main.)
Diff is 1,198 insertions / 64 deletions across 9 files, against #383's 3,161 lines.
8. Suite + the revert-proof
cargo test -p dig-wallet --lib — 678 passed, 0 failed, 1 ignored.
The new supervisor-level test is load-bearing. Committing first, then reverting only the fix
(threading attributor.as_ref() back to None and disabling the post-catch-up pass), leaving the test
untouched:
the_supervisor_attributes_an_unattributed_cat_row_in_its_replica ... FAILED
left: None
right: Some("7cc7e6a7c9fb65550e8fda00764038cf706d403d8c0cf59da7dfee778f3841d1")
Red for the right reason — the row is unattributed, not some unrelated panic. Restored from a file
copy, git status --porcelain empty afterwards.
c97e218 to
5a7a26e
Compare
loop-security — IN PROGRESS, not the verdictAuditing Finding 1 (NON-GATING, hygiene) —
|
loop-security — IN PROGRESS (2 of 3), not the verdictHead audited: The rebase resolutions — all three check out
|
loop-security verdict: PASSHead audited: No security defect in this diff. Two non-gating items are recorded below with recommended The primary hunt: did the rebase reopen #394's fabricated-coin hole?No. All three resolutions land on the correct side, and the one place the lane resolved toward
Nothing treats
|
| mutation | claimed | measured |
|---|---|---|
M1 — ChiaPeerSession::run attributor -> None (sync_supervisor.rs:2445) |
GREEN 706 | GREEN, 706 passed / 0 failed |
M2 — supervisor construction -> None (sync_supervisor.rs:1407) |
RED, 3 fail | RED, 703 passed / 3 failed |
| M3 — remove post-catch-up pass only | RED, 2 fail; update-loop green | RED, 704 passed / 2 failed; update-loop test GREEN |
Baseline: 706 passed, 0 failed, 1 ignored. M2's three are exactly the attribution tests; M3's two
exclude a_frame_on_a_live_session_attributes_through_the_update_loop, confirming the two call
sites are genuinely separated.
M1's null IS structural — adjudicated, not accepted
The lane is right, and I verified the mechanism rather than the claim:
- Exactly two
impl SyncSessionexist —ScriptedSession(sync_supervisor/tests.rs:253) and
ChiaPeerSession(sync_supervisor.rs:2388). ChiaPeerSessionis constructed at only two sites,sync_supervisor.rs:1992and:2031, and
BOTH require a liveconnect_peer/connect_random_peer_excludingreturning a realPeerand
receiver from an actual TLS dial. The struct cannot be built without a socket.
No unit test in this crate can reach that line. Measuring it twice and declining to manufacture
a covering test was the correct call. Stating the residual plainly: passing None there still
COMPILES, so the only thing protecting that one line is review — which is exactly how #382 shipped.
Only an e2e harness against a real peer closes it. Recommend naming that on dig-node#396.
M3's catch_up_count pin genuinely discriminates
Verified two ways. By reading: the poll loop breaks on a second catch-up and then asserts the count
is exactly 1, so a reconnect ends the window and fails the test rather than silently explaining the
result. The "before is None" precondition independently asserts the post-catch-up pass did not
attribute while the source was gated. Empirically: under M3 that test stayed green while the
other two went red — only possible if it does not depend on the post-catch-up pass.
Also verified clean
- Wiring:
sync_supervisor.rs:2445forwards a real attributor; built at:1407from the
per-attempt subscription set (not a boot snapshot); the post-catch-up pass is best-effort and
logs rather than failing the session.service.rsbuilds it onenable_chain_sync, correctly
not onenable_live_broadcast— gating a READ on the spend flag would reproduce CAT asset_id attribution never runs in production, so $DIG balance reads zero on a funded wallet #382 on
every read-only install. - SPEC 18.11a collision:
18.11.,18.11a.,18.11b.,18.11c.each appear exactly once.
The head's full duplicate-section set (10.4.,18.6c.,18.7a.,18.9a.) is identical to
base's — every collision predates this PR. No clause lost: the block grows 121 to 156 lines and
all 11 deletions in the file are the two deliberately-rewritten paragraphs. - Scratch files: none at HEAD.
- Authorship: sole commit is
Michael Taylor <michael@michaeltaylor.dev>— the configured
machine identity, not a fabricated one. - Secrets: no credential-shaped addition. Every regex hit is SPEC prose describing the existing
control-token scheme. - Versions: minor is correct.
LineageSource::parent_spendandSyncSession::runare both
public and both change signature; on a 0.x crate the minor IS the semver-incompatible bump, so
dig-wallet 0.40.0 to 0.41.0 is right, and dig-node 0.162.0 to 0.163.0 follows. - Reorg:
rollback_above(db.rs:1971) DELETEs coins above the fork, so an
attribution_examinedmark cannot survive a reorg as stale state. - Merge preconditions by name: tip
5a7a26eb, all five required contexts present and SUCCESS —
Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage —
unresolvedReviewThreads=0, blocked ondraft=truealone. - One chia line, from the resolved lock:
Cargo.lock's diff is EXACTLY the two version bumps,
and the resolved set ofchia-*versions is byte-identical to base. Correcting the brief on
one point: the lock does not carry a single chia line (chia-bls resolves 5 versions via clvmr
and chialisp transitives) — but that is pre-existing, is the situation CLAUDE.md 2.4b explicitly
says a duplicate-tree check cannot gate on, and this PR changes none of it. Not a finding
against fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG #391.
Non-gating findings
N1 (hygiene, recommend fixing before merge). SPEC.md flipped LF to CRLF. Base has 0 CR bytes;
head has 7,614, one per line. Raw diffstat is 15,187 changed lines; ignoring CR at EOL it is
52 insertions, 11 deletions. Not a security defect and I do not gate on it, but it destroys
git blame on the repo's normative spec and buries the real change under 15k lines of noise —
the exact condition under which a lost clause goes unnoticed. I checked the content by hand and
nothing was lost, but the next reader will not. Suggest normalising to LF, or a .gitattributes
entry pinning SPEC.md to LF.
N2 (defense-in-depth, follow-up ticket — do NOT gate). A corroborated Absent is remembered
permanently, and the read behind it is not height-guarded. singleton.rs:517 marks
attribution_examined on Absent, and chia-query's get_coin_spend_opt(coin_id) takes no
height (router.rs:480) — it asks "is there a spend" of peers that may legitimately sit up to
PEAK_LAG_EVICTION (3 blocks) behind the reference peak. So for a coin whose parent was spent
within the last few blocks, a truthful but lagging quorum can produce a corroborated absence, and
the row is then never re-examined for the life of the replica.
Scenario: coin C arrives from the sync peer at height H; chia-query's pool sits at H-2 and is still
"current"; all three answer "no spend" for C's parent; C is marked examined forever and never
reconstructed.
Why this is NOT gating:
- No money-loss leg. XCH selection is scoped by
puzzle_hash IN (wallet plain p2 hashes) AND asset_id IS NULL(db.rs:2740). An unreconstructed NFT/DID/CAT coin sits at a singleton or CAT
outer hash, not a plain p2 hash, so it is scoped out of XCH selection. The failure is an
under-report, which SPEC 18.11a explicitly permits: a wallet may under-report, it must never
report a figure that is wrong. - No new attacker primitive. Forcing an absence requires controlling the corroboration quorum,
which is already the NC-12 boundary. What this PR changes is the durability of that lie —
transient before, permanent now. A strengthened payoff for an existing capability, not a new one. - Recoverable by a resync, and deliberately specified in the new SPEC 18.11c.
Suggested follow-up: skip the mark when the coin's created_height is within PEAK_LAG_EVICTION
of the current peak, or clear marks on a slow periodic sweep.
Coverage I did NOT achieve — stated plainly
- No real-wallet acceptance run. Not claimable on this machine (dig-node#396); I did not mark
it down and did not treat its absence as a defect. - Only the dig-wallet lib suite — 706 tests. I did not run the workspace suite, the
tests/real_wallet_cat_discovery.rsintegration target, clippy, or rustfmt myself; I relied on
CI's five green required contexts for those. - No coverage of
ChiaPeerSession::run— structurally impossible here (see M1). The one
production line this PR exists to fix is verified by reading only. - chia-query read partially. I verified the constant, the "besides the answering one"
semantics and thesettle_peer_answerrouting; I did not audit the whole peer-pool
corroboration implementation, so N2's reasoning aboutPEAK_LAG_EVICTIONrests on that
constant's own documentation rather than on tracing every armed/eviction path. - No gitnexus impact run — the available index is superproject-scale and the 2.0 bounds forbid
holding a lane on it; I used grep sweeps plus direct reads instead, and the
LineageAnswer::Absentconsumer sweep is the compensating measurement. - SPEC prose reviewed only in the changed region, plus whole-file section numbering.
PASS. Do not merge on this alone: the PR is still draft=true, and the correctness gate plus
all-checks-green plus all-threads-resolved remain separate preconditions.
…es its $DIG PR-A of the #383 split. sync_supervisor passed None where a CatAttributor belongs, so CAT asset_id attribution never ran in production and a funded wallet reported a confident zero $DIG. Scoped deliberately to the attributor wiring plus the fixes verified sound across five review rounds: get_coin_spend_opt's corroborated absence, the coin-id binding and placeholder repair, and from_lookup. CAT discovery by derived puzzle hash is NOT here -- it needs staging before it is safe, and it is dig-node#390. Deleted with it: SPEC 18.11a, which was born false in the diff that wrote it; the frame-path attribution write; LineageAnswer::Deferred; the CountingLineage scaffolding, whose "0 outbound reads" assertion measured nothing because the counter was never passed to apply_coin_states; and the false claims that get_cats becomes "complete". Why the split: five rounds each fixed one defect and introduced the next. That is a unit-size problem rather than five careless rounds, and this half is independently mergeable and independently verifiable against a real wallet. Closes #382 Co-Authored-By: Claude <noreply@anthropic.com>
5a7a26e to
e05da67
Compare
Run CAT attribution in production, so a funded wallet can name its $DIG
Closes #382·Refs #380(which closes only when a funded wallet reports its real balance afteran ordinary sync) ·
Refs #396Rebased onto
2f0f148(post-#393, post-#395).What was wrong
sync_supervisor.rspassed a hard-codedNonewhere aCatAttributorbelongs, andCatAttributorwas constructed only under
#[cfg(test)]. The one production promotion site (rpc.rs) isreachable solely from the point-read tier. So #393's staging and promotion machinery was present,
correct, and — on the shipped node — never ran during ordinary sync. A funded wallet reported a
confident zero.
This PR builds the attributor in the supervisor and threads it through
SyncSession::runintorun_update_loop, and runs one pass after a completed catch-up so a quiet replica still attributeswhat it holds. Post-#393
CatAttributor::attributerunspromote_staged_catsandreconstruct_all, so the wiring now reaches both halves.Blast radius checked
impactwas unavailable in this worktree (no index built; §2.0 permits the documented fallback), sothe radius was established by call-graph grep + direct read and then measured by compiling the
whole workspace.
LineageSource::parent_spend— return type changedResult<Option<ParentSpend>>→Result<LineageAnswer>. Implementors, ecosystem-wide: 4, all incrates/dig-wallet(
ChiaQueryLineage, plus three test doubles).grepacross every other crate forLineageSource for/parent_spendreturns nothing, andcargo check --workspace --all-targetsis clean.parent_spend:singleton::reconstruct_coinsandcat_discovery::promote_staged_cats— both adapted, both covered below.
SyncSession::run— gained anattributorparameter. Implementors: 2 (ChiaPeerSession,ScriptedSession).actually bit was
cat_discovery.rs, a 1,597-line file added by feat(wallet): stage derived-hash CAT arrivals, promote only lineage-proven coins #393 that this branch had neverseen. Predicted radius is a hypothesis; the compile is the measurement.
No HIGH/CRITICAL-risk symbol was edited blind, and nothing outside
crates/dig-wallet/src/sage/istouched.
Rebase — what was resolved toward
main, and the one place it was notThree conflicts, plus one silent integration failure the conflict markers did not show.
db.rs— additive migration lists. Both kept.singleton.rs— a genuine clash.main(refresh_tracked_coins admits hint-matched coins as XCH, so a fabricated coin reaches the spend selector #394) had made every unsuccessful parent read anErrand handled it per coin, so one flaky read could not abandon the pass. This branch hadthe richer
LineageAnswerbut used.await?, which reintroduces exactly the failure refresh_tracked_coins admits hint-matched coins as XCH, so a fabricated coin reaches the spend selector #394removed, through a narrower door. Synthesised: the three-way answer is kept, and the
Errarm is a per-coin skip, as
mainhad it.fallback.rs— resolved toward this branch, deliberately, and this is the one place the"resolve toward
main" instruction was not followed.mainsays it cannot distinguish anabsence from an outage because "only the inner coinset client exposes an absence-aware read, and
lifting it onto the facade is a chia-query release this PR will not take". That premise is
factually wrong:
ChiaQuery::get_coin_spend_optis on the facade (chia-query 0.19.0,lib.rs:347).main's direction — a failure is never an absence — is preserved unchanged; whatchanges is that a real absence can now be reported at all. Verified in 0.19.0 rather than
assumed:
Ok(None)arrives only viaOptAnswer::CorroboratedAbsent, and an uncorroboratedabsence is
ChiaQueryError::UncorroboratedAbsence(anErr), where corroboration meansCORROBORATION_FLOOR = 2peers besides the answering one. One hostile peer cannot mint anabsence. A stale comment in this branch that named the wrong error (
SourcesDisagree) wascorrected.
cat_discovery.rs— not a conflict; it simply did not compile. Adapted so promotionbehaviour is byte-for-byte unchanged:
Absent,UnavailableandErrall defer exactly asOk(None)andErrdid. Deliberately not "improved" to treatAbsentas a disproof —corroboration is agreement, not currency; every source can agree and every source can be
behind the chain, so discarding on
Absentdeletes real coins whenever sources lag. A test nowpins that.
Proof, entering above the narrowing
the_supervisor_promotes_a_staged_cat_admission_during_an_ordinary_syncenters at the top of#393's chain —
route_point_read_rowsdecides the coin is a claim,stage_cat_admissionspersistsit — and then runs only the real
Supervisor. It never namespromote_staged_cats,CatAttributororrun_update_loop, and never callsdb.upsert_coin. It asserts throughunspent_coins(Some(asset)), the query behind every $DIG balance, not throughall_coins.Fixture preconditions are asserted, not assumed: that the coin is staged and not believed (a
believed coin would make the test exercise the plain-XCH path while reading like a promotion proof),
that it is recognised at its derived hash, and that
coinsis empty before the supervisor runs.The control is the load-bearing half: with
attribution: Nonethe coin stays staged andcoinsstays empty — the shipped node's behaviour today.
Mutation transcript, including an honest null
ChiaPeerSession::run'sattributor→None(the literal #382 line)let attributor = self.attribution.as_ref().map(…)→NoneM1 is the honest null, and it is structural rather than a weak assertion. The supervisor holds
its session as
dyn SyncSession, and every unit test substitutesScriptedSession;ChiaPeerSessionis only ever constructed against a real TLS peer connection, so no unit test inthis crate can cover that line. This was measured, not inferred — M1 was run against the full
suite twice, once before and once after adding a test written specifically to catch it. Saying so
is better than manufacturing a test that appears to cover it.
What M2 shows is that everything upstream of that line is covered, and it is production code:
the supervisor building the attributor, handing it to
SyncSession::run, and an implementationforwarding it to
run_update_loop.M3 is the discriminator that makes the third test meaningful: removing only the post-catch-up pass
kills the two tests that depend on it and leaves
a_frame_on_a_live_session_attributes_through_the_update_loopgreen, proving that test measures theupdate-loop path and not the catch-up pass.
That third test needed two iterations, both recorded in its doc comment. Its lineage source answers
nothing until armed, and is armed only after the post-catch-up pass has run and failed. The
first version still passed under mutation, because a reconnect reruns the catch-up and the second
attempt's pass attributed the row with the now-armed source; a
catch_up_count() == 1assertioncloses that hole.
Fixture-blindness fixes
CountingLineageandCapturedLineagefolded every miss to a single answer. Both were widened, soa double can express both of production's miss modes. A new
Missenum defaults toUnavailable— the weaker claim — because a double silently asserting a settled absence would let a test
read a deferral as a disproof.
a_corroborated_absence_defers_the_row_exactly_as_an_unreadable_parent_doesvaries oneactor and keeps a truthful control: the honest CAT resolves in both runs and must promote in both,
so an implementation that abandoned the table on the first miss fails visibly. It goes red on the
tempting
Absent→ discard change.Coherence
18.11a. feat(wallet): stage derived-hash CAT arrivals, promote only lineage-proven coins #393's keeps the number; mine arerenumbered
18.11b/18.11cand moved after it, with the cross-reference fixed.production" — false as of this PR. Rewritten, along with the matching stale comment in
rpc.rsthat called the point-read tier "the ONE promotion site".
§2.4b
chia-*is already on the highest coherent line and is deliberately not bumped.chia-wallet-sdk's own latest publish is 0.36.0, and its index entry requires^0.36.1acrosschia-protocol/chia-bls/chia-puzzle-types/chia-traits/chia-sha2/clvm-utilsand^0.16.2forclvmr— exactly what is declared. Moving the set to 0.48 would split the crateacross two chia lines, which is the failure §2.4b names.
chia-query0.19.0 = latest.dig-node-control-interface0.24,dig-offers0.3,dig-clvm0.4,
dig-keystore0.13 — all at latest.digstore-chain/digstore-corearegit-rev dependencies,an NC-7 violation and invisible to a registry freshness check. Filed rather than fixed, as it is
unrelated to this change.
Version
0.162.0 → 0.163.0(root) and0.40.0 → 0.41.0(dig-wallet). Minor, not patch: although thecommit is a
fix, the publicLineageSource::parent_spendsignature and theSyncSession::runsignature both change, which is breaking for any implementor.
What could NOT be measured
No real-wallet acceptance run is claimed, because none can be made on this machine. Measured
directly against a copy of
%LOCALAPPDATA%\DigNode\wallet.sqlitetaken with its-waland-shm: 948 coins, 1 distinct puzzle hash, 0 coins carrying a hint, 0 attributed, andcats/nfts/didsall empty.cat_admission_pendingdoes not exist on that replica at all.There is no CAT coin in it to attribute, hinted or otherwise — the node never subscribed to its own
$DIG address, so those coins were never fetched. A resync is required after this lands, not
merely an upgrade (#396, dig-app#295).
Everything proven here is proven against the simulator and in-memory SQLite.
#380therefore staysopen and is
Refs, notCloses: it closes when a funded wallet reports its real balance after anordinary sync.