Skip to content

fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG - #391

Merged
MichaelTaylor3d merged 1 commit into
mainfrom
loop/382-attributor-wiring
Aug 28, 2026
Merged

fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG#391
MichaelTaylor3d merged 1 commit into
mainfrom
loop/382-attributor-wiring

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 after
an ordinary sync) · Refs #396

Rebased onto 2f0f148 (post-#393, post-#395).

What was wrong

sync_supervisor.rs passed a hard-coded None where a CatAttributor belongs, and CatAttributor
was constructed only under #[cfg(test)]. The one production promotion site (rpc.rs) is
reachable 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::run into
run_update_loop, and runs one pass after a completed catch-up so a quiet replica still attributes
what it holds. Post-#393 CatAttributor::attribute runs promote_staged_cats and
reconstruct_all, so the wiring now reaches both halves.

Blast radius checked

impact was unavailable in this worktree (no index built; §2.0 permits the documented fallback), so
the radius was established by call-graph grep + direct read and then measured by compiling the
whole workspace.

  • LineageSource::parent_spend — return type changed Result<Option<ParentSpend>>
    Result<LineageAnswer>. Implementors, ecosystem-wide: 4, all in crates/dig-wallet
    (ChiaQueryLineage, plus three test doubles). grep across every other crate for
    LineageSource for / parent_spend returns nothing, and cargo check --workspace --all-targets is clean.
  • Callers of parent_spend: singleton::reconstruct_coins and cat_discovery::promote_staged_cats
    — both adapted, both covered below.
  • SyncSession::run — gained an attributor parameter. Implementors: 2 (ChiaPeerSession,
    ScriptedSession).
  • The prediction was wrong in an instructive way and is recorded rather than hidden: the radius that
    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 never
    seen. 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/ is
touched.

Rebase — what was resolved toward main, and the one place it was not

Three conflicts, plus one silent integration failure the conflict markers did not show.

  1. db.rs — additive migration lists. Both kept.
  2. 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 an
    Err and handled it per coin, so one flaky read could not abandon the pass. This branch had
    the richer LineageAnswer but used .await?, which reintroduces exactly the failure refresh_tracked_coins admits hint-matched coins as XCH, so a fabricated coin reaches the spend selector #394
    removed, through a narrower door. Synthesised: the three-way answer is kept, and the Err
    arm is a per-coin skip, as main had it.
  3. fallback.rs — resolved toward this branch, deliberately, and this is the one place the
    "resolve toward main" instruction was not followed. main says it cannot distinguish an
    absence 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_opt is on the facade (chia-query 0.19.0,
    lib.rs:347). main's direction — a failure is never an absence — is preserved unchanged; what
    changes is that a real absence can now be reported at all. Verified in 0.19.0 rather than
    assumed: Ok(None) arrives only via OptAnswer::CorroboratedAbsent, and an uncorroborated
    absence is ChiaQueryError::UncorroboratedAbsence (an Err), where corroboration means
    CORROBORATION_FLOOR = 2 peers besides the answering one. One hostile peer cannot mint an
    absence. A stale comment in this branch that named the wrong error (SourcesDisagree) was
    corrected.
  4. cat_discovery.rs — not a conflict; it simply did not compile. Adapted so promotion
    behaviour is byte-for-byte unchanged: Absent, Unavailable and Err all defer exactly as
    Ok(None) and Err did. Deliberately not "improved" to treat Absent as a disproof —
    corroboration is agreement, not currency; every source can agree and every source can be
    behind the chain, so discarding on Absent deletes real coins whenever sources lag. A test now
    pins that.

Proof, entering above the narrowing

the_supervisor_promotes_a_staged_cat_admission_during_an_ordinary_sync enters at the top of
#393's chain — route_point_read_rows decides the coin is a claim, stage_cat_admissions persists
it — and then runs only the real Supervisor. It never names promote_staged_cats,
CatAttributor or run_update_loop, and never calls db.upsert_coin. It asserts through
unspent_coins(Some(asset)), the query behind every $DIG balance, not through all_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 coins is empty before the supervisor runs.

The control is the load-bearing half: with attribution: None the coin stays staged and coins
stays empty — the shipped node's behaviour today.

Mutation transcript, including an honest null

Mutation Result
M1ChiaPeerSession::run's attributorNone (the literal #382 line) GREEN. All 706 tests pass.
M2 — supervisor's let attributor = self.attribution.as_ref().map(…)None RED — 3 tests fail
M3 — remove the post-catch-up pass only RED — 2 fail; the update-loop test stays GREEN

M1 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 substitutes ScriptedSession;
ChiaPeerSession is only ever constructed against a real TLS peer connection, so no unit test in
this 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 implementation
forwarding 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_loop green, proving that test measures the
update-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() == 1 assertion
closes that hole.

Fixture-blindness fixes

  • CountingLineage and CapturedLineage folded every miss to a single answer. Both were widened, so
    a double can express both of production's miss modes. A new Miss enum defaults to Unavailable
    — the weaker claim — because a double silently asserting a settled absence would let a test
    read a deferral as a disproof.
  • The new a_corroborated_absence_defers_the_row_exactly_as_an_unreadable_parent_does varies one
    actor 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

§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.1 across
    chia-protocol/chia-bls/chia-puzzle-types/chia-traits/chia-sha2/clvm-utils and
    ^0.16.2 for clvmr — exactly what is declared. Moving the set to 0.48 would split the crate
    across two chia lines, which is the failure §2.4b names.
  • chia-query 0.19.0 = latest. dig-node-control-interface 0.24, dig-offers 0.3, dig-clvm
    0.4, dig-keystore 0.13 — all at latest.
  • Pre-existing and not fixed here: digstore-chain/digstore-core are git-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) and 0.40.0 → 0.41.0 (dig-wallet). Minor, not patch: although the
