DO NOT MERGE (superseded by #391 + #393): make a funded wallet report its real $DIG balance - #383
DO NOT MERGE (superseded by #391 + #393): make a funded wallet report its real $DIG balance#383MichaelTaylor3d wants to merge 1 commit into
Conversation
6676595 to
dcb3130
Compare
dcb3130 to
00e0bee
Compare
loop-security — IN PROGRESS — not the verdictHead audited: Three items resolved so far. Posting now so they survive. 1. CLEAR — the widening admits exactly the CAT-of-a-subscribed-p2-hash set, and ownership is PROVED not asserted
I could not construct a coin a hostile peer gets written that it could not before. To get a row 2. CLEAR — the placeholder guard sits at the single production ingress, not on one call siteThe brief's concern was a guard on one consumer while another consumes the same garbage. It is not Failure direction is correct: an unbindable coin yields 3. CLEAR — no CAT coin can be counted as XCH by the new admission path
Still working: amplification / cost-asymmetry on the new lineage-fetch path, the unrepairable-lineage |
loop-security — IN PROGRESS — not the verdict (2/2)Head: 4. FINDING — cost asymmetry: the widening turns a free drop into an unbounded outbound chain readThis is the one item I cannot clear. Brief item 5 asked about What changed in cost terms. Before this PR an unsubscribed coin state cost one No limiter covers it. Where the bounds are, and why they do not bound this.
Who can drive it. Not an operator-chosen peer only. Concrete scenario A — per-frame fetch storm. Attacker runs a chia full node, gets dialled by a Concrete scenario B — persistent, and it survives restart. The same peer pushes M coin states at a Scenario B's seeding half is pre-existing; what is new is that each seeded row now costs an outbound Assessment. LIVE, remotely reachable, and a genuine bytes-in/work-out asymmetry against both the 5. Secondary — a coinset error inside the repair branch ends the peer session
Merge preconditions (informational)
Still running: the placement-revert probe. |
loop-security VERDICT: CHANGES-REQUIREDHead audited: One GATING finding. The three defects the PR fixes are fixed correctly and the ownership proof is GATING - F1.
|
The CAT-attribution widening turned a free drop into an outbound chain read a remote peer chooses the volume of. Before it, an unsubscribed coin state cost one HashSet::contains and was discarded; after it, each one reaches admit_hinted and buys a live get_puzzle_and_solution, issued sequentially inside the apply loop. Nothing bounded that: the WalletBackend token bucket is not on this path, the supervisor builds its lineage source straight off the shared client, there was no cache, and handle_coin_state_update places no limit on an update's items at all. The catch-up budget does charge before the write, but its 250,000 was sized for a DB upsert rather than a network round trip. Two shapes of abuse, so two mechanisms. A flood frame of N coins was N reads, repeatable for the life of the session. And a peer that seeds odd-amount coins at a subscribed puzzle hash makes every later frame - including an empty one - drive reconstruct_all over the whole replica and re-fetch the same unresolvable parents, permanently and across restarts. BoundedLineage decorates the LineageSource, so the bound sits in front of the fetch rather than beside a caller. A token bucket meters distinct reads; a negative cache keyed on parent_coin_info means an unresolvable parent is asked about once. The burst is 256, taken from the 188 hinted CAT coins measured on a live node, so an honest catch-up is not throttled into the zero-balance bug this series fixes. The cache is capacity-bounded and TTL'd: its keys are peer-supplied, so an unbounded one would trade a network amplifier for a heap one, and a permanent one would turn a transient chain-source outage into a standing refusal to name the wallet's own money. A refusal answers Ok(None), never Err - an error escapes reconstruct_coins and ends the peer session. Attribution's fields are now private behind Attribution::new, which does the wrapping, so no construction site can assemble an attributor whose reads are unmetered. Also fixes the repair read added by the parent-spend binding check, which propagated Err while the spend read beside it deliberately maps to Ok(None). On the peer tier the repair branch is the common path, so a coinset blip killed the session - the asymmetry the supervisor's own post-catch-up pass argues against. And two operator-facing warnings carried a run of stray whitespace. Closes #383 review finding F1 (gating), F2 and F4. Co-Authored-By: Claude <noreply@anthropic.com>
F1 fixed (gating), plus F2 and F4 — head
|
| revert | red test(s) | observed |
|---|---|---|
budget removed (if !try_acquire() → if false) |
a_flooded_push_frame_cannot_drive_unbounded_lineage_reads |
400 fetches vs bound 8 |
| ″ | distinct_parents_are_capped_by_the_budget |
200 vs 8 |
negative cache remember() removed |
an_unresolvable_parent_is_asked_about_once |
50 vs 1 |
| cache eviction loop removed | the_negative_cache_stays_bounded_under_attacker_chosen_keys |
1000 vs 16 |
refusal → Err |
an_exhausted_budget_refuses_rather_than_erroring + distinct_parents_… |
red on two different assertions |
F2 Err(e) => return Err(e) |
a_failed_repair_read_is_no_lineage_rather_than_a_failed_session |
Err(… all sources failed); both siblings stayed green |
The 400 vs 8 figure is the one that answers the gate directly, and it is measured on the push
path — run_update_loop fed a single coin_state_update of 400 items — because that is the path
with no items.len() bound. Every coin carries a distinct parent_coin_info, so the negative
cache cannot produce the result and the assertion is genuinely about the budget; the same test runs a
generous-budget control first that observes all 400, proving the fixture really does present 400
chances to fetch rather than being narrow.
Blast radius checked
gitnexus, indexed per worktree (dn-382, 12,204 symbols):
Attribution— HIGH, 22 impacted, 3 direct:sync::attribute,service::WalletService::build_with, and the supervisor test. Warning as required. All three are in this diff; the private-field change makes any missed one a compile error rather than a silent bypass, and the full suite is green.admit_hinted— LOW, 1 direct.parent_spend— LOW, 1 direct.reconstruct_all— LOW, 2 direct.
detect_changes is MCP-only and not exposed by the CLI, so the diff-scope check was done by diff:
8 files, all expected. Note gitnexus analyze rewrites the AGENTS.md/CLAUDE.md banner blocks —
that drift was reverted and is not in either commit.
Evidence
cargo test -p dig-wallet --lib — 678 passed, 0 failed. cargo clippy --all-targets -D warnings
clean, cargo fmt clean. The four tests the gate verified still pass. Version stays 0.161.0
(already a minor bump over main's 0.160.0; a new public module keeps that correct).
Left draft, unmerged, as instructed.
loop-security round 2 — IN PROGRESS — not the verdict (1/n)Head audited: Merge preconditions asserted BY NAME via G1 (candidate GATING) — the negative cache does not cover the case that actually recurs, and the shared bucket turns that into the #382 money-lieThe deferral of
That premise is false for the rows that actually persist. if answer.is_none() {
self.lock_misses().remember(parent_coin_id, now);
}So the cache covers exactly one shape: a parent that does not resolve. It cannot cover a parent that
Each such row costs one token per push frame, forever. Why that is a money defect and not a performance note
Drain the bucket with leg 2 and leg 1 gets The observable is This also makes The trust gate does not cover the pass
Both then fall through to Worse for that path specifically: a Discovered attempt resolves The comment at Exploit (state -> action -> impact)State: a wallet with a few hundred unspent unattributed candidate rows whose parents resolve — an NFT Action: any discovered peer the node dials sends Impact: the bucket sits at ~0 against a 2/s refill. The next genuinely-hinted $DIG coin reaching I am building the probes for this now (a resolving-but-unattributable row re-charging a token per pass, |
loop-security round 2 — IN PROGRESS — not the verdict (2/n)Head: Probe 1 — the negative cache does not cover a resolving parentIdentical traffic shape to the existing 50 passes over one parent cost 50 reads. The premise quoted in the fix comment — "after the first Probe 2 — the shared budget starves the admission leg, under the PRODUCTION bound
The refusal is Probe 3 — end to end, through the real
|
loop-security round 2 — IN PROGRESS — not the verdict (3/n)Head: G1 completed — the exhausted budget produces
|
| constant | mutated to | meaning of the mutation |
|---|---|---|
LINEAGE_REFILL_PER_SEC |
2.0 -> 0.0 |
the bucket never refills: after 256 reads attribution is dead for the life of the process |
MAX_NEGATIVE_CACHE |
4_096 -> usize::MAX |
the unbounded peer-keyed map the module doc says it exists to prevent |
NEGATIVE_TTL |
300s -> 10 years |
a transient chain-source blip becomes a permanent refusal to name the wallet's money |
test result: ok. 678 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
Zero red. Every test in lineage_guard.rs passes its own explicit values through
BoundedLineage::with_budget, so the configuration that actually ships is asserted by nothing. The one
test that uses BoundedLineage::new — the_production_burst_clears_a_measured_catch_up — needs 188
tokens from a 256 burst and therefore never touches refill, cache capacity, or TTL.
Each mutation is a stated safety property of the module doc (lineage_guard.rs:37-40, :67-72,
:82-87). A guard whose safety properties are all unpinned is one edit away from being decoration, and
the 0.0 refill mutation is precisely how G1 becomes permanent rather than transient.
The revert table re-executed — honest
if !self.budget.try_acquire() -> if false, anchor-asserted for uniqueness:
a_flooded_push_frame_cannot_drive_unbounded_lineage_reads ... FAILED left: 400 right: 8
distinct_parents_are_capped_by_the_budget ... FAILED left: 200 right: 8
test result: FAILED. 676 passed; 2 failed
400-vs-8 confirmed. Both tests are genuinely load-bearing on the budget check.
One scope note on that flood test, since it is the headline evidence: its CountingLineage answers
Ok(None) to everything, so no row is ever written and the reconstruct_all leg it shares a budget
with runs over an empty table. It measures the admit_hinted leg only. That is why it stayed green
while probe 3 (comment 2/n) found 40 reads for 20 rows.
The "bypass is structural" claim — verified, and narrower than stated
Verified as written. Substituting a struct literal for Attribution::new in service.rs:
error[E0451]: fields `lineage` and `prefix` of struct `Attribution` are private
--> crates/dig-wallet/src/sage/service.rs:242:21
But the generalisation does not hold. CatAttributor (sync.rs:755-762) has pub lineage: &'a dyn LineageSource and is publicly constructible, and singleton::reconstruct_all takes a bare
&dyn LineageSource. There is already a second, unwrapped caller in the tree:
WalletBackend::refresh_tracked_coins (rpc.rs:3114-3117) calls reconstruct_all with self.lineage,
built at service.rs:347 as a raw ChiaQueryLineage that never meets BoundedLineage.
I am NOT gating on that. rpc.rs is untouched by this PR, the service.rs diff adds only the
attribution field and does not alter build_live_wallet, and its production caller is
tipping.rs:1085 — the local tipping engine, not a remote peer. It is pre-existing and not
peer-driven. It belongs in the follow-up ticket as the reason the privacy of Attribution's fields is
not the whole guarantee.
Cleared this round
- The widened admission still looks sound. I could not construct a coin a hostile peer gets written
that it could not before;admit_hintedproves the recoveredp2_puzzle_hashis a hash this wallet
owns before the write, androw.asset_idis filled at the same moment, so the
unspent_coins(None)-> counted-as-XCH direction stays shut. - A CAT counted as XCH, or the reverse, via
reconstruct_coins: the CAT branch can only fire for rows
at non-plain hashes, and under the new rules those enter only throughadmit_hinted.
Not the verdict. Writing it now.
loop-security VERDICT: CHANGES-REQUIREDHead audited: F1 is genuinely fixed. The bound is charged BEFORE the fetch ( One GATING finding. The fix closes the amplification by introducing a shared, drainable budget in GATING - G1. The
|
| probe | result |
|---|---|
| a resolving parent over 50 passes | 50 reads (the sibling an_unresolvable_parent_is_asked_about_once gets 1) |
| production bound, 256 resolving scan reads, then one honest read | honest read refused with Ok(None) |
real run_update_loop + real DB, 20 own odd-amount XCH rows, 2 frames, second frame empty |
40 reads - an empty frame re-read every row |
the PR's own the_supervisor_attributes_..._catch_up_syncs with the burst spent |
catch-up latched initial_sync_complete = true, then unspent_coins(Some(asset)) = [] |
That last one is the whole finding in one line: the failure is at tests.rs:4455, after :4451's
wait on initial_sync_complete returned. {"balance":0,"source":"db","synced":true} on a funded
wallet - #382's exact observable, produced by the read bound alone.
Exploit (state -> action -> impact)
State: a replica holding a few hundred unattributable-but-resolving rows. This needs no attacker -
an NFT collection or an active wallet's odd-amount XCH coins suffice.
Action: any peer sends coin_state_update frames in a loop. They may be empty and they may be
refused: run_update_loop:1151 calls attribute() unconditionally after every decoded frame,
including one dropped at sync.rs:888-894 for coming from a PeerTrust::Discovered peer and one
dropped at :900-903 for a refused peak. A discovered peer's entry bar, per the guard's own module
doc, is "run a chia full node and be honest about the height". Worse on that path specifically: a
Discovered attempt resolves puzzle_hashes = Vec::new() (sync_supervisor.rs:1375-1377), so
plain_puzzle_hashes is empty and is_candidate then admits every unspent unattributed row.
Impact: the bucket sits at ~0. The next genuinely-hinted $DIG coin is refused, and
apply_coin_states drops it without writing it - so nothing retries it; reconstruct_all can only
revisit rows that are in the table. The catch-up still latches authority (apply_coin_states returns
Ok(()) however many it dropped, and complete_catch_up fires on is_finished alone), so the wallet
answers a confident wrong number rather than unknown. The drop counts are already computed in the
aggregate warning at sync.rs:733-741 and are not consulted.
Why this gates rather than being logged
It is the money-lie class the end-to-end-first rule keeps as a stop: a surface lying about money.
It is remotely triggerable, it is durable (poisoned rows persist in the DB across sessions and
restarts, so a one-shot drive keeps starving later honest peers), and it re-opens the exact ticket the
PR closes. sync_supervisor.rs:1402-1404's "A DISCOVERED peer ... writes nothing" no longer holds
with the attribution pass sitting after the drop.
Direction, not a design (the lane owns the fix): give the scan leg its own budget so it can never
starve admission; and/or stop the scan re-charging at all by recording that a row was examined and is
not attributable, which also removes the per-frame cost the reconstruct_all deferral was about; and
do not latch initial_sync_complete while lineage_unavailable > 0 - an unknown balance is
survivable, a confident zero is not.
NOT GATING - defence-in-depth, for a follow-up ticket
D1. Every production constant in the guard is unpinned (vacuity). LINEAGE_REFILL_PER_SEC 2.0 ->
0.0, MAX_NEGATIVE_CACHE 4096 -> usize::MAX, NEGATIVE_TTL 300s -> 10 years, all three at
once: 678 passed, 0 failed. Every test passes explicit values through with_budget; the only test
using BoundedLineage::new needs 188 of a 256 burst and never reaches refill, capacity or TTL. Each
mutation destroys a safety property the module doc claims (:37-40, :67-72, :82-87), and the 0.0
refill mutation is exactly how G1 becomes permanent. Related: a_flooded_push_frame_...'s
CountingLineage answers None to everything, so no row is written and its reconstruct_all leg runs
over an empty table - it measures the admission leg only, which is why it stayed green while probe
3 found 40 reads for 20 rows.
D2. The bound is not as complete as the bypass claim implies. The Attribution privacy check is
real (E0451 verified), but CatAttributor (sync.rs:755-762) has pub lineage: &'a dyn LineageSource, reconstruct_all takes a bare &dyn LineageSource, and a second unwrapped caller
already exists: WalletBackend::refresh_tracked_coins (rpc.rs:3114-3117) over the raw
ChiaQueryLineage built at service.rs:347. Explicitly not gating - rpc.rs is untouched by this
PR, the service.rs diff adds only the attribution field, and its production caller is
tipping.rs:1085 (the local tipping engine, not a peer).
D3. The negative cache extends a transient outage into a money-lie window. fallback.rs's
Err(_) => Ok(None) on both the spend read and the repair read is now remembered for 300s. A blip
during a catch-up drops every hinted coin AND caches the refusal, so a retry inside the window is
refused without asking. Self-healing after 300s and shorter than the session lifetime, so the shape is
right - but the 300s is pinned by nothing (D1), and the initial_sync_complete latch makes the window
read as a confident zero rather than unknown.
D4. Two comments are now false where it matters. lineage_guard.rs:161-163 says a refused coin is
"left unattributed and retried on a later pass" - true for the scan leg, false for admit_hinted,
where the coin is never written. sync_supervisor.rs:1402-1404 says a Discovered peer "writes
nothing".
D5. One token buys two outbound RPCs on the peer tier. fallback.rs's repair branch issues a
second read (coin_record_by_id), and its own comment says the placeholder answer is "the common
path" there. The meter counts parent_spend calls, so the real outbound rate is up to 2x the bound.
Cleared
- The widened admission. I could not construct a coin a hostile peer gets written that it could not
before.admit_hintedproves the recoveredp2_puzzle_hashis a hash this wallet owns before the
write and fillsrow.asset_idat the same moment, so theunspent_coins(None)-> counted-as-XCH
direction stays shut. The child-id match atsingleton.rs:145is over the coin id
SHA256(parent, puzzle_hash, amount), none of whose inputs the peer can vary. - CAT-counted-as-XCH and the reverse.
reconstruct_coins' CAT branch can only fire for rows at
non-plain hashes, and those now enter only throughadmit_hinted. TokenBucket- monotonicInstant, poison recovery, clamped capacity, and no lock held across
the.await.- Charged before the fetch -
try_acquire()at:158precedes the.awaitat:166; the revert
measures it. - Dependencies - version bumps plus a transitive
socket2 0.5.10 -> 0.6.5consolidation. Nothing
added, no pin loosened. - Authorship - all three commits
Michael Taylor <michael@michaeltaylor.dev>. - Merge preconditions - five required contexts asserted BY NAME, all
SUCCESS; zero unresolved
threads; BLOCKED ondraft=truealone. Closes #382(attribution never ran) andCloses #380(CAT coins dropped at ingestion) are
both genuinely addressed on the honest path, and dig-node drops every CAT coin at ingestion, so $DIG balance reads a confident zero on a funded wallet #380's "without widening the guard into accepting
arbitrary puzzle hashes" is respected - admission is by proof. G1 does not undo the fixes; it
reintroduces the same observable under a reachable condition.
Coverage I did NOT achieve - stated so it is not mistaken for cleared
- No mainnet run this round. The 3,856,455-mojo figure is the lane's round-1 evidence; I did not
re-verify it. fallback.rs's 204 added lines were read only atparent_spendand the repair branch.sync_supervisor/tests.rs's 261 added lines were not swept for vacuity beyond the mutations above.- SPEC.md 18.11a/b/c were checked for existence, not audited as prose.
- No concurrency testing of the shared bucket (the supervisor runs one session at a time).
- No
cargo audit/ supply-chain scan; I judged the dependency delta by diff only.
Probes ran in C:/tmp/worktrees/dn-383-amp (my own worktree, detached at head), removed after the
audit. The lane's dn-382 checkout was never touched; every mutation was applied with an
assert old in s anchor plus a uniqueness assert and reverted via git checkout -- with
git status --porcelain confirmed empty afterwards.
G1 fixed — the shape, not the constants. Head
|
| round 2 head | this head | |
|---|---|---|
| result | latched initial_sync_complete = true, then [] |
does not latch; catch-up retried |
It races the two outcomes rather than waiting for the retry, so it is reached under both
behaviours. Reverting only the latch guard: red in 0.07s, at the assertion, with
"a catch-up that could not read every coin it was offered must not latch the replica as
authoritative" — not a timeout that reads as a wedged supervisor.
Revert proofs — committed first, restored from a file copy, every anchor asserted
| revert | red test(s) | observed |
|---|---|---|
| the outcome mark is never written | a_resolving_but_unattributable_row_is_read_once… |
10 reads vs 1 |
| ″ | a_later_pass_pays_only_for_what_newly_arrived |
41 vs 21 |
| an unreadable parent is marked too | an_unreadable_parent_is_retried_rather_than_written_off |
1 vs 10 |
| one bucket serves both legs | the_background_pass_cannot_starve_the_admission_leg + the killer probe |
both red |
| the incomplete-batch check removed | the killer probe | red at the assertion, 0.07s |
| the pass runs after a dropped frame | a_refused_frame_schedules_no_attribution_pass |
1 read vs 0 |
guard caches Unavailable; refusal reported as Absent |
6 tests | incl. 1 vs 50 re-asks |
LINEAGE_REFILL_PER_SEC → 0.0 |
the_shipping_constants_are_pinned_in_both_directions |
red |
MAX_NEGATIVE_CACHE → usize::MAX |
″ | red |
NEGATIVE_TTL → 10 years |
″ | red |
D1 answered. Your three-at-once mutation gave 678 passed, 0 failed. Each now dies
individually. The constants are read through black_box so the comparisons stay runtime assertions
(and so clippy::assertions_on_constants does not reject the thing the test exists to do), and
the_production_burst_is_exact_at_its_boundary pins the burst from both sides — the 256th read
served, the 257th metered — where the old test only showed that 188 fit.
The flood test now also asserts the session outcome, not just the read count: its generous-budget
control survives (every coin was read and refused on its merits) while the bounded run returns
IncompleteBatch. It measures the admission leg only, as you noted — the scan leg is covered by the
four new singleton tests over a real DB.
D4 / D5
D4: both false comments fixed — lineage_guard's refusal warning no longer claims the coin is
"retried on a later pass" (it says nothing is known, so it is neither written nor written off), and
the supervisor's "writes nothing" now states why the pass gating is what makes it true. A third
whitespace-run corruption found and fixed in sync_supervisor.rs's corroboration-probe log.
D5 is documented rather than fixed: the guard's module doc now states that the meter counts
parent_spend calls and the peer tier's repair read makes the true outbound rate up to 2x the
token rate, and the burst is sized knowing that. A meter that counted round trips would have to
reach through the source's internals.
D2 noted, not expanded to. WalletBackend::refresh_tracked_coins (rpc.rs:3114-3117) still
calls reconstruct_all over the raw ChiaQueryLineage. It is pre-existing and not peer-driven, and
it does now benefit from the durable mark, but it remains unmetered.
Blast radius checked
LineageSource::parent_spend — signature changed, so every impl and caller is a compile error
rather than a silent adaptation. 11 impls: 2 production (ChiaQueryLineage, BoundedLineage),
9 test-only. 4 production call sites: admit_hinted, reconstruct_coins, rpc.rs:3329
(resolve_cat), rpc.rs:3469. Risk: MEDIUM-HIGH — custody read path, and it changes both what
enters the replica and when the replica claims to be authoritative.
apply_coin_states → AdmissionOutcome and handle_coin_state_update → FrameApplied: both
return types changed, so no caller can ignore the new information by omission. 2 production callers
each.
New DB column coins.attribution_examined, additive via ADD_COLUMN_MIGRATIONS + a partial index.
NULL on every pre-existing row, which reads correctly as "not examined": each is re-examined once
and then settles. upsert_coins' DO UPDATE does not clear it (same coin id ⇒ same parent ⇒ same
answer), and rollback_above deletes the row and the mark together.
gitnexus detect_changes is MCP-only and not exposed by the CLI, so the diff-scope check was by
diff: 9 files, all expected, nothing outside dig-wallet plus SPEC.md.
Evidence
cargo test -p dig-wallet --lib — 690 passed, 0 failed (678 at the round-2 head; +12).
cargo clippy --workspace --all-targets -- -D warnings clean, cargo fmt clean. The four tests the
round-1 gate verified still pass, including the_supervisor_attributes_the_hinted_cat_coins…
unchanged.
Versions unchanged: dig-node 0.161.0, dig-wallet 0.40.0 — both already minor bumps over main,
which is the correct band for a 0.x breaking change, and this delta ships inside the same
unreleased version.
Worktree C:\tmp\worktrees\dn-383-fix, cut detached at 7f860e0a. The lane's dn-382 checkout
was never touched; every revert was applied with an assert old in s anchor and restored from a
file copy, never git checkout.
Left draft, unmerged, as instructed.
|
loop-security round 3 — Head audited: Area 1 — permanent censorship via a false
|
|
loop-security round 3 — FINDING F1 (candidate GATING) — the second instance of the conflation, and it is a remote session-killThis is the "look for a second instance of that same conflation" item, and I believe it is there. Reasoned from code below; probe to follow in the next comment. The property the diff claims.
Why the production path cannot honour it. The only production
So for the specific case the Consequence: Exploit.
Why this is the same shape as the bug the lane already fixed. The lane added Second, independent route to the same state — budget exhaustion.
What the lane's own test does and does not establish. Severity. The failure direction is honest — the wallet reports unsynced and falls through to the chain tier, so this is not a money-lie. It is an availability defect on a money surface, remotely triggerable at ~82 bytes by a peer inside the PR's own stated threat model, and it also has a no-attacker scaling cliff. I am treating it as GATING pending the probe below; if the probe contradicts me I will say so plainly in the verdict. Probe next, then the remaining areas. |
|
loop-security round 3 — F1 is PROBED, not just reasoned. All three legs confirmed.Standalone integration test in my own worktree only ( The calibration went red for the right reason, so the three greens are load-bearing. Leg 1 — one fabricated coin ends the session. A single
Leg 2 — the control, which is what makes leg 1 a defect rather than a design. The same coin, same code path, with the source answering So the rule is right and only the mapping is wrong. Leg 3 — the budget route, with no attacker and no failure anywhere. 300 distinct coins in ONE batch (the catch-up permits Every one of those 44 is the node refusing itself. No unreachable chain, no hostile answer, nothing transient — and it is The two parts with no attacker in them, restated for the verdict.
Verdict on F1: GATING. Failure direction is honest — the wallet reports unsynced and falls through to the chain tier rather than lying — so it is not a money-lie. It is a remotely-triggerable, indefinitely-repeatable denial of the local replica for ~82 bytes, plus a no-attacker scaling cliff, plus a security property asserted in the diff that the diff does not have. Next: the censorship question (can a row be marked |
|
loop-security round 3 — The censorship question — ANSWERED, and the guard is realCan a row be marked Revert applied in my own worktree — 1 vs 10, exactly the figure the lane reported. The other seven tests in the module stayed green, so the guard is specific to the censorship case rather than a blanket tripwire. File restored; Everything else on this axis holds too: the mark is written only from the The DB migration — clean, and it fails in the right direction
FINDING F2 (non-gating, but it is why F1 survived) — every test double inverts production's miss mappingThis is the "fixture whose incidental properties prevent the interesting path" sibling. The lane found one instance; the systemic version is that no double in the suite models production's answer for an unreadable parent.
The consequence is structural: Not gating on its own — it is a test-fidelity defect, not a runtime one — but it should be fixed with F1, because a fix for F1 verified against these doubles would be verified against the wrong semantics. Next and last: |
loop-security VERDICT: CHANGES-REQUIREDHead audited: Round 3 did not create a fourth defect of the same kind — the reshape is sound and the censorship case I most feared does not land. It created a different one: the honest-failure signal it introduced is reachable by a remote peer for ~82 bytes, and by an honest wallet with no attacker at all. F1 — GATING. A peer can permanently deny the wallet its replica, and an honest wallet can deny itself
This is the second instance of the transient-vs-standing conflation the lane fixed once with Exploit. State: the supervisor holds an authoritative session — a bar this PR itself describes as "run a chia full node and be honest about the height" ( Probed (
The control is what makes this a defect rather than a design: the rule is right, only the mapping is wrong. The budget route, with no attacker anywhere. SPEC 18.11c is not true of the code in this same diff. Three clauses fail:
The same claim at Severity: availability, not a money-lie. The failure direction is honest — unsynced, reads fall through to the chain tier. That is the right direction and the lane deserves credit for it. But an honest permanent refusal to sync is still a denial of the local replica on a money surface, remotely triggerable and cheap. Gating. Shape of a fix (yours to choose): distinguish the two at the source — use the absence-aware F2 — non-gating, but fix it WITH F1: every test double inverts production's miss mapping
Production answers an unreadable parent F3 — non-gating, follow-up ticket
Areas checked and CLEAR
My coverage, honestlyI re-executed one of the lane's ten reverts — the censorship one, which is the one that mattered most — and took the other nine on report. I did not re-run the full Do not merge. F1 is gating; F2 should land with it so the fix is verified against production semantics. F3 is a follow-up ticket. |
|
round 4 lane — IN PROGRESS (from head Taking F1 (gating) + F2, and the SPEC §18.11c claims. Worktree Measured first: the
|
…enying an honest catch-up A peer could permanently prevent the wallet from ever syncing, and an honest wallet could do it to itself. Two distinct causes. 1. The production lineage source read spends through the non-`_opt` `get_puzzle_and_solution`, so a parent that does not exist arrived as an `Err` indistinguishable from an outage and was mapped to `Unavailable`. `NotAdmitted::ParentAbsent` was therefore unreachable in production for the exact case its own doc named, and one ~82-byte fabricated `CoinState` made the batch incomplete and ended the session, repeatably. It now reads through `chia-query`'s absence-aware `get_coin_spend_opt`, whose `Ok(None)` is a corroborated absence. 2. The read budget is supervisor-lifetime and `admit_hinted` consults no DB state, so every catch-up re-paid a token per hinted CAT coin forever. The bucket now credits a token back when the read resolves a real spend, so the meter only counts reads that produced nothing -- which is what a fabricating peer generates and what an honest catch-up does not. A budget refusal is also no longer reported as an outage: `LineageAnswer` gains `Deferred` and `NotAdmitted` gains `ReadBudgetExhausted`, counted as `AdmissionOutcome::deferred`. The catch-up latch requires the stronger `fully_judged`; the push path keeps the session but drops the authoritative latch, so reads fall back to the chain tier rather than answering a confident balance. Refs #383 Co-Authored-By: Claude <noreply@anthropic.com>
…ery lineage double with production F2 was the reason F1 survived a round hunting exactly that class: every LineageSource double folded a miss to `Absent` via `from_answered`, while production reported `Unavailable`. `FixtureLineage` drives the whole supervisor suite, so the suite was structurally unable to reach `IncompleteBatch` by the ordinary route. `from_answered` had zero production callers and its only effect was to make every double model the wrong semantics; it is replaced by `from_lookup`, which makes the caller state the miss answer. Two new tests, both over the PRODUCTION guard and its shipping constants: a fabricated parent must not make the batch incomplete, and 300 coins over an always-answering source must leave nothing unknown. Refs #383 Co-Authored-By: Claude <noreply@anthropic.com>
…an outage control Refs #383 Co-Authored-By: Claude <noreply@anthropic.com>
…t, and pin the refund The four tests that asserted the old contract are updated rather than relaxed: a budget refusal is now `Deferred` at all three guard sites, and the flooded push frame must KEEP its session while DROPPING the authoritative latch -- with an honest control that stays authoritative, so the latch assertion cannot be met by a node that drops the latch on every frame. Adds `a_resolving_read_is_refunded_and_a_fruitless_one_is_not`: four times the production burst of resolving reads must all be served, while the same count of fruitless ones stops at exactly the burst. Refs #383 Co-Authored-By: Claude <noreply@anthropic.com>
…-query absence claim Three 18.11c clauses did not hold. The honest-burst clause is now satisfied by the refund and says so, and states why a fixed per-read charge cannot satisfy it. The absent-vs-unavailable clause is now three-way and, more importantly, requires the distinction to be carried by the chain READ -- an enum cannot distinguish what the API it reads through has already collapsed. The incomplete-batch clause now says what a budget refusal does instead, and notes that its own 'parent absent does not make a batch incomplete' sentence is only true because the source can produce an absence at all. F3: the `coin_record_by_id` doc cited `chia-query` 0.6 and denied the property the mapping beside it depends on. The graph resolves 0.19.0, where the absence is corroborated; dig_ecosystem#2456 has landed. Refs #383 Co-Authored-By: Claude <noreply@anthropic.com>
…suite could not do Refs #383 Co-Authored-By: Claude <noreply@anthropic.com>
round 4 — F1 (gating), F2 and F3 addressed. Head
|
| path | on deferred > 0 |
|---|---|
| catch-up | requires the stronger fully_judged() → does not latch; the session ends and a fresh catch-up re-offers with a replenished budget |
| push | keeps is_complete() → session survives, but initial_sync_complete is cleared, so wallet reads fall through to the chain tier |
The push behaviour is the part worth reading closely. Ending the session there is what hands a peer an endless reconnect loop; absorbing the frame silently is the money lie. It does neither: it keeps the session and drops the latch, which is the same honest fallback a backwards move already takes.
F2 — the doubles, and the reason F1 survived a round hunting exactly this
LineageAnswer::from_answered had zero production callers. Its entire effect was that every test double in the crate modelled an unresolvable parent as a settled Absent while production modelled it as Unavailable. It is deleted, replaced by from_lookup(spend, on_miss), which makes the caller state the miss answer. FixtureLineage gains an explicit on_miss, defaulting to Unavailable; MockLineage and both OneParent doubles answer Unavailable on a miss.
And the suite now reaches IncompleteBatch by the ordinary route:
a_parent_this_node_could_not_read_leaves_the_replica_unauthoritative — a coin whose parent the fixture cannot answer about, with both bursts generous so the budget refuses nothing, plus a control run where the parent IS known and the replica must latch. Without the control, "does not latch" would be satisfied by a supervisor that never latches.
Reverting FixtureLineage's on_miss to Absent makes it RED — under the old semantics the replica latches over an unreadable parent, and the whole suite is blind to it. That is F2 demonstrated rather than asserted.
F3 — fixed inline rather than filed
fallback.rs's coin_record_by_id doc cited chia-query 0.6 and denied the property the mapping beside it now depends on. Corrected to state 0.19.0's actual guarantee (read_opt_corroborated, CORROBORATION_FLOOR, SourcesDisagree → Err) and to record that dig_ecosystem#2456 has landed. It was cheaper to fix with the file open than to file it (§1.3c rule 3), and leaving it would have left a comment telling the next reader the new mapping is unsound.
SPEC §18.11c — three clauses made TRUE, not narrowed
- the honest burst — now satisfied by the refund, and the clause now states why a fixed per-read charge cannot satisfy it, with the measured 300/44 figure, so a future simplification back to a flat charge is refused by the spec rather than only by a test.
- absent vs unavailable — now three-way, and it now requires the distinction to be carried by the chain read, not merely by the enum. That is the actual gap: an enum cannot distinguish what the API beneath it has already collapsed, and the ABSENT arm was unreachable while the mapping read correct.
- the incomplete batch — now says what a budget refusal does instead, and states plainly that its own "parent absent does not make a batch incomplete" sentence is only true because the source can produce an absence at all.
Tests, each proven load-bearing by reverting ONLY its own fix (committed first; reverts by script, never git checkout on live work)
| revert | test | result |
|---|---|---|
| remove the refund | an_honest_catch_ups_burst_is_admitted_whole_over_an_answering_source |
RED — deferred: 44 |
budget refusal → Unavailable |
same | RED — unknown: 44 (the gate's own figure) |
a fixture absence → Unavailable |
a_parent_the_chain_says_does_not_exist_does_not_make_the_batch_incomplete |
RED — unknown: 1 (the 82-byte exploit) |
production Ok(None) → Unavailable |
a_parent_the_chain_reports_no_spend_for_is_absent_not_unavailable |
RED |
| supervisor threads no attributor | the_supervisor_attributes_the_hinted_cat_coins_its_catch_up_syncs |
RED — found [] |
FixtureLineage miss → Absent |
a_parent_this_node_could_not_read_leaves_the_replica_unauthoritative |
RED |
The three probe figures the gate measured — 44, 44, 1 — are reproduced exactly as failure modes.
Fixture design, stated rather than assumed. 300 is taken from the protocol's own limit: decisively over LINEAGE_BURST = 256, while remaining a plausible holding for a wallet whose measured catch-up carried 188. The acceptance test runs two sources, because the two mechanisms fail in different directions — a Found source pins the refund (asserting only unknown there would be satisfied by the deferral change alone, leaving the refund deletable with the suite green), and an Absent source pins the deferral and the bound from both sides in one run: exactly 256 admitted at the bound, exactly 44 declined over it. a_resolving_read_is_refunded_and_a_fruitless_one_is_not runs four times the production burst with a non-resolving control, because "no refusals" is otherwise satisfied by having no bound at all.
The gate's honest coverage note — what I re-proved rather than inherited
The gate re-executed one of ten reverts and took nine on report, and asked that anything I touch be re-proved. I changed FixtureLineage, which drives the supervisor suite, so the supervisor revert was in scope: I re-executed it and reproduced found [] under the corrected double. I did not re-execute the ingest-guard-placement or binding-check reverts; those tests are green at this head and my diff does not alter the guard's placement or the binding check, but I am stating that rather than claiming it.
Blast radius checked
gitnexus analyze was not run for this worktree — §2.0's fallback taken, by exhaustive grep, and the radius is closed:
LineageSource::parent_spend— 1 production impl (ChiaQueryLineage), 1 decorator (BoundedLineage), 8 test doubles; production call sitessync.rs:903,singleton.rs:455,rpc.rs:3329/:3470. The new enum variant makes everymatcha compile error until handled, so no consumer can inherit a silent default —rpc.rs's two.found()sites discard the distinction into anErr, which is fail-closed and the documented use of that helper.AdmissionOutcome/SyncError::IncompleteBatch— both consumed at exactly two call sites each, both updated.TokenBucket(dig-wallet) — 2 consumers (lineage_guard.rs:120,rpc.rs:618);refundis additive and only the guard calls it.dig-node-core/src/tier0_prefetch.rs:138declares an unrelated type of the same name; no edge (and a rival implementation worth a note, not this PR's job).- Nothing outside
dig-walletchanged. Risk: MEDIUM-HIGH — custody read path; it changes what enters the replica and when the replica calls itself authoritative.
Version
Unchanged from round 3: dig-node 0.161.0, dig-wallet 0.40.0. from_answered was removed, which is breaking — under 0.x that is a minor, and 0.39.0 → 0.40.0 already carries it. Both files still increase over main.
Realizations (for auto-ticketing, not filed here)
dig-node-coreanddig-walleteach declare their ownTokenBucket— a rival implementation under a shared name. Neither is wrong today; they will drift.LINEAGE_REFILL_PER_SEC = 2.0is now doing much less work than its doc claims, since resolved reads no longer draw on it. The constant and its justification should be re-derived against the refund rather than left describing the old model.- The
admit_hintedpath still consults no DB state. The refund removes the harm, but a coin already attributed is still re-read from chain on every catch-up. That is bandwidth, not correctness, and it is the cheapest remaining win here.
|
IN PROGRESS — not the verdict. Round-4 security gate, head S1 (GATING candidate) — the refund's legitimacy test is "did the read resolve", and the attacker picks that side for free
The rationale at
That is the guard-specificity failure class: the guard's rationale names one specific attacker behaviour, and the one-off variant walks past it. A peer that wants to spend the budget does not have to fabricate. It can name parents the chain DOES have. The exploitState: an NC-12 adversary that has reached
The same real parent id may be repeated in every item:
This diff also raises the per-read cost about 12x on that same path
Production builds the fabric from Bytes in per coin: about 82 on the wire. Work out: about 12 requests to third-party mainnet full nodes, issued sequentially inside the apply loop, for the life of a 600 s session and again on every reconnect. That is both a reflected amplifier against the Chia peer network and a self-denial — the wallet sync loop does nothing else while it runs. Why this is not merely defence-in-depth
A narrow fix shape, for whoever takes it: refund only where the read is attributable to work the wallet asked for — e.g. only once Still open in this round: the |
SHAPE DECISION — admission does NOT need lineage. Neither does it need the unattributed row.Deciding, not consulting. Head audited VerdictTake the cheap-and-local shape — but NOT the one proposed in the brief. The proposed shape
A coin at Why the brief's shape is wrong on two counts(a) "admit a hinted CAT coin whose hint is in the subscribed set" is not a local check — the hint pub struct CoinState { coin: Coin, spent_height: Option<u32>, created_height: Option<u32> }Three fields. No hint. (b) "unattributed contributes to no balance" is FALSE in this schema.
So every unattributed row is offered to the XCH coin selector as a spendable input. The wallet cannot The PR is right about this and says so in The narrow escape worth recording: The four tests, answered with evidence1. Does it still fix #382/#380? Yes, and more directly than the current shape. The PR's own One implementation detail that decides whether the balance reads correctly: the CAT balance query 2. Can a hostile peer write coins the wallet does not own? No — the filter gets STRONGER. The one hazard to hold: keep the derived CAT hashes OUT of 3. Latency cost: none. This is the decisive advantage over the brief's shape. Attribution does not 4. Does anything genuinely need lineage at admission? Yes, for exactly one case — and it is not It does not justify keeping it, for three reasons. Main does not support unknown-CAT discovery What gets DELETEDAll of it is surface
This is roughly the majority of the 3,161 added lines. That is the right outcome, not a loss: four What gets KEPT, on its own merits
Failure mode of the recommendation, stated explicitlyIncompleteness, never a confident wrong figure. If a CAT coin sits at a hash the wallet did not S3 and
|
…eports its real $DIG balance A CAT coin sits at the OUTER puzzle hash that curries the asset's TAIL around its owner's p2 hash, never at the p2 hash itself, so the subscription filter dropped every hinted $DIG coin and attribution had no row to fill in. A funded wallet reported a balance of zero. Because that curry commits to the asset AND the owner together, the wallet can derive `cat_puzzle_hash(owner_p2, asset_id)` itself and SUBSCRIBE it. A coin arriving at one of those hashes is that asset, and is this wallet's, by construction: the hash it matched is the proof. `apply_coin_states` therefore returns to main's shape -- filter plus upsert, zero chain reads -- and the coin arrives already carrying its `asset_id` and its owner `hint`. The `hint` is not decoration. The CAT balance query scopes by `hint` rather than by puzzle hash, so a row admitted without one is stored, correctly typed, and still reads as zero. This deletes the lineage-at-admission machinery entirely rather than bounding it: `admit_hinted`, `NotAdmitted`, `AdmissionOutcome`, `SyncError::IncompleteBatch` and its session-kill, and the `BoundedLineage` read guard with its token bucket and refund. Outbound requests per admitted coin go from about twelve to zero, and the latency cost is none, because attribution is now a hash comparison on the frame path. No path can create an `asset_id IS NULL` row from a peer frame, so the XCH-miscount direction is closed structurally rather than by a guard. Kept on merit: the supervisor threading the `CatAttributor` (the original bug -- the only production call site passed `None`, so the pass could never run), `get_coin_spend_opt`'s corroborated absence, the coin-id binding check and placeholder repair, `from_lookup`, and the persisted `attribution_examined` mark. Those serve the out-of-band pass, which still attributes NFT and DID singletons and any CAT row already in the replica. Out of scope, deliberately: CATs whose asset id the wallet does not know in advance. Their outer hash cannot be derived, so they read as ABSENT rather than as a wrong number. That is the failure direction this wallet must have, and unknown-CAT discovery belongs out of band. SPEC 18.11a is rewritten around the derived-hash subscription and the set-separation rule; 18.11c is reduced to the out-of-band pass's outcome memory and its absent-versus-unavailable contract. Both are true of the code in this diff. Closes #382 Closes #380 Co-Authored-By: Claude <noreply@anthropic.com>
54b0ad5 to
8f4e1a6
Compare
Round 5 — reshaped to the decided shape (derived CAT hash subscription)Head Implements the decision in #issuecomment-5448203160. Mostly deletion: 3,161 → 1,865 insertions. Shape
Deleted
S1, S2, S3 and round 3's finding are gone structurally — the mechanism they lived in no longer Measurements (observed values, obtained by calibrating each assertion to a wrong constant)
Previous shape issued roughly one read per admitted coin and left 44 of 300 unread at the shipping Set separation — confirmed, and now revert-proofedNo derived CAT hash reaches any of the neighbouring sets:
Revert-proofs (committed first; reverted by file copy, never
|
loop-security round 5 — IN PROGRESS, not the verdictHead audited: Item A — merge preconditions, asserted by name. CLEAN except the intended draft block. All five required contexts are PRESENT and SUCCESS — none absent, none Item B — commit hygiene.
Substantive items (derivation soundness, the five-set separation re-execution, |
loop-security round 5 — IN PROGRESS, not the verdictHead audited: FINDING 1 — GATING, HIGH. A coin at a derived CAT hash is admitted and counted WITHOUT any proof it is that asset. Anyone can inflate the $DIG balance and permanently brick $DIG sends for ~2 cents.The reshape's whole load-bearing claim is that the outer hash proves asset AND owner. Half of that The cryptography, checked rather than read
Under sha256 collision resistance and the injectivity of the curry encoding, that commits to exactly
The currying cannot be satisfied another way. Attack vector 1 in my brief is clean, and I could What that does NOT establish, and what the code does with the differenceA coin EXISTING at a puzzle hash is not a claim anyone had to earn. On Chia any spend may
.filter(|s| subscribed.contains(&s.coin.puzzle_hash))
.map(|s| {
let mut row = coin_state_to_row(s);
if let Some(cat) = derived_cats.identify(&s.coin.puzzle_hash) {
row.asset_id = Some(hex::encode(cat.asset_id));
row.hint = Some(hex::encode(cat.owner_p2));
}
row
})
Exploit — no peer compromise, no malicious node, public information onlyState: victim's node syncs $DIG normally. Attacker knows the victim's XCH address (public — it is how
$DIG precision is 3 ( It is worse than a display lie: it is a permanent remote kill-switch on $DIG sends
rows.sort_by(|a, b| b.amount.parse::<u64>()... .cmp(&a.amount.parse::<u64>()...))
let cat = singleton::resolve_cat(&parent, child)?
.ok_or_else(|| Error::internal("could not resolve CAT lineage"))?;
So an attacker who plants one coin larger than the victim's largest real $DIG coin makes that coin Executed, not arguedTwo probes added to Wallet holds 1,000 base units of real $DIG. Attacker adds one coin of 9,000,000 at the derived Why this is round 5 repeating the patternRound 4's SPEC §18.11a is born-false in this same diff
Remedy shape (not prescribing an implementation)The zero-read-on-the-frame-path property is worth keeping and does not have to be traded away. The More items to follow (set separation re-execution, |
loop-security round 5 — IN PROGRESS, not the verdictHead audited: Item 2 — SET SEPARATION: CONFIRMED, and the new test genuinely catches the leak.Re-executed the leak mutation. Exactly one test fails, with a message that names the mechanism. The mutation that previously passed All five sets checked in both directions, plus a sixth and a seventh I went looking for. Traced
Reverse direction: a p2 hash can only become a FINDING 2 — LOW, defence-in-depth, do NOT gate. A seventh set is now contaminated, but it is dead code.
It has ZERO callers repo-wide (grepped FINDING 3 — LOW, non-gating. A doc claim in this diff's blast radius is now false.
Item 3 —
|
loop-security round 5 — IN PROGRESS, not the verdict (final item batch)Head audited: FINDING 5 — GATING, part of Finding 1. The fake coin is ANNOUNCED, not merely counted.An eighth puzzle-hash set exists that the diff's own enumeration (
let watched: Vec<String> = session.subscribed.iter().map(hex::encode).collect();
if let Err(e) = db.record_arrivals(&watched, update.height).await {
The live consequence is in match &coin.asset_id {
Some(asset_id) => Verdict::Arrival(Some(asset_id.clone())),An attributed CAT is an Arrival unconditionally — the Separately and latently: because the derived hashes are now in The scope question — VERDICT: the boundary is acceptable and documented. One claim contradicts it.Acceptable. Absent-not-wrong is the right direction, it is a strict improvement on the pre-state Documented, in three places — SPEC §18.11a's failure-direction paragraph, FINDING 6 — LOW, non-gating. Something DOES claim otherwise, and it is normative.
After this diff §18.11c — checked clause by clause against the code. Mostly TRUE, one clause is not.
FINDING 7 — LOW, defence-in-depth, do NOT gate. One error path still escapes and kills the session.
Low severity only because reachability is poor: the peer tier returns typed FINDING 8 — INFORMATIONAL.
|
loop-security VERDICT: CHANGES-REQUIREDHead audited: Worked in my own detached worktree The one gating findingG1 (HIGH) — Full write-up with the executed probes: comments Short form. The derivation is cryptographically sound — I checked What it does not prove is that the coin is a unit of that asset. On Chia any spend may Attacker cost is 1 mojo per displayed base unit; $DIG precision is 3, so a fabricated 1,000,000 $DIG Three consequences, all executed or traced:
I am gating on this because it is the narrow stop that survives even the end-to-end-first override: a This does not require abandoning the shape. Zero-reads-on-the-frame-path is worth keeping, and the Non-gating — file as follow-ups, do NOT hold the merge on theseF4 (MEDIUM) F6 (LOW) F7 (LOW) F5b (LOW) F2 (LOW) F3 (LOW) F8 (INFO) What I checked and found CLEAN
Coverage I did NOT achieve — judge this verdict accordinglyI ran Do not merge on this verdict. One gating finding. |
|
Orchestrator — correcting a measurement I relayed as verified. I reported round 5's headline as "0 outbound chain reads, 300 admitted, stranger refused" and treated all three figures as observed. The gate found the first one is vacuous (its F4): So "0 reads" was measuring nothing. The other two figures — Worth stating why this one slipped past a lane that was otherwise rigorous. That lane calibrated each assertion against That is the same class as the harness findings from today: the mutation harness that could not read its own output certifying a suite, and the harness that under-reverted and would have certified the next run against a dirty tree. Three instruments, three ways of being confidently wrong about nothing. The general check: a measured zero should be made to go non-zero on purpose before it is believed. If you cannot make the counter move, it is not attached. The shape question is now with a decider (see the next comment) rather than another fix round — five rounds each fixing one defect and creating the next is a shape problem, not a parameter problem. |
SHAPE DECISION — round 6. Discovery is not authenticity. Separate them with a TABLE, not a flag; and SPLIT this PR.Deciding, not consulting. I wrote the round-5 decision ( 1. What the gate proved, and the premise I got wrongRound 5's decision said a coin at A coin existing at a puzzle hash is a claim nobody had to earn. Any spend may The one observation that should drive everything after this: round 4's 2. The gate's suggested shape, evaluated first — it does NOT work as statedThe suggestion is that The out-of-band pass only sees rows with 3. Does a third state exist? Yes — but NOT as a column on
|
…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>
|
DO NOT MERGE — superseded, kept only as the reference for the six-round history. This PR was split, because it did not work as one change:
Acceptance measured on a real wallet at PR-B's head: Merging this branch would land the pre-split shape over both. It stays draft and will be closed once |
…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>
…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>
…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>
…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>
…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>
…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>
…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>
…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>
… name its $DIG (#391) fix(wallet): run CAT attribution in production so a funded wallet names 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>
Verdict: PARTIALLY superseded. Do not close this — reshape it.Measured 2026-08-28 against Superseded, and genuinely soThe NOT superseded — and this is the part worth keepingAgainst pub enum LineageAnswer { // singleton.rs
pub fn found(self) -> Option<ParentSpend>
pub fn from_lookup(spend: Option<ParentSpend>, on_miss: Self) -> Self
}
async fn parent_spend(&self, parent_coin_id: &str, spent_height: u32) -> Result<LineageAnswer>;This replaces a bare
That is the same defect class this repo has paid for before: a local or transport failure being
Next actionReshape this PR down to the How this was measured, so it is not re-derived
|
Make a funded wallet report its real $DIG balance
Closes #382
Closes #380
The bug
A CAT coin sits at the outer puzzle hash that curries the asset's TAIL around its owner's p2
hash — never at the p2 hash itself. The subscription filter therefore dropped every hinted $DIG
coin the peer answered with, and the attribution pass had no row to fill in. Measured on a live
node: 188 coins discarded per catch-up, and a funded wallet reporting a balance of zero.
A second, independent defect: the supervisor passed
Nonewhere aCatAttributorbelongs at theonly production call site, so the attribution pass existed, was correct, was unit-tested, and was
never reached.
The fix
Because the outer curry commits to the asset and the owner together, the wallet derives
cat_puzzle_hash(owner_p2, asset_id)for its own p2 hashes crossed with the asset ids it knows,and subscribes those hashes. A coin arriving at one is that asset, and is this wallet's, by
construction — the hash it matched is the proof.
So
apply_coin_statesis main's shape again: filter plus upsert, zero chain reads. The coinarrives already carrying its
asset_idand its ownerhint.hintis load-bearing: the CAT balance query scopes byhint, not by puzzle hash, so a rowadmitted without one is stored, correctly typed, and still reads as zero.
Construction is
digstore_chain::cat::cat_puzzle_hash, the same one the balance, reconstructionand send paths already use. A second spelling of that curry would be a byte-drift bug that decides
whether money is counted.
What this deletes
Rounds 1-4 each bounded a lineage-resolution step on the frame path, and each fix created the next
defect. This round removes the mechanism instead:
admit_hinted,NotAdmitted,AdmissionOutcome,SyncError::IncompleteBatchand its session-kill, andBoundedLineagewithits token bucket and refund (
lineage_guard.rsdeleted;rate_limit.rsandmod.rsreverted toorigin/main).3,161 → 1,865 insertions. Outbound requests per admitted coin: ~12 → 0. Latency cost:
none — attribution is a hash comparison on the frame path.
No path can create an
asset_id IS NULLrow from a peer frame. That matters becauseasset_id IS NULLmeans XCH in this schema and is selected unscoped by the spend-inputselector, so the XCH-miscount direction is closed structurally rather than by a guard.
Blast radius checked
apply_coin_states,handle_coin_state_update,initial_sync_with_authority,SessionState,CatAttributor,SyncSession::catch_up,Attribution,Supervisor. Callers swept acrosssync.rs,sync_supervisor.rs,service.rsand both test modules;singleton.rsandfallback.rsdoc references to deleted types rewritten.Set separation — the hazard that would turn this into a different money bug. Four similarly
named puzzle-hash sets exist. Confirmed no derived CAT hash reaches any but the subscription:
plain_puzzle_hashes(sync_supervisor.rs:1414) — built frompuzzle_hashesonly. It means"hashes we can sign for"; a CAT outer hash there reads as spendable p2.
handle.set_watched(:1428) — p2 count only; a CAT outer hash is not an address.CatchUpReplay::finished_at(sync.rs:1144) — recordspuzzle_hashes, not the widened set.spend.rs:97'spuzzle_hashes()andfollowed_puzzle_hashes— untouched in this diff.Kept on merit
Supervisor threading of the
CatAttributor(the original bug),get_coin_spend_opt's corroboratedabsence, the coin-id binding check and placeholder repair,
from_lookup, and the persistedattribution_examinedmark. These serve the out-of-band pass, which still attributes NFT and DIDsingletons and any CAT row already in the replica.
Out of scope
CATs whose asset id the wallet does not know in advance. Their outer hash cannot be derived, so
they read absent rather than wrong — the failure direction this wallet must have. Unknown-CAT
discovery cannot be done by local derivation and must not sit on the frame path where a remote peer
sets the pace. Owed as a separate ticket.
How verified
Each figure is an observed value, obtained by calibrating the assertion to a wrong constant.
hintwrite fails the hint test while the supervisortest still passes. Leaking derived hashes into
plain_puzzle_hashesinitially passed 681/681 — areal gap — so
a_derived_cat_hash_never_reaches_the_plain_p2_setwas added; it passes clean andfails under the leak.
SPEC 18.11a and 18.11c rewritten to be true of the code in this diff.
dig-node-control-interfaceheld at0.21(bump split to #386).