commit is a fix, the public LineageSource::parent_spend signature and the SyncSession::run
signature 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.sqlite taken with its -wal and
-shm
: 948 coins, 1 distinct puzzle hash, 0 coins carrying a hint, 0 attributed, and
cats/nfts/dids all empty. cat_admission_pending does 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. #380 therefore stays
open and is Refs, not Closes: it closes when a funded wallet reports its real balance after an
ordinary sync.

@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/382-attributor-wiring branch from 2e58d61 to a1360ed Compare August 28, 2026 07:13
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Real-machine measurement — and the finding: PR-A alone does NOT restore the balance

Measured 2026-08-28 on this machine, against live mainnet, at head a1360ed.

The brief asked me to reproduce {"balance":0}{"balance":3856455} on PR-A alone, and said that
if the attributor wiring by itself does not restore the balance, that is the finding. It does not.
The attributor wiring is correct and complete — I proved that separately — but the balance stays at
zero, because the coins it would name are never in the table.

1. BEFORE — the installed node, which is origin/main

$ dign wallet balance --json --asset dig xch1htza92lzzkaxtgnz39jlfyzjvr9tjnprhjx6zunlue3wn006pgeqfkchv0
{"ok":true,"version":"0.160.0","balance":0,"pending":0,"source":"db","synced":true,"peak_height":9211220}

$ dign wallet balance --json xch1htza92lzzkaxtgnz39jlfyzjvr9tjnprhjx6zunlue3wn006pgeqfkchv0
{"ok":true,"version":"0.160.0","balance":1599179999972,"pending":0,"source":"db","synced":true,"peak_height":9211220}

{"balance":0,"source":"db","synced":true} — the exact string #382 was filed for. The wallet is
genuinely funded and genuinely synced: its XCH balance is 1,599,179,999,972 mojos and the node reports
phase: synced, peak_height: 9211214, chia_peer_count: 8.

Independent corroboration (api.coinset.org, not this node):

get_coin_records_by_puzzle_hash 0x6ce1cdf86cb39059eb43e3c4c0e7c62fdf7d099fdb71033563cc4b9daeab376d
  -> 8 unspent coins, 3,856,455 mojos

That hash is cat_puzzle_hash(p2, DIG_ASSET_ID) for this wallet's first p2 hash — derived here, not
transcribed, and it matches the 6ce1cdf8… recorded in round 1.

2. The measured cause — the coins are not in the replica at all

I read the live node's own replica directly
(%LOCALAPPDATA%\DigNode\wallet.sqlite, under the service profile):

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

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 --lib678 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.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing 5a7a26eb95bda0acea5c77fa9686fa020889a09a in a private detached worktree. Posting findings as
they resolve so none is lost to a watchdog.

Finding 1 (NON-GATING, hygiene) — SPEC.md was flipped LF -> CRLF, rewriting all 7,573 lines

  • base 2f0f148b: 0 CR bytes. head 5a7a26eb: 7,614 CR bytes, one per line.
  • Raw diffstat: 15,187 changed lines. With --ignore-cr-at-eol: 52 insertions, 11 deletions.

Not a security defect, and I am not gating on it. Recording it because of what it COSTS a reviewer: the
line-ending flip makes the real normative change invisible in the GitHub diff view and destroys git blame
on the repo's normative spec. Any reviewer eyeballing the rendered diff on this PR is looking at 15k lines
of noise, which is exactly the condition under which a clause goes missing unnoticed.

Recommend the lane restore LF before merge (git show 2f0f148b:SPEC.md-style normalize, or a .gitattributes
SPEC.md text eol=lf). Follow-up ticket is fine; this does not block.

Verified clean — SPEC section-number collision (the 18.11a double-mint)

The brief flagged that both branches minted an 18.11a and nothing detects it. Checked by hand:

  • 18.11., 18.11a., 18.11b., 18.11c. each occur exactly once in the head SPEC.md.
  • The head's full duplicate-section-number set is 10.4., 18.6c., 18.7a., 18.9a. — and that set is
    byte-identical to the base's. Every collision in the head predates this PR; the PR introduces none.
  • No clause lost: the 18.11a block goes 121 -> 156 lines, and all 11 deletions in the whole file are
    accounted for by the two deliberately-rewritten paragraphs ("The sync loop runs this attribution..." in
    18.11, and "Where promotion runs today" in 18.11a). Nothing else was removed.

The hand resolution of the collision is correct.

Continuing: the three rebase resolutions (db.rs, singleton.rs, cat_discovery.rs), the Ok(None)
restraint sweep, and re-execution of M1/M2/M3.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS (2 of 3), not the verdict

Head audited: 5a7a26eb95bda0acea5c77fa9686fa020889a09a.

The rebase resolutions — all three check out

db.rs — clean. Diff is additions only. The new ALTER TABLE coins ADD COLUMN attribution_examined is APPENDED after #393's two cat_admission_pending migrations
(attempts, last_attempt_at) — neither dropped, neither duplicated. The runner at db.rs:916
is let _ = sqlx::query(stmt), so the duplicate-column error on a fresh DB (which already gets
the column from SCHEMA) is correctly swallowed. POST_MIGRATION_INDEXES runs AFTER the ALTERs,
so the new partial index can legally name the new column.

singleton.rs — the per-coin skip is genuinely restored. singleton.rs:507-540: the Err
arm logs at debug and continues. No ?. #394's failure is not reintroduced. The arm is
narrower than #394's (an outage is now Unavailable, not Err), and both non-fatal arms
continue rather than propagate.

cat_discovery.rs — the promotion equivalence holds. cat_discovery.rs:354/362/370: Absent,
Unavailable and Err all do the identical three things — record_promotion_attempt,
stats.deferred += 1, continue. That is exactly what Ok(None) and Err did pre-rebase. No
promotion decision changed.

Ok(None) is NOT treated as a disproof anywhere — swept, not assumed

Every non-test consumer of LineageAnswer::Absent in the crate, by file:line:

  • cat_discovery.rs:354 — defers, keeps the staged row.
  • singleton.rs:517 — marks attribution_examined, continue. Does not set asset_id, does
    not discard the row, does not refuse it.

sync.rs:2854 and sync.rs:2932 are both inside #[cfg(test)] mod tests (opens at sync.rs:1271).
fallback.rs:555/603/621 PRODUCE the value; they do not consume it. So the restraint the lane
described holds in both places it could have failed, not only in the one it was described in.

chia-query claims verified against the published 0.19.0 source

  • CORROBORATION_FLOOR = 2chia-query-0.19.0/src/peer/plurality.rs:47. Confirmed.
  • "2 BESIDES the answering one" — confirmed: peer/mod.rs:80 says "independent peers besides the
    one that answered", and peer/corroboration_tests.rs:150 says "Three peers, not two: the
    answering peer cannot corroborate itself." The brief's phrasing is accurate.
  • Only OptAnswer::CorroboratedAbsent becomes Ok(None)router.rs:238. UncorroboratedAbsent
    is routed to settle_uncorroborated_absence. So fallback.rs:538-546's comment is accurate and
    one hostile peer cannot mint an absence.

M1 reproduced: GREEN, and the null IS structural

Reverting the literal #382 line (sync_supervisor.rs:2445, attributor -> None) gives
706 passed, 0 failed — identical to baseline. Adjudicated as genuinely structural:

  • Exactly two impl SyncSession exist — ScriptedSession (sync_supervisor/tests.rs:253) and
    ChiaPeerSession (sync_supervisor.rs:2388).
  • ChiaPeerSession is constructed at only two sites, sync_supervisor.rs:1992 and :2031, and
    BOTH require a live connect_peer / connect_random_peer_excluding returning a real Peer and
    receiver from an actual TLS dial. The struct cannot be built without a socket.

So no unit test in this crate can reach that line. The lane measured this rather than manufacturing
a covering test, and that was the right call. Residual risk worth stating: passing None there
still COMPILES, so the only thing protecting that line is review — which is exactly how #382
shipped.
Only an e2e harness against a real peer closes it. Not gating; recommend it be named on
the #396 follow-up.

Also clean

No scratch-shaped files at HEAD (.py/pr_body/lanework/.bak/.log/.orig all absent).
Sole commit authored Michael Taylor <michael@michaeltaylor.dev> — the configured identity, not a
fabricated one. Cargo.lock diff is EXACTLY the two version bumps, and the set of resolved
chia-* versions is byte-identical to base, so this PR splits nothing across chia lines.

Still running: M2/M3, the M3 catch_up_count() pin, merge preconditions by name.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security verdict: PASS

Head audited: 5a7a26eb95bda0acea5c77fa9686fa020889a09a (resolved from the remote myself;
gh pr view 391 --json headRefOid). Base 2f0f148be044d4ebc9543077d53b1f70b43df81e. Read in a
private detached worktree at C:\tmp\worktrees\dn391-sec; no shared checkout was touched.

No security defect in this diff. Two non-gating items are recorded below with recommended
follow-ups; neither blocks the merge.

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
its own branch is justified.

Resolution Verdict
db.rs migrations Clean — additive only
singleton.rs per-coin skip Genuinely restored, proven by mutation
cat_discovery.rs promotion Behaviourally identical, proven by the lane's own two-arm test

singleton.rs:507-540 — the per-coin skip is real. The Err arm logs at debug and
continues, with no ?. #394's failure is not re-entered through the narrower door. Both
non-fatal arms (Unavailable, Err) continue and leave the row UNMARKED, so a flaky read
cannot abandon the pass and cannot write a coin off.

cat_discovery.rs:354/362/370 — equivalence holds. Absent, Unavailable and Err each do
the identical three things: record_promotion_attempt, stats.deferred += 1, continue. That is
exactly what Ok(None) and Err did pre-rebase. No promotion decision changed.

db.rs — nothing duplicated, nothing skipped. The new ALTER TABLE coins ADD COLUMN attribution_examined appends after #393's two cat_admission_pending migrations (attempts,
last_attempt_at), both intact. The runner at db.rs:916 is let _ = sqlx::query(stmt), so the
duplicate-column error on a fresh DB is correctly swallowed, and POST_MIGRATION_INDEXES runs
after the ALTERs so the new partial index can legally name the new column.

Nothing treats Ok(None) as a disproof — swept, not taken on description

Every non-test consumer of LineageAnswer::Absent in the crate:

  • cat_discovery.rs:354 — defers; the staged row SURVIVES.
  • singleton.rs:517 — marks attribution_examined and continues. Does not set asset_id,
    does not discard the row, does not refuse it.

sync.rs:2854 and sync.rs:2932 are inside #[cfg(test)] mod tests (opens sync.rs:1271).
fallback.rs:555/603/621 produce the value and do not consume it. The restraint holds in both
places it could have failed, not only the one it was described in.

chia-query claims verified against the published 0.19.0 source

  • CORROBORATION_FLOOR = 2chia-query-0.19.0/src/peer/plurality.rs:47. Confirmed.
  • "2 besides the answering one" is accuratepeer/mod.rs:80 ("independent peers besides the
    one that answered") and peer/corroboration_tests.rs:150 ("Three peers, not two: the answering
    peer cannot corroborate itself").
  • Only OptAnswer::CorroboratedAbsent becomes Ok(None)router.rs:238; UncorroboratedAbsent
    routes to settle_uncorroborated_absence. So fallback.rs:538-546's comment is accurate and one
    hostile peer cannot mint an absence.

Amplification — the guard is placed BEFORE the expensive step

This is where a new capability hung off a peer-reachable handler would have bitten, and it was
handled. handle_coin_state_update now returns FrameApplied; both refusal paths return
Dropped before any database write — the discovered-peer drop (sync.rs:895) and the refused-peak
drop (sync.rs:905) — and run_update_loop (sync.rs:1205-1211) runs the pass only on Applied.
So an empty, already-refused frame from an untrusted peer buys nothing. Covered by
a_refused_frame_schedules_no_attribution_pass, which carries an honest operator-session control.

Residual cost is proportional, not asymmetric: unexamined_attribution_candidates() narrows in SQL
behind a partial index, so a repeat frame costs one indexed query returning zero rows
(a_later_pass_pays_only_for_what_newly_arrived pins 20 rows then 21, not 41).

Mutation transcript — all three reproduced exactly

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 SyncSession exist — ScriptedSession (sync_supervisor/tests.rs:253) and
    ChiaPeerSession (sync_supervisor.rs:2388).
  • ChiaPeerSession is constructed at only two sites, sync_supervisor.rs:1992 and :2031, and
    BOTH require a live connect_peer / connect_random_peer_excluding returning a real Peer and
    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:2445 forwards a real attributor; built at :1407 from 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.rs builds it on enable_chain_sync, correctly
    not on enable_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_spend and SyncSession::run are 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_examined mark 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 on draft=true alone.
  • One chia line, from the resolved lock: Cargo.lock's diff is EXACTLY the two version bumps,
    and the resolved set of chia-* 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

  1. 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.
  2. Only the dig-wallet lib suite — 706 tests. I did not run the workspace suite, the
    tests/real_wallet_cat_discovery.rs integration target, clippy, or rustfmt myself; I relied on
    CI's five green required contexts for those.
  3. No coverage of ChiaPeerSession::run — structurally impossible here (see M1). The one
    production line this PR exists to fix is verified by reading only.
  4. chia-query read partially. I verified the constant, the "besides the answering one"
    semantics and the settle_peer_answer routing; I did not audit the whole peer-pool
    corroboration implementation, so N2's reasoning about PEAK_LAG_EVICTION rests on that
    constant's own documentation rather than on tracing every armed/eviction path.
  5. 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::Absent consumer sweep is the compensating measurement.
  6. 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>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/382-attributor-wiring branch from 5a7a26e to e05da67 Compare August 28, 2026 21:23
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 28, 2026 21:47
@MichaelTaylor3d
MichaelTaylor3d merged commit 154075d into main Aug 28, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/382-attributor-wiring branch August 28, 2026 21:47
Sign up for free to 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