feat(wallet): stage derived-hash CAT arrivals, promote only lineage-proven coins - #393
Conversation
…/discard path Salvaged from a lane the watchdog killed mid-implementation, with 232 lines uncommitted. VERIFIED: cargo check -p dig-wallet passes on this tree. Derived-hash arrivals stage here; only a lineage-proven coin is written to `coins`, fully attributed. `coins` keeps origin/main's semantics exactly, so a fabricated coin is unreachable by ABSENCE from the table its 22 readers read, rather than by a guard each of them must remember to apply. A staging table rather than a lineage_proven column because this PR family twice proved its own enumerations incomplete: SPEC 18.11a named the puzzle-hash sets, a lane found seven, a gate found an eighth -- and that eighth was exactly where a false "you were paid" notification came from. INCOMPLETE. The lane's last words were "now the reorg rollback must unmake staged rows too", and that is not done. A reorg that rolls `coins` back above the fork must roll staged rows back with it, or a staged row outlives the chain state that justified it and can later be promoted against a fork that no longer exists. Refs #390 Co-Authored-By: Claude <noreply@anthropic.com>
…e on reorg A staged row records an OBSERVATION of chain state, not money. A rollback deletes the state that observation was made against, so a row left behind can later be promoted against a fork the chain no longer has -- writing into coins on the strength of undone history. Deleted by the same predicate, in the same transaction, as the coin rows. A spend above the fork is cleared rather than the row deleted, since the coin's own creation is still confirmed.
…ry arrival A CoinState carries no hint, so a wallet cannot recognise its own CAT coins from the frame that delivers them; the only local route is to derive the outer hash cat_puzzle_hash(owner_p2, asset_id) and subscribe it. On a real wallet, 50 of the 51 puzzle hashes holding its coins were dropped at ingest for want of this. Subscribing is discovery, not belief: apply_coin_states now ROUTES rather than types, sending derived-hash arrivals to cat_admission_pending and leaving coins with exactly origin/main's semantics. Zero chain reads on the frame path, structurally -- the staging path takes no LineageSource. SessionState keeps the address set and the derived set as separate fields so record_arrivals cannot see an outer CAT hash.
…ame path Promotion runs in the out-of-band pass, at the two sites that already hold a LineageSource: CatAttributor::attribute and refresh_tracked_coins. Three outcomes, and the third is deliberately not the second -- proven promotes, disproven deletes terminally, unavailable leaves the row staged and retried. CatAttributor::promote returns nothing and swallows every error: run_update_loop calls attribute(db).await?, so a propagating chain-read failure would end a live peer session, which is the denial primitive earlier rounds introduced twice. Attribution is taken from the parent spend's own reconstruction, never from the derivation that found the coin, and both halves -- asset id and inner p2 -- must agree before a coin enters coins.
Fixtures built to distinguish the property from the nearest wrong implementation, not merely to assert an outcome: - the fabricated-coin test carries a REAL CAT beside the fake, so a filter placed at the wrong layer -- one refusing every derived-hash coin -- fails visibly instead of satisfying an empty-set assertion identically; - the denial test fails ONE parent read and keeps a truthful control, so 'handled the error' is distinguishable from 'did no work'; - the read-bound test CALIBRATES the counter to non-zero before believing any zero it reports, and pins the cap from both sides with one-over; - incompleteness is asserted in both directions -- absent from its own asset's balance AND from the XCH balance and the spend selector; - the derivation claim itself is asserted rather than assumed, so the rest cannot pass vacuously.
The routing test is the one that fails against origin/main's behaviour: a coin at a derived CAT hash is dropped there and staged here. Paired with an ordinary p2 coin in the same batch, so an implementation admitting the CAT coin straight into coins satisfies neither assertion, and with an unknown-hash control so staging is shown to widen acceptance by exactly the derived set and nothing else. The arrivals test pins the sync.rs:957 defect class as unreachable rather than guarded: the address set and the derived set are separate fields, and only the former is handed to the notifier.
…ved CAT hashes The two supervisor tests correctly detected the behaviour change and are tightened rather than widened: subscribed_for derives the expected extra hashes from cat_puzzle_hash(address, DIG_ASSET_ID), so an implementation subscribing one hash too many, or the wrong curry, fails as loudly as one subscribing too few.
… bar #380 states its bar as a real wallet reporting its real $DIG figure, which a green suite does not answer. Ignored by default and driven by DIG_REAL_WALLET plus a captured chain snapshot, so the capture is auditable separately from the code it exercises and the harness performs no network I/O of its own. Measured against a copy of the live replica: 948 coins across ONE puzzle hash, zero attributed, zero at the derived CAT hash -- the starvation. The chain holds 8 unspent coins at cat_puzzle_hash(that address, DIG_ASSET_ID) totalling 3,856,455, all 8 of which stage, promote and are counted.
Normative: derived-hash arrivals are staged, only lineage-proven coins enter coins, the address set and the derived set stay distinct, promotion is terminal and capped and never propagates into the update loop, the staging bound delays rather than errors, and the stated failure mode is incompleteness. Bumps dig-wallet 0.39.0 -> 0.40.0 and dig-node 0.160.0 -> 0.161.0 (minor: new capability, no removed API).
loop-security — audit STARTED (IN PROGRESS — not the verdict)Head audited: Working in my own worktree under Attack surface I am working through, in order — each gets its own comment as it resolves:
|
FINDING 1 — CRITICAL, LIVE. A fabricated coin DOES reach
|
FINDING 2 — CRITICAL, LIVE. 64 mojos buys a permanent $DIG starvation and unbounded chain-read amplificationIN PROGRESS — not the verdict. Head The "terminal, ~1x amplification" property ( The seam
Ok(cs) => cs,
// The parent spend is not available (unspent / not found) — a clean "no lineage".
Err(_) => return Ok(None),Every failure — including a An attacker's Two consequences compound, because the queue is FIFO — (a) Permanent head-of-line starvation. 64 never-refusable rows at the head of the queue are (b) Unbounded read amplification. 64 chain reads per pass, forever, from a one-time spend. ReproducedProbe in my own worktree
ExploitState: victim runs dig-node; attacker knows the victim's public address only.
The lane saw the mechanism and downgraded the assertion instead of filing it
// Every one of them was refused (their parents are unknown to the map -> but the map
// returns None, which is UNAVAILABLE, so they stay staged and ARE re-read). Assert the
// honest thing instead: the pass is capped and the already-PROMOTED coin is never re-read.That comment states this finding exactly. The test was then weakened to assert only the per-pass cap Shape of a fix (the lane owns the design)The classification, the ordering, and the doc are three separate problems and all three are load-bearing:
|
FINDING 3 — HIGH. The primary promotion site is DEAD in production; the one live site is silentIN PROGRESS — not the verdict. Head Brief item 4 asked whether
|
EVIDENCE VERIFICATION — the lane's instrument discipline holds upIN PROGRESS — not the verdict. Head 1. The load-bearing revert reproduces EXACTLY as claimedMutated
2. The real-wallet measurement reproduces independentlyI did not reuse Every figure matches what was reported: 948/1/0, One caveat, stated so nobody over-reads it: 3. The read-bound calibration is real, not decorative
4. Reorg rollback atomicity is real
The staged predicate is byte-identical to the Residual, non-gating: a staged row with 5. The sibling — a staged coin later spent
|
FINDING 4 — GATING. SPEC §18.11a is BORN FALSE in the diff that writes it (four clauses)IN PROGRESS — not the verdict. Head Checked clause by clause against this same diff, per the born-false rule. §18.11a is at
Four normative MUSTs are false of the code in the same diff, and one more is vacuous. Three of the Also worth naming: the FINDING 5 — MEDIUM (defense-in-depth). Promotion races the reorg rollback it depends on
let mut tx = self.pool.begin().await?;
sqlx::query("INSERT INTO coins … ON CONFLICT(coin_id) DO UPDATE …") // writes unconditionally
…
sqlx::query("DELETE FROM cat_admission_pending WHERE coin_id = ?")The staged row is read at That is exactly the outcome commit Not gating on its own: it needs a reorg concurrent with a promotion pass, and the promoted coin is Cheap fix: do the The out-of-scope item is accurately stated, and this PR does not worsen itdig-node#394 ( This PR does not make it worse: |
loop-security VERDICT: CHANGES-REQUIREDHead audited: The central thesis — only lineage-proven coins are written to GATING (ranked)1. CRITICAL — a fabricated coin reaches 2. CRITICAL — 64 mojos buys permanent $DIG starvation plus unbounded read amplification. 3. GATING — SPEC 18.11a is born false in four normative MUSTs. "never to 4. HIGH — the promotion path is dead in production and the live one is silent. NON-GATING (follow-up tickets, do not hold the PR on these)5. MEDIUM — promotion races the reorg rollback. 6. LOW — 7. LOW — What I verified as CLEAR
Coverage I did NOT achieve — judged honestly
Re-gate scopeFindings 1-3 are one root cause plus its SPEC text; finding 4 is wiring. A fix touching the |
A catch-up needs coverage over addresses AND derived CAT hashes, and admission over addresses only. `sync_supervisor.rs` built the union at the call site and handed it down as one vector; `initial_sync_with_authority` then built the `coins` admission set from that widened vector, so every coin at a derived CAT hash was admitted and typed `asset_id: None` -- which means XCH. One `CREATE_COIN` at `cat_puzzle_hash(victim_p2, DIG_ASSET_ID)`, costing one mojo per displayed base unit and needing only the victim's public address, then bought a fabricated XCH balance and a permanent send kill-switch: selection is largest-first and the coin is unspendable by anyone. The union now happens inside `initial_sync_with_authority` and reaches only the peer request. The admission set is built from `addresses` with derived hashes actively FILTERED OUT, so a caller cannot widen admission even by passing one. `covered_puzzle_hashes` returns to addresses-only for the same reason. Refs dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
Two regression tests for the fabricated-XCH primitive. The first drives the real catch-up with a NON-default `DerivedCats` -- every pre-existing catch-up test passed `DerivedCats::default()`, and a field every fixture sets to the same value is a field the suite cannot test. It asserts BOTH halves: the peer is still asked about the derived hash (coverage), and the coin at it is staged rather than admitted (admission). Asserting only the second is satisfied identically by a catch-up that never subscribed the hash at all, which is #380's starvation wearing this defect's assertion. The second hands a derived hash in the ADDRESS vector -- the misbehaving caller -- and pins that admission still refuses it. That is what makes the guarantee structural rather than a convention a future caller has to remember. Refs dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
… starving 64 mojos bought permanent $DIG starvation. A promotion that cannot read its parent leaves the row staged -- deliberately, because deleting on an unreadable parent lets a source that is merely behind erase real money -- and the queue was served `ORDER BY seq ASC LIMIT 64`. So 64 coins with invented parents held the head for ever: every pass re-read the same 64 rows, reads climbed without bound, and no honest coin behind them was ever reached. The queue is now ordered `attempts ASC, seq ASC`, so a row that keeps failing sinks below every row that has never been tried and cannot hold the head. On top of that a row is eligible only if it has not been read within `PROMOTION_RETRY_COOLDOWN`, which bounds an attacker to `rows / cooldown` reads rather than `limit` reads per pass. No row is ever deleted for failing: absence is the accepted failure direction, erasing a real coin is not. A terminal refusal keyed on "the parent never existed" was considered and rejected: a coinset source answers a null `coin_solution` both for a spend it has never heard of and for one it is behind on, so that classification would convert a brief outage into permanent erasure. The cost is bounded instead of the cause classified; the reasoning is recorded on the constant. `ChiaQueryLineage::parent_spend` also stops manufacturing `Ok(None)` -- the value callers read as "the chain has no such spend" -- out of every transport failure. `reconstruct_all` keeps its per-coin resilience explicitly so one flaky read cannot abandon a whole attribution pass. Also corrects `MAX_CAT_PROMOTIONS_PER_PASS`'s doc, which claimed promotion was terminal and the total amplification therefore 1x. That is true of a promotion that concludes and false of the deferral an attacker chooses. Refs dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
…ening the claim Three changes, all to the same property. `promotion_reads_are_bounded_and_never_repeated` now asserts the thing its name promises. It previously settled for `second <= cap` with a comment conceding that deferred rows "ARE re-read" -- an assertion the defect satisfies, on a test named for the property the defect breaks. The correct number is one: after a pass one row past the cap has never been read, and the 64 already tried are inside their cooldown. Zero would be wrong too, and for the opposite reason. `a_wall_of_unresolvable_coins_cannot_starve_an_honest_one` stages exactly one pass's worth of coins with invented parents and one real simulator-built CAT behind them. A fixture where every staged coin is unresolvable cannot see this defect at all -- a starved queue and a healthy one emit the same output when nothing could promote -- so the honest control is what makes them differ. It is false for any number of passes under the old ordering. `the_queue_serves_fewest_attempts_first_and_honours_the_cooldown` asserts the mechanism directly, with time PINNED to an explicit NOW rather than drawn from the clock, and pins the cutoff from both sides: one second past it is served, exactly at it is served, inside it is not. Refs dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
… of believing `refresh_tracked_coins` fetched coins two ways -- by puzzle hash, which finds coins at the wallet's own p2 hashes, and by HINT, which finds coins that merely claim to be for this wallet -- and upserted both straight into `coins`. A row with no `asset_id` means XCH in this schema, and anyone may `CREATE_COIN` with any hint, so an attacker holding only the victim's public address minted a fabricated XCH balance for one mojo per displayed base unit, plus a permanent send kill-switch: selection is largest-first and nobody can spend the coin. This tier needs no peer at all -- the coinset oracle serves it -- and it is live on `main` today. The third tier is routed through the SAME staging table as the peer frame path rather than given a guard of its own. Three guards that must agree is precisely what produced this defect at three tiers; one admission point that demands a lineage proof is the shape that cannot be widened. Coins at the wallet's own p2 hashes are unaffected: they are genuinely ours and genuinely XCH. A hinted coin at a derived hash for a known asset is staged and promotes on proof. A hinted coin that is neither is now dropped rather than believed -- it could never be selected as an XCH input and carries no asset id, so admitting it only ever produced a wrong figure, and absence is this design's accepted failure direction. Closes dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
…ping it The first cut of the point-read re-route staged only coins sitting at a DERIVED hash for a known asset, and dropped every other hinted coin. That closed the fabricated-XCH hole but silently removed a shipped capability: the wallet's only path to an arbitrary (non-$DIG) CAT is the hint read, and `refresh_tracked_coins_feeds_cat_selection_and_build_sign` exercises exactly that end to end. A narrowing that a green suite would have hidden, and did not. So a hinted coin with no prediction is staged too, with an empty sentinel in place of the derived pair, and promotion proves it from the parent spend alone: the reconstruction must name this coin id, and its hint must be an address this wallet controls. That is the same claim the derived path proves -- this coin is a unit of asset A and only this wallet can spend it -- reached without a prediction to check against. `promote_staged_cats` therefore takes the wallet's own p2 hashes, so the unpredicted branch has something to check the hint against; without it the hint would be attacker-controlled all the way into `coins`, which is the defect rather than a smaller version of it. The supervisor tests now assert coverage and admission SEPARATELY -- the set put on the wire and the set that admits coins are different values, and asserting only the union is what let them be conflated. `subscribed_for` is renamed `requested_for` to say which one it describes. Refs dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
Two regression tests for dig-node#394. `a_hinted_coin_is_staged_while_a_coin_at_our_own_hash_is_admitted` drives the real `refresh_tracked_coins` with an attacker's coin at the victim's derived $DIG hash beside an honest coin at the victim's own p2 hash. The honest coin is what makes it a PLACEMENT test: "the balance is short" is satisfied identically by a correct re-route and by a refresh that fetched nothing, so the control must still be admitted, still be selectable, and still be the whole balance. The fabricated coin is asserted present in STAGING as well as absent from `coins`, so the fix cannot be satisfied by dropping it on the floor either. `an_unpredicted_hinted_coin_promotes_only_to_an_address_we_control` runs the same coin, the same proof and the same lineage source twice, varying only the set of addresses the wallet claims. A promotion path that ignored `owned_p2` passes the first half and fails the second. Refs dig-node#394. Co-Authored-By: Claude <noreply@anthropic.com>
loop-security round 7 — GATING FINDING, posted as formed (verdict still to follow)Head FINDING 1 (GATING) — the narrowing silently DELETES the wallet's NFTs and DIDsThis is error 2's generalisation manifesting a second time, in the same function, and the lane's own standard for error 2 requires fixing it: a shipped capability whose only path is the hint read. Mechanism. Empirically confirmed, not inferred. I minted a real NFT with the existing
This removes the ONLY production path that populates Why no test caught it. It is worse than one-shot loss. On the next refresh the NFT is fetched by hint again, is still absent from Suggested shape (not my call to implement): in FINDING 2 (non-gating, doc) —
|
loop-security round 7 — CHANGES-REQUIREDHead audited: One GATING finding. It is not a re-statement of any earlier round: it is round 7's own self-caught error 2 recurring in the same function, against the lane's own stated standard. GATING 1 — the point-read narrowing silently and permanently DELETES the wallet's NFTs and DIDs
State → action → impact. A user holds an NFT (or a DID profile). Its coin sits at a singleton puzzle hash — never at one of the wallet's own p2 hashes — and is hinted to the owner p2, which is how every Chia wallet discovers it. Measured directly, with the repo's own
This removes the ONLY production path that populates Worse than one-shot loss. The coin is re-fetched by hint on every refresh, re-staged, and re-refused — a permanent recurring chain read per NFT per refresh. The "terminal per verdict" property that bounds promotion amplification does not hold for a coin the source keeps re-offering. Why nothing caught it. Remedy is the lane's call, but the acceptance bar should be an end-to-end test driving Non-gating findings (follow-up tickets; do NOT hold the merge on these)N1 — N2 — first-sweep starvation still scales with the wall size. The attempts-ordering fixes repeat starvation, not bulk-fresh starvation: a wall of never-tried rows precedes a later never-tried honest row by At the staging bound of 20,000 ( N3 — two early-exit branches in N4 — N5 — two admission sets, one filtered and one not. The catch-up builds its admission set with an explicit derived-hash filter ( N6 — SPEC §18.11a enumerates three promotion outcomes; the code has four deletion reasons. The spent-coin discard ( Areas checked and CLEAR
What I did NOT achieve
|
…DIDs An NFT or DID coin sits at a singleton puzzle hash, never an owned p2 hash, and is hinted to the owner -- so route_point_read_rows stages it. Promotion reconstructed it correctly as a singleton, which is not a Cat, and the let-else returned Disproven: the row was deleted terminally. Since the point-read tier is the only production path that reaches reconstruct_all, this silently emptied nfts/dids and re-paid the chain read for every singleton on every refresh. Disproven now means only 'the derivation was a lie'. A proven singleton gets its own outcome, Resolved, and is written to nfts/dids -- never to coins, where a missing asset id reads as XCH. Admission is not widened: the singleton must be owned by a p2 hash the wallet controls, the same standard an unpredicted CAT is held to, checked against the reconstruction rather than the attacker-controlled hint. Proven end to end through production routing (route_point_read_rows -> promote_staged_cats), not through a helper below the narrowing -- which is why nothing caught this: singleton.rs's own test injects with upsert_coin one layer down. Also covers both previously-uncovered early exits in promote_staged_cats. Refs #380, #394 Co-Authored-By: Claude <noreply@anthropic.com>
18.11a listed three outcomes while the code had four deletion reasons, and now has a fourth outcome: a proven singleton is Resolved, not Disproven. Refs #394 Co-Authored-By: Claude <noreply@anthropic.com>
…lica The acceptance measurement asked for the singleton row counts, not only the $DIG figure: this round's defect predicts refused>0 and emptying nfts/dids, which the pre-existing balance-only harness could not observe. Refs #394 Co-Authored-By: Claude <noreply@anthropic.com>
Round 8 — the gating finding is fixed, with the outcome set widened rather than admissionHead The fix
The gate's framing was right and I took it: Three properties, stated so each is checkable:
I did not touch anything the gate cleared. Mutation transcript — five mutations, each landing on exactly one testCommitted before every revert; reverts done by file copy, never
Mutation 5 is the placement half, and it is there because the gate has caught this family asserting Mutation 2 is why test 1 alone is not enough — test 1 stays green under mutation 2, and the stranger The end-to-end proof goes through production routing
It deliberately does not use Both singleton kinds are in the fixture rather than one, because NFT and DID reconstruct through
Suite689 / 22 / 8, green — the gate's 685/22/8 plus the four new tests. Blast radius checkedgitnexus MCP tools were not available in this session, so the radius was established by exhaustive
Nothing reaches further than the promotion outcome set and the point-read tier, which is the Non-gating
The acceptance measurement — obtained, with a limitation I want on the recordThe replica is on this machine: I extended the harness to report And the honest reading is that this wallet cannot exhibit the defect. All 948 rows sit at the
|
loop-security re-gate (round 9) — IN PROGRESS, not the verdictAuditing head Interim 1 — "
|
Interim 2 — IN PROGRESS, not the verdictThe ownership guard is VACUOUS on the
|
Interim 3 — IN PROGRESS, not the verdictThe forged DID reaches
|
loop-security re-gate, round 9 — CHANGES-REQUIREDHead audited: The ninth defect exists and it is the one the brief predicted. The brief said: "If any branch Read this first, because it bounds the remedy: the exploit is NOT a regression. The F1 — GATING.
|
| Clause | Evidence | Verdict |
|---|---|---|
Proven promotes into coins |
cat_discovery.rs:366 -> db.rs:1857 |
true |
Resolved writes nfts/dids + deletes the staged row |
cat_discovery.rs:369, db.rs:1898-1919 |
true |
| Disproven deletes the staged row | cat_discovery.rs:370-373 |
true |
| Unavailable MUST NOT delete | cat_discovery.rs:344-362 |
true |
| A proven non-CAT MUST NOT be refused | cat_discovery.rs:483-496 |
true |
A singleton MUST NOT be written to coins |
db.rs:1898-1919; pinned by mutation 5 |
true |
| A resolved singleton MUST be owned, never the hint | singleton.rs:157-162 |
FALSE for DID |
| Disproven covers four cases | five producers exist (below) | inaccurate, non-gating |
F2 — GATING-with-F1 (fixing this makes F1's sentence true). The DID ownership guard is vacuous
crates/dig-wallet/src/sage/cat_discovery.rs:424 — owned_p2.contains(&reconstructed_owner_p2).
Load-bearing for NFT, vacuous for DID.
Why NFT is sound. chia-sdk-driver .../nft.rs:273-278 builds the child as
Coin::new(parent.coin_id(), info.puzzle_hash().into(), amount) — the puzzle hash is derived from
info, and p2_puzzle_hash is inside it. singleton.rs:147 then asserts
nft.coin.coin_id() == child_id, which commits to the puzzle hash. owner_p2 is bound.
Why DID is not. Did::parse_child stores the caller's coin unexamined and never compares
info.inner_puzzle_hash() against create_coin.puzzle_hash — which is precisely the check the SDK's
own construction path makes at did.rs:80-82. The read path omits the check the write path makes.
singleton.rs:155-163 adds no compensating check either (contrast the NFT arm at :147).
Executed exploit — end to end through the production routing
Probe run in my worktree at 904f506, entering above the narrowing (route_point_read_rows ->
stage_cat_admissions -> promote_staged_cats, no db.upsert_coin):
[R9b] real child puzzle_hash = 22afa23b31055faa09311acf59986d84d139c89c425964af1cad28b91fe18930
[R9b] reconstructed puzzle_hash = 3d93c9d59fd6d3c2eb988bf7eca52f3d3435b0aa3a893c710f50a02f5630f9c0
[R9b] believed=0 staged=1
[R9b] stats = PromoteStats { promoted: 0, resolved: 1, refused: 0, deferred: 0 }
[R9b] dids rows = 1
[R9b] {"launcher_id":"f63dfd5aed...","address":"xch1nxvenxven...vsxnssf9",...}
[R9b] coins rows = 0
State -> attacker action -> impact. Mallory owns any DID. She spends it, keeping her own p2 in
the new inner puzzle, and sets the odd CREATE_COIN's memo hint to the victim's p2 hash. The
simulator accepted the spend, so it is chain-valid. The victim's coin_records_by_hints returns the
child (that is what a hint is for); route_point_read_rows stages it;
promote_one reconstructs it as Reconstructed::Did { owner_p2 = victim_p2 }; the guard passes;
db.rs:1898 writes it to dids keyed by Mallory's launcher id, rendering the victim's own
address as owner. Verdict counted as resolved: 1 — the SUCCESS outcome. Repeatable per launcher;
dids is uncapped (contrast CAT_ADMISSION_PENDING_MAX_ROWS = 20_000).
Impact, measured — identity fabrication, not balance fabrication
coins rows = 0: the placement half holds even under forgery. No balance, nothing selectable, no
spend enabled. Consumers of the forged row:
rpc.rs:2524get_dids— the wallet lists a DID the user does not control.rpc.rs:3555-3569did_parent_child/resolve_did, used at:3765as the minting DID —
selecting a forged one yields a spend the user cannot sign. It fails; it does not misdirect funds.db.rs:2976is_asset_ownedanswerstruefor that launcher, exposed as an RPC method
(rpc.rs:1138, 2516, 4324). No internal caller today, so it gates nothing — but it is an
ownership assertion the wallet makes falsely.
Severity: MEDIUM, and explicitly NOT a regression. At the merge-base, singleton.rs:436
(reconstruct_coins) calls db.upsert_did with no ownership test at all, fed by
rpc.rs:3105-3116 upserting every hinted coin. The identical forged row lands on main today.
The remedy is proven available and small
The two hashes above differ, so binding the reconstruction to the real coin closes it:
parsed.info.puzzle_hash() == child.puzzle_hash // else Disproven
In the honest case they are equal by construction (Did::transfer returns self.child(p2, ..),
whose coin is built from info.puzzle_hash()), so a real DID pays nothing.
Put it in reconstruct_parsed (singleton.rs:155-163), not in promote_singleton. Fixing only
the promotion guard leaves the wider pre-existing path open: reconstruct_coins (singleton.rs:436)
still calls upsert_did with no check, and Did::parse_child ignores the coin argument entirely,
so a parent spend of a DID can mint a bogus dids row from an unrelated child. (That second path is
read-analysis, NOT executed — see coverage below.) Fixing the reconstruction closes both at once
and makes F1's sentence true as written.
F3 — non-gating. SPEC.md says Disproven covers four cases; the code has five
SPEC.md:5493-5497 enumerates: spent-on-chain, coin-id-does-not-bind, disagrees-with-derivation,
singleton-owned-by-another. It omits Reconstructed::Unknown -> Disproven
(cat_discovery.rs:498) — which the in-code doc at cat_discovery.rs:286-288 does list
("or reconstructs to nothing at all"). A countable claim that is off by one, in a section with a
born-false history. Fix the count while fixing F1.
F4 — non-gating. PromotedSingleton steals StagedCatRow's doc comment
crates/dig-wallet/src/sage/db.rs:349-361. The new enum was inserted between StagedCatRow's
five-line doc block and StagedCatRow itself. Result: PromotedSingleton's rustdoc opens with
"A discovered CAT coin awaiting a lineage proof … Deliberately NOT a CoinRow…", and
StagedCatRow (db.rs:369-370) now has no doc comment at all — losing the hypothesis-vs-belief
rationale that is the whole security argument of this PR. Move the block back.
What I verified clean, and how
Mutation transcript re-executed (1, 2, 5), each reproduced exactly. Applied by exact-text
replacement asserting a single occurrence; reverted between each; git status --porcelain empty
after every restore.
| Mut | Change | Result |
|---|---|---|
| 1 | singleton arms -> Disproven |
an_owned_nft_and_did_survive_the_point_read_tier FAILED, PromoteStats { promoted: 0, resolved: 0, refused: 2, deferred: 0 } — matches the transcript |
| 2 | ownership guard neutered | a_singleton_owned_by_a_stranger_is_refused FAILED, resolved: 1, refused: 0 — matches |
| 5 | singleton also written to coins |
test 1 FAILED on a singleton must never enter coins |
Independence confirmed by execution, not assertion: under mutation 1, test 2 stayed ok; under
mutation 2, test 1 stayed ok. They are two tests, not one. Mutation 5 confirms "not deleted" cannot
be satisfied by a version that reintroduces #394's fabricated-balance defect.
Realization 1 applied as a sweep. For every narrowing this PR introduces, its tests enter above
it: the hint->staging narrowing is tested at route_point_read_rows (cat_discovery.rs:1380);
promote_singleton's guard and the spent/unconfirmed early exits are tested at
promote_staged_cats, which is above all three. I confirmed the end-to-end test contains no
db.upsert_coin call — the only mention is in its FIXTURE DESIGN doc explaining why. And
refresh_tracked_coins (rpc.rs) really does make those calls: route_point_read_rows ->
upsert_coins(&rows) (believed only) -> stage_cat_admissions(&staged) -> promote_staged_cats.
mint_did_and_nft is now pub(crate) and genuinely crossing the module boundary
(cat_discovery.rs:1372 calls crate::sage::singleton::tests::mint_did_and_nft).
N1 — true now. route_point_read_rows's paragraph (cat_discovery.rs:201-206) says the coin is
STAGED with the empty sentinel. The loop at :216-253 has exactly three outcomes — believed
(owned hash), believed (already promoted), staged — and drops nothing. The old "DROPPED" wording is
gone and the replacement is accurate.
Promotion::Resolved logging — fixed, and no other stat is silent. rpc.rs:3162 now reads
stats.promoted > 0 || stats.resolved > 0 || stats.refused > 0 and emits resolved as a field at
:3166. The only other arm is the deferred > 0 debug at :3169. All four stats have a log path;
an all-zero pass is correctly silent. I checked for a stat whose log condition predates it — there
is none.
Admission is not widened (the half that holds). I re-enumerated every writer of coins:
db.rs:1602 upsert_coin, :1632 upsert_coins, :1857 inside promote_cat_admission. The other
four INSERT INTO coins are inside mod tests (:5721, :6929, :7014, :7136).
promote_singleton_admission (:1898-1919) calls only upsert_nft_on/upsert_did_on. No singleton
path reaches coins — confirmed statically, by mutation 5, and by coins rows = 0 in my own forged
run.
nfts/dids reachability is NARROWED, not opened — full argument in
#393 (comment).
Suite, verified myself. cargo test -p dig-wallet --tests on a clean run:
lib 689 passed / 1 ignored, conformance 22, money_path_vectors 8, real_wallet 2 ignored, 0
failed. Exactly the claimed 689/22/8. A first run under my own parallel compile showed
sync_supervisor::tests::stall_evidence_survives_the_end_of_a_session timing out at 120s; isolated
it passes in 0.03s — a load flake, not a defect. cargo fmt --all -- --check exit 0;
cargo clippy --workspace --all-targets exit 0.
Merge preconditions, by name (check-merge-preconditions.sh): Lint commit messages, Check
version increment, Rustfmt, Clippy, Test + coverage — all five present and SUCCESS;
unresolvedReviewThreads=0; mergeStateStatus=CLEAN. RESULT: BLOCKED on draft=true alone,
as expected.
Authorship: all 25 commits Michael Taylor <michael@michaeltaylor.dev>, author and committer —
the configured machine identity, no fabrication. Secrets: no match for key/token/password/
credential/mnemonic patterns anywhere in the added lines.
Version bump — the lane's judgement is right, no further bump needed. Reconstructed::Nft(Box<..>)
-> Reconstructed::Nft { row, owner_p2 } is source-breaking on a pub enum, and PromotedSingleton
is new pub. Under 0.x, ^0.39 is semver-incompatible with 0.40, so 0.39.0 -> 0.40.0 is
the breaking slot. cargo clippy --workspace --all-targets passing proves the two in-repo consumers
(dig-node-service, dig-runtime, both path) still build.
PR #393 / #395 do not cross. Changed files are Cargo.lock, Cargo.toml, SPEC.md,
crates/dig-wallet/Cargo.toml and seven files under crates/dig-wallet/src/sage/ plus its
tests/. Zero dig-node-service source files. The root Cargo.toml/Cargo.lock touch is the
workspace binary version (0.160.0 -> 0.161.0), required by the version-increment gate.
Real-wallet measurement. I accept the lane's reading and its stated limitation verbatim: all 948
rows sit at the single owned p2 hash, so the tier stages zero of them; the run proves no
regression and does not corroborate the finding, and nfts=0 dids=0 is equally consistent with
both explanations. That reasoning is correct and I am not asking for more from it. The harness is
#[ignore]d, env-gated, and opens a copy (real_wallet_cat_discovery.rs:1-23) — read-only, no
risk to the user's live replica.
Coverage I did NOT achieve — stated plainly
- Mutations 3 and 4 not re-executed. The brief required 1, 2 and 5 at minimum; I ran those
three. The two N3 early-exit mutations are unverified by me this round. - The second, wider DID path is read-analysis, not an executed probe. I proved
Did::parse_childignores itscoinargument by reading it and by the executed forgery, but I
did not execute the variant where an odd-mojo ordinary payment from a DID spender mints a
bogusdidsrow viareconstruct_coins. Treat that as a hypothesis with strong code evidence. - Rounds 1-7 material not re-audited. I took round 8's clears as given (crypto soundness,
DELETE-first race-freedom, reorg coverage of staged rows, the refusal of round 6's classification
remedy). The one round-8 claim I independently re-derived is "no fourth admission tier", which
holds. - Workspace suite not run — only
-p dig-wallet(plus a workspace-wideclippy --all-targets,
which type-checks every crate including tests). - No live/mainnet execution. Everything is simulator + in-memory SQLite. I did not observe a
real wallet ingest a real forged DID. is_asset_owned's hex normalisation — it bindsasset_idraw for thenfts/didsqueries
while normalising forcoins(db.rs:2963-2979). Pre-existing, outside this diff, not chased.
Verdict
CHANGES-REQUIRED, and the required change is small.
- F1 must not ship as written —
SPEC.md§18.11a's "never the hint" MUST is false for the DID
branch. This is the gate. - F2 is the right way to satisfy F1: bind the DID's
p2_puzzle_hashto the child coin in
reconstruct_parsed. ~5 lines, remedy proven above, and it closes the wider pre-existing path
too. If the lane prefers to defer the code fix, F1 can alternatively be satisfied by stating the
DID limitation explicitly in §18.11a and filing F2 — but do not ship the sentence as it stands. - F3 and F4 are non-gating, both cheap enough to fold into the same commit.
What I am NOT asking for. No re-architecture, no further rounds on the promotion outcome set, no
re-gate of anything round 8 cleared. Everything else in this diff is clean: the outcome set is
correct, the placement half holds under mutation, the tests enter above the narrowing, the logging
gap is closed, the suite is green, the preconditions pass by name, authorship is correct, there are
no secrets, the version slot is right, and #393 does not touch #395's crate.
Round 8 fixed a real defect and, as predicted, exposed the next one one layer down. The exploit it
leaves behind is pre-existing and not fund-moving — this PR is still a net security improvement
over main, and the only thing genuinely blocking it is a sentence that claims more than the code
delivers.
`Did::parse_child` reads the owner out of the parent spend's CREATE_COIN memo hint and stores it verbatim, so the ownership guard downstream tested an attacker-writable value. Recompute the singleton puzzle hash from the parsed info and require it to equal the real child coin's. Refs #380 Co-Authored-By: Claude <noreply@anthropic.com>
Three probes for the binding: the pure core, the wider reconstruct_coins path that has no ownership test of its own, and production routing (route_point_read_rows -> promote_staged_cats) with an honest DID riding alongside the forged one as a control. Refs #380 Co-Authored-By: Claude <noreply@anthropic.com>
The "never the hint" ownership MUST was false for DIDs until the binding in reconstruct_parsed; state the binding as its own normative clause so the ownership clause is true as written. Disproven covers five cases, not four -- the fifth is a coin that reconstructs to nothing at all. Also restore StagedCatRow's doc comment, which PromotedSingleton was inserted in front of. Refs #380 Co-Authored-By: Claude <noreply@anthropic.com>
Round 9 addressed — head
|
loop-security round 10 — IN PROGRESS, not the verdictHead audited: Finding 1 (clear) — the push hazard is genuinely self-corrected; no round-9 commit is stranded. Both refs point at the identical commit, so nothing landed only on Auditing from my own detached worktree Remaining: curry-chain verification against chia-sdk-driver 0.36.0, the fall-through/ |
loop-security round 10 — IN PROGRESS, not the verdict (2/n)Finding 2 (clear) — the curry chain holds, verified against Paths are in
The defect being fixed is confirmed in the driver, verbatim — let mut info = DidInfo::from_layers(&layers);
info.p2_puzzle_hash = hint;
Honest DIDs are equal by construction, confirmed: Finding 3 (clear) — the SPEC's "the NFT path needs no equivalent" is TRUE, and for a stronger
coin: Coin::new(parent_coin.coin_id(), info.puzzle_hash().into(), create_coin.amount)
Finding 4 (clear) — the fall-through cannot be reached transiently, and cannot be induced by a
|
loop-security round 10 — IN PROGRESS, not the verdict (3/n)Finding 5 (clear) — mutation 1 RE-EXECUTED by me, on my own worktree, and it reproduces exactly. Applied by exact-text replacement with a uniqueness assertion ( Result — The Finding 6 (clear) — the independence result is CONFIRMED BY EXECUTION, and it is the important one. In the same mutated run, round 8's two tests both stayed green: Round 8's blind spot is therefore demonstrated rather than argued: the guard it shipped is fully Baseline and restore, both verified by me:
|
loop-security round 10 — IN PROGRESS, not the verdict (4/n)Finding 7 (NON-GATING, defense-in-depth) — an HONEST DID can reach the refusal, but only via an I wrote a probe into my own worktree: Alice mints her own DID, then spends it with Mechanism. Why I am NOT gating on it:
Recommended as a follow-up ticket, not a merge gate: either carry the child's own metadata through Restored again with |
loop-security round 10 — IN PROGRESS, not the verdict (5/n)Finding 8 (clear) — the sibling-fixture confinement does NOT extend to the NFT or CAT arms. I The distinguishing property is whether the driver call accepts the child coin as an INPUT or
Two specifics I checked because they were the plausible places for a repeat:
Finding 9 (clear) — merge preconditions asserted BY NAME; BLOCKED on Finding 10 (clear) — scope, authorship, secrets, version.
|
loop-security round 10 — VERDICT: PASSHead audited: No security defect in the diff. The round-9 fix is correct, minimal, and closes a real forgery that Audited from my own detached worktree Area-by-area
Executed evidence (all run by me, this round)
Two NON-GATING findings (follow-up tickets, do NOT hold the merge)A. An honest DID with an UNSETTLED metadata/recovery update is now refused. Severity: low. B. match Did::parse_child(ctx, parent.coin, parent_puzzle, solution_ptr, child) {
Ok(did) => Ok(did), // no child binding at all — child_id is never even computed
Err(_) => Ok(None),
}Its sibling SPEC and docs — every clause checked against a
|
Co-Authored-By: Claude <noreply@anthropic.com>
…ends.list (#395) * build(deps): adopt control-interface 0.23.0 and the collateral math crate Bumps to latest published (CLAUDE.md 2.4b), verified against the crates.io index rather than memory: dig-node-control-interface 0.21 -> 0.23 (service + wallet) dig-logging 0.1 -> 0.2 (service) dig-constants 0.11.2 -> 0.13.0 (service + core, together) dig-mirror-collateral (new) 0.3 (service) The chia-* set is deliberately NOT moved. chia-bls and chia-protocol publish 0.48.0, but chia-wallet-sdk's own latest (0.36.0) requires ^0.36.1, so bumping the pair would ship this crate internally split across two chia lines -- the exact failure that shipped twice elsewhere in one day. The set is already at its latest coherent point. Adopting 0.23.0 turns the contract-conformance test red, naming the four methods this branch exists to serve: control.spends.list control.collateral.requirement control.collateral.margin.get control.collateral.margin.set Co-Authored-By: Claude <noreply@anthropic.com> * feat(collateral): per-epoch record store, local safety margin, funding advice The node's side of the deterministic mirror-coin collateral model, built on dig-mirror-collateral 0.3 -- no formula is restated locally, because required_per_store is the WHOLE answer and re-deriving it as equilibrium x multiplier - handicap omits the floor clamp. Three parts, split along the line that matters: * EpochRecordStore -- what this node censused, keyed by epoch. Distinguishes a record it never wrote (Absent) from one it wrote and cannot read (Unreadable); the remedies differ. * CollateralConfig -- the LOCAL safety margin, persisted. A config predating the field loads as the +1% default, never 0: zero is a deliberate choice to post exactly, and reporting it for a config that never expressed one tells the operator they declined a cushion they were never offered. * buffer_advice -- how much DIG to hold, and the three funding states. The buffer is deliberately NOT requirement x epochs of runway. Collateral is RECLAIMED, not spent, and reclaims run first and are never gated on funds, so steady state is roughly ONE epoch's lock. The real peak is the transition overlap, so the recommendation is lock + lock x (9/8)^4 -- one epoch's lock, the overlap, and four epochs of escalation headroom, in one expression rather than three that could double-count. Escalation compounds, so the horizon is a choice and it is reported with the figure. Only ShortNow and DangerouslyLow may notify. BelowRecommendedBuffer is a readout: a normal node sits there much of the time, and an alert an operator learns to dismiss teaches them to dismiss the two that matter. An unknown requirement or an unknown balance yields Unknown and never a zero cost. Co-Authored-By: Claude <noreply@anthropic.com> * feat(spend-audit): cursor paging and an explicit completeness flag The PR#31 gate measured that control.spends.list is not a bare dispatch arm: SpendQuery carried no after_id although the method is cursor-paginated, and SpendLog::query could not report 'complete' -- the contract distinguishes 'that is all' from 'we stopped here', and on an audit record those read the same and mean opposite things. * SpendQuery.after_id -- positional, not a filter, so matches() ignores it. Resuming by time would drop every spend sharing the boundary millisecond, and automated spends are issued by a cycle so several routinely share one. * SpendLedger.complete -- computed from whether rows were actually withheld, never from whether the page came out full. A matching set that is an exact multiple of the page size fills its last page and would otherwise read as truncated forever. * SpendLog::cursor_of -- the id of the last row HANDED to the caller, never a marker for where the record got to. An unknown cursor is REFUSED. Restarting would repeat rows the caller has seen; an empty page would either end the walk early or leave no cursor to advance and loop forever. The fixture puts six rows across three timestamps with two ties, and pages at 2, 3 and 4 so a boundary falls inside a tie and so one page is exactly full and final. A fixture with distinct timestamps or a page size of 6 could not tell a correct cursor from a time-based one. Co-Authored-By: Claude <noreply@anthropic.com> * feat(control): serve spends.list and the three collateral methods Closes the drift the contract bump exposed: four published methods with no server. All four now dispatch, and the conformance test that named them is green. control.spends.list -- one page of the automated-spend record, decoded through SpendsListParams so the contract's own page-bound validation runs without this handler remembering to. control.collateral.requirement -- the consensus-derived per-store figure, or a NAMED reason. Never a zero, never a stale epoch's figure as this epoch's. control.collateral.margin.get -- the local margin; a config predating the field reads as +1%, never 0. control.collateral.margin.set -- persisted before it is reported, and a value over the ceiling is refused rather than clamped, so stored intent and node behaviour cannot disagree on the money path. A record that could not be READ is now SPEND_AUDIT_UNREADABLE (-32048, taken from the shared catalogue rather than restated) and never an empty page: 'nothing to report' is the answer a person stops investigating on. CLI verbs, so a headless machine can drive all of it: dign collateral requirement dign collateral margin dign collateral margin set <tight|default|generous|BP> The margin is shown with what it costs, not as a bare setting, and a preset resolves to dig-mirror-collateral's own constant -- a second spelling of 'generous' is how two surfaces come to post different amounts for one choice. An unrecognised word is refused rather than falling through to the default. Co-Authored-By: Claude <noreply@anthropic.com> * feat(collateral): the recommended DIG buffer and the three funding states Closes dig-node#389. `dign collateral buffer [--balance <DIG>]` composes the requirement, the margin and the served-advertisement count into one number a person acts on -- 'add 9.706 DIG', not 'balance low' -- with the working shown so the figure can be sanity-checked. Also adds the census epoch marker seam: the node reads which epoch the census settled on rather than deriving it from its own clock, because the schedule is a consensus fact and a guess would post against the wrong epoch. Absent or malformed marker means NOT CENSUSED, never epoch zero. Two defects the live run caught, both the same class -- an unknown rendered as a reassuring answer: * a missing or non-array hosted-store list defaulted to ZERO advertisements, which produces a 0.000 DIG recommendation that every balance clears. A node that could not tell how much it owes would have answered 'funded'. It now answers UNKNOWN. * a node serving nothing said 'funded -- at or above the recommended buffer', implying its stores were covered when it has none. It now says there is nothing to collateralise. A malformed --balance is refused rather than parsed as zero, which would have reported SHORT NOW over a typo, and the amount is scaled by integer arithmetic because 0.001 DIG steps are where an f64 starts rounding. Co-Authored-By: Claude <noreply@anthropic.com> * docs(spec): SPEC section 24 -- the mirror-coin collateral contract Normative contract for the three collateral control methods and the three dign verbs: the consensus/local split, the four unknown reasons and why they are distinct, the census-names-the-epoch rule, the margin's persistence and refuse-not-clamp bound, the reclaim-not-spend derivation of the recommended buffer, and the three funding states with only two of them notifying. Section 24.6 records the hazard the live run surfaced: an unknown and a genuine zero produce identical arithmetic, so an unreadable store list read as zero yields a recommendation every balance clears and a node that cannot tell how much it owes reports 'funded'. Co-Authored-By: Claude <noreply@anthropic.com> * fix(collateral): derive the epoch from the canonical clock, not a stored marker Self-correction. The first pass assumed the mirror-coin epoch schedule was a chain-anchored consensus fact the node could not compute, and introduced a marker file for the census to write. That was wrong: dig-constants 0.13.0 publishes the schedule as a WALL-CLOCK one -- 7-day epochs from a fixed genesis -- and mirror_epoch_at_unix_ms is its canonical implementation. The marker is removed and the epoch is derived. Three consequences: * The epoch number is an INPUT TO COIN IDENTITY (dig_mirror_coin::mirror_hint takes it), so a second implementation of the arithmetic would derive different coins rather than a different label. The constant is delegated to, never re-derived. * Deriving it makes a STALE answer structurally unrepresentable. The lookup is for the epoch current NOW, so a node whose census stopped running reports not_censused for the present epoch instead of confidently serving last week's figure. The marker could not have detected that -- it was the hazard. * The surface is genuinely live rather than pending a census. A node with a record for the current epoch now answers with a real number, unaided. Verified on a real node: it derived epoch 104 by itself and reported 3.780 DIG per store from 17 advertisements across 820 owners; when the record was edited to name epoch 103 instead, it correctly refused with not_censused rather than serving the stale figure. One-based and div_euclid are both pinned in the test, at the only input that can tell them apart: the millisecond before genesis, which a truncating divide collides with epoch 1. Co-Authored-By: Claude <noreply@anthropic.com> * docs(spec): SPEC 24.3 follows the epoch clock correction The section described the superseded marker design. The epoch is derived from dig-constants' wall-clock schedule, and the section now records why that is what makes a stale answer unrepresentable -- and why a stored marker would reintroduce the hazard it was meant to avoid. Co-Authored-By: Claude <noreply@anthropic.com> * docs(collateral): name the census-vs-local trap the buffer must not fall into No behaviour change. The buffer's pair count is read from THIS NODE's hosted stores; control.collateral.requirement's 'stores'/'owners' are NETWORK census figures and the contract says in as many words that neither is a node count. Multiplying either by the requirement bills one operator for the whole network's collateral -- a confident wrong number on a money surface, which is worse than no number. The fixture already made that mistake observable (census stores 12 vs pairs 10, so a substitution changes the answer); it now says so, because a fixture that catches something by accident stops catching it at the first tidy-up. Co-Authored-By: Claude <noreply@anthropic.com> * style(collateral): satisfy clippy on the buffer summary Three findings, caught locally before CI: a useless format! over a literal and two needless borrows into serde_json::to_value. No behaviour change; 469 lib tests still green. Co-Authored-By: Claude <noreply@anthropic.com> * test(collateral): pin the rendering half of the never-render-an-unknown rule The wire was already honest -- an unknown answer carries no figure at all -- but nothing asserted the human line does not invent one on the way out, and the summary functions had no tests. Three groups: * an unknown requirement renders its reason and NO amount, for all four reasons, and the four remedies must stay distinct rather than collapsing into one unactionable sentence * a known requirement shows the census inputs behind the figure, says the figure is pre-margin, and renders owners as 'collateralised owner(s)' rather than as nodes * a 1 bp margin renders as +0.01% rather than rounding to zero, and a value that is nobody's preset is not mislabelled as the nearest one Co-Authored-By: Claude <noreply@anthropic.com> * refactor(collateral)!: conform the buffer to the declared 0.24.0 contract Reshapes #389's calculation to what dig-node-control-interface PR#36 declares, so adopting control.collateral.buffer is a wiring step rather than a reshape. FIXES A REAL DEFECT, not just names. The pair count was read from control.hostedStores.list -- the rival derivation SPEC 4.2e used to direct and PR#36 removes. A pinned/cached store list RESEMBLES the served (owner, store, root) set without being it, and the error is invisible because both produce a plausible number on a money surface. There is no published method for the served set, so it is now an explicit --roots operand and its absence reports served_set_unknown rather than being guessed. * BufferAdvice is a TAGGED enum. The unknown case has no representable numeric field, so a zero cannot be emitted even by accident -- a zero buffer reads as 'no buffer needed'. * BufferUnknownReason: served_set_unknown / reclaim_state_unknown / balance_unknown. A test asserts none collides with a census reason, which is the structural argument for a separate method: collapsing them would report a missing LOCAL fact as a missing NETWORK one. * Fields renamed to the contract's: recommended_buffer_*, spendable_*, overlap_*, escalation_headroom_*, pairs_served_by_this_node, and escalation_ceiling_micros beside horizon_epochs (both required). * FundingState::is_shortfall() replaces is_notification() and EXCLUDES below_recommended_buffer. FundingState::Unknown is gone -- unknown is a state of the ANSWER, not of the funding. Escalation is no longer a hand-rolled (9/8)^n. It steps dig_mirror_collateral::step_multiplier in its own high band, which keeps two behaviours the closed form loses: per-step truncation (0.8x over 4 epochs is 1.281444, not 1.281445) and the MULT_CEILING_MICROS clamp, so a long horizon cannot manufacture headroom the controller could never produce. A test drives 500 epochs and asserts it lands exactly on the ceiling. Co-Authored-By: Claude <noreply@anthropic.com> * docs(spec): SPEC 24.5/24.6 follow the 0.24.0 buffer contract Records the three-term decomposition, the served-set-is-not-the-hosted-list rule that a rival derivation in the old text used to direct, the step_multiplier delegation and why a closed form loses truncation and the ceiling clamp, and the tagged-unknown shape that makes a zero unrepresentable. Co-Authored-By: Claude <noreply@anthropic.com> * fix(collateral): resolve state paths internally, clearing 3 high CodeQL alerts CodeQL traced DIG_NODE_STATE_DIR through ctx.state_dir into three file operations (rust/path-injection, 3x high). Main carries no alerts of this rule, so these were genuinely new rather than an inherited pattern. The root cause was a second resolver, not a missing guard: the handlers passed ctx.state_dir in, while spend_audit's existing code asks state_dir() itself. Production now goes through CollateralConfig::load/save and EpochRecordStore::in_state_dir, and spends_list uses SpendLog::in_state_dir -- one component knowing where one file lives. The explicit-directory forms remain for tests. Four handlers no longer need &ControlCtx at all, so the parameter is dropped rather than underscored. Co-Authored-By: Claude <noreply@anthropic.com> * feat(collateral): adopt control-interface 0.24.0 and serve control.collateral.buffer 0.24.0 published while this branch was in flight, declaring the buffer as its OWN method rather than a widening of .requirement. Adopted in both dig-node-service and dig-wallet, and the method is now served. The local BufferAdvice/BufferFigures/FundingState/BufferUnknownReason types are DELETED in favour of the contract's CollateralBufferResult, CollateralFundingState and CollateralBufferUnknownReason. Keeping a parallel set would have been a rival definition of a money-path shape, which is how two surfaces come to disagree about a funding warning. Three things the published shape corrected in this lane's version: * a fourth reason, RequirementUnknown. This lane folded a missing requirement into served_set_unknown -- reporting a NETWORK gap as a LOCAL one, which sends the operator to fix the wrong thing. Now distinct, with its own remedy sentence, and a test asserts no buffer reason collides with a census reason. * epoch and protocol_version travel WITH the buffer, so a client never has to pair it with a separately-fetched requirement and hope both describe the same epoch. * Funded, not Adequate; and the lock and the shortfall are DERIVED rather than carried, because the contract publishes the inputs to both and a fourth field could disagree with the three it comes from. The node's own answer is honestly unknown today: it passes None for both the served set and the balance rather than approximating them from the hosted-store list or an arbitrary address. A set that merely resembles the served roots, or a balance for the wrong address, is a plausible wrong number on a money surface -- worse than no number. dign still produces a real figure from operands. Conformance: 5/5, node and contract now agree on all five methods. Co-Authored-By: Claude <noreply@anthropic.com> * feat(cli): dign collateral buffer asks the node, with operands as an override Closes the gap between the CLI-parity list and reality: the list claimed a verb drove control.collateral.buffer and none did. With no operands the verb now calls the node -- it is the authority on its own served set, preference and balance. --roots/--balance remain as an override so a person can get a figure before the node can enumerate its served set. One renderer (render_buffer) serves both paths. Two renderings of one money figure is how an operator comes to trust the wrong one. A payload this build cannot decode is reported as unreadable rather than rendered as a figure. Co-Authored-By: Claude <noreply@anthropic.com> * docs(spec): SPEC 24.5/24.6 follow the served 0.24.0 buffer method Adds the fourth unknown reason (requirement_unknown) and why it is distinct -- a missing requirement is a NETWORK gap while the other three are LOCAL, and reporting one as the other sends the operator to fix the wrong thing. Also records why the buffer is its own method rather than a widening of .requirement, that the funding state is carried rather than re-derived by clients, funded rather than adequate, and that the dign operands are an override rather than a fallback the node applies to itself. Co-Authored-By: Claude <noreply@anthropic.com> * chore(cli): drop committed splice scratch files and restore spliced doc comments The `.tsplice.py` and `.wire2.py` helpers were editing scratch and never belonged in the tree; they are now ignored via the worktree's info/exclude rather than the repo .gitignore, which is for artefacts every clone produces. One of those splices concatenated three doc blocks above `parse_dig_amount`, orphaning `chia_peers_action`'s "listing is the default" security rationale and `collateral_action`'s "an unrecognised preset is REFUSED" rationale onto an unrelated parser. A security comment on the wrong function is worse than a missing one: it reads as reviewed. Each block is back on the function it describes. Co-Authored-By: Claude <noreply@anthropic.com> * fix(cli): refuse an undecodable collateral answer instead of rendering a figure `summarize_collateral_requirement` guarded positively on `state == "unknown"` and let every other payload fall through to a formatter whose fields were each `unwrap_or(0)`. An unrecognised state therefore rendered a REAL epoch number beside a fabricated `0.000 DIG per store` — authoritative-looking rather than degraded, and the exact money lie the unknown branch exists to prevent. An operator acting on it posts nothing and leaves every store root uncollateralised. The trigger is a planned event, not a failure: `CollateralRequirementResult` is `#[serde(tag = "state")]`, so a new variant is additive, and `dign` ships separately from the node — the next minor would put an unrecognised state in front of every already-installed CLI. All three collateral renderers now decode typed and refuse what they cannot decode, matching `summarize_collateral_buffer`, which already did: - `summarize_collateral_requirement` — the gating case. - `collateral_buffer`'s `margin_bp` — an absent margin defaulted to zero understates the recommendation by exactly the cushion the operator chose, flipping `BelowRecommendedBuffer` to `Funded`. - `summarize_margin` — found by sweeping the same class; zero is a legitimate margin, so an absent one substituted for it is indistinguishable from a real answer on the line an operator reads back after `margin set`. Tested on the RENDERED OUTPUT, since the defect was in what a person reads: the fixtures carry the same epoch as the truthful control, so any leaked field fails the assertion. Co-Authored-By: Claude <noreply@anthropic.com> * fix(collateral): an unreadable state directory is not an uncensused epoch Round-2 gate findings D2 and D3, plus the recorded decisions for D1 and D4. D2 -- `EpochRecordStore::get` returned `Absent` for ANY read failure, collapsing the Absent/Unreadable split the method's own doc comment exists to preserve. The consequence is a wrong REMEDY rather than a wrong figure: `Absent` renders as "run the census for this epoch", which writes to the very file that could not be read, so an operator with a broken state directory is sent to a remedy that fails again without ever naming the fault. Only `NotFound` is now `Absent`. D3 -- `dign collateral buffer --roots` rendered an operator-supplied count in a line otherwise identical to the node's own measured answer, so a guess was indistinguishable from a measurement, including in the recommendation derived from it. The line now marks its provenance, which makes the named limitation visible where the figure is read rather than only in the help text. The marker goes when dig-node#387 lands the served-set count. D1 (a well-formed but false stored record scales into a multi-million-DIG recommendation) is recorded as a bounded known rather than fixed: writing that record needs the state directory that also holds the identity key, arbitrary corruption already fails closed, and a plausibility bound derived at the read side would be a rival implementation of the controller. The honest remedy is a protocol_version ceiling check where the census writer's invariants live. D4 (a paired-tier margin outlives `pairing.revoke`) is likewise documented, not fixed: it is a pairing-lifecycle question, and answering it for this one setting would establish by accident a rule the other paired-tier writes do not follow. Co-Authored-By: Claude <noreply@anthropic.com> * docs(spec): SPEC 24.2/24.7 fix the undecodable-answer and operand-provenance rules Three normative statements the round-2 gate showed were absent: - a client MUST NOT render a requirement it cannot decode as a figure, and MUST NOT borrow the `unknown` rendering, which asserts the node named a missing fact that an undecodable answer did not; - `record_unreadable` is decided by the record FILE, not only its contents: a missing file is `not_censused`, an unreadable one is `record_unreadable`; - `collateral buffer` MUST mark a root count that came from `--roots`. Co-Authored-By: Claude <noreply@anthropic.com> * fix(collateral): pin the margin and provenance guards, and stop the config's silent fallback Round-2 re-gate follow-ups. Four items, all in files this PR already owns. 1. The round-2 claim that three tests failed independently was WRONG: reverting the S1 margin decode and the D3 provenance marker together left the suite green, so only the F1 leg was pinned. Both guards are observable ONLY in the rendered string, and both sat behind two `call_control` round trips where no test could reach them. `buffer_outcome` now separates everything after the I/O, and two tests read what a person reads. The margin test asserts the FLIP rather than merely an error: the balance is calibrated at run time to the ZERO-margin recommendation, the exact point at which `unwrap_or(0)` and the truthful decode disagree about whether the operator is funded. The provenance test asserts a PLACEMENT: the same advice through the shared renderer must stay silent, so moving the marker into `render_buffer` -- the obvious simplification -- makes the node's own measured answer claim an operator supplied a count they never typed, and fails. 2. A third orphaned doc block, this one introduced by this PR rather than inherited: a `load_from` description was attached to `load()`, which takes no directory, while the function that actually swallows the error carried no rationale at all. Each block is now on its own function. 3. The D1 rationale rested on a false premise -- it named the identity key as living in the state directory, but `state.rs` holds ONLY the control/auth state and is identity-independent by design. The conclusion survives, because the control token is the strictly greater capability, so the sentence now rests on the token. A correct decision resting on a false premise is how the premise gets reused elsewhere. 4. `CollateralConfig::load_from` carried the exact Absent/Unreadable conflation D2 just fixed, one type above it in the same file. Falling back to the `+1%` default is still right -- refusing to start over a corrupt preference file would take the node down over the one setting whose absence is survivable -- but doing it SILENTLY is not: an operator whose margin has reverted learns about it only from a figure that looks deliberate. A file that exists and cannot be read, or cannot be parsed, is now warned about by path and cause. Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): 0.162.0 — 0.161.0 was taken by #393 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
DO NOT MERGE — round-7 gate not yet run. Draft until the full security leg returns on the new head.
PR-B of the #383 split, now carrying the third tier as well. A coin discovered at a derived CAT
hash or by hint is staged; only coins a lineage proof establishes are written to
coins, fullyattributed.
coinskeeps exactlyorigin/main's semantics.Shape decision (normative): #383 (comment)
Round-6 security verdict: #393 (comment)
Orchestrator ticket: #390
Closes #394.
Refs #380. Refs #382.
On the
Closeskeywords, and the PR-A orderingThe round-6 gate was right that
Closes #380overstated: #380 asserts an end-to-end outcome — areal wallet reporting its real $DIG figure — and on the shipped node nothing calls promote on the
frame path, because
CatAttributoris constructed only undercfg(test)(
sync.rs:2858, inside the#[cfg(test)]module opened atsync.rs:1248) while production passesNone(sync_supervisor.rs:2364). Verified again on this head.Decision: #393 does NOT carry the wiring, and drops to
Refs #380.PR #391 is the wiring half and closes #380 when
it merges. Reasons, in order of weight:
1,331 additions across
sync.rs,sync_supervisor.rs,rpc.rs,db.rs,fallback.rs,singleton.rsandservice.rs— six of the seven files this PR touches. Threading aLineageSourceintoChiaPeerSessiona second time guarantees a conflict and leaves twospellings of the same wiring in flight.
merged
mainthan that a security fix rebases onto an ungated draft. This PR is the smallerdiff and it closes a live primitive; fix(wallet): run CAT attribution in production so a funded wallet can name its $DIG #391 is functionality.
Closes #380while nothing in production ever calls promote". It no longer claims that.What this PR does do about the wiring, because it is cheap and in the diff already: the one
promotion site that DOES run on a shipped node (
refresh_tracked_coins) waslet _ = ...,discarding both the counts and the cause. It now logs. A wallet whose $DIG never appeared previously
produced no evidence anywhere of why.
Round 7 — what changed, per gate finding
GATING 1 (CRITICAL) — the catch-up admitted a fabricated coin as XCH
Fix. The union that widens the peer REQUEST moved from the supervisor call site into
initial_sync_with_authority, and the admission set is built fromaddresseswith derived hashesfiltered out rather than merely not added.
file:linecrates/dig-wallet/src/sage/sync_supervisor.rs:1379-1389crates/dig-wallet/src/sage/sync.rs:1076-1084crates/dig-wallet/src/sage/sync.rs:1058-1074crates/dig-wallet/src/sage/sync.rs:1147addressescrates/dig-wallet/src/sage/sync_supervisor.rs:812-838, 2322A caller cannot widen admission even by passing a derived hash as an address — that is what the
filter buys over an ordering fix, and it is asserted directly.
Mutation transcript.
subscribedrebuilt as the union again(
addresses.iter().copied().chain(derived.hashes()).collect()):Restored by file copy,
git status --porcelainempty afterwards.Fixture note. Every pre-existing catch-up test passed
DerivedCats::default()— the fieldcollapse that made this defect invisible. The supervisor's test double now forwards the real
derived set into the real catch-up instead of defaulting it, and records the set actually put on the
wire, so coverage and admission are separately observable. Without the second recorder, "the coin
was not admitted" is satisfied identically by a catch-up that stopped subscribing derived hashes,
which is #380's starvation.
GATING 2 (CRITICAL) — 64 mojos bought permanent starvation and unbounded reads
Fix. The queue is ordered
attempts ASC, seq ASCand a row is eligible only if it has not beenread within
PROMOTION_RETRY_COOLDOWN(1 h). No row is ever deleted for failing.file:linecrates/dig-wallet/src/sage/db.rs:1706-1762crates/dig-wallet/src/sage/db.rs:1764-1780crates/dig-wallet/src/sage/cat_discovery.rs:296-348crates/dig-wallet/src/sage/cat_discovery.rs:62-83crates/dig-wallet/src/sage/db.rs:409-428, 627-630crates/dig-wallet/src/sage/fallback.rs:519-546I did not implement the never-existed / unavailable classification the gate named, and the reason
is load-bearing. A coinset source answers a null
coin_solutionboth for a spend it has neverheard of and for one it is merely behind on, so a terminal refusal built on that answer converts a
brief outage into permanent erasure of a real coin — the one failure direction this design refuses.
I checked whether the distinction is available:
chia-query's inner coinset client exposes anabsence-aware
get_puzzle_and_solution_opt, but theChiaQueryfacade does not, so lifting itis a chia-query release this PR will not take. So the cost is bounded rather than the cause
classified — starvation removed by the ordering, amplification bounded by the cooldown at
staged rows / cooldowninstead ofcapper pass. Argued in full oncat_discovery.rs:62-83. If a later round disagrees, the classification is additive on top of this,not a replacement for it.
ChiaQueryLineage::parent_spenddid stop manufacturingOk(None)— the value callers read as "thechain has no such spend" — out of every DNS failure, timeout and 500. It now returns
Err, whichcallers already treat as "retry later".
reconstruct_allkeeps per-coin resilience explicitly so oneflaky read cannot abandon a whole attribution pass.
Mutation transcript. Queue reverted to
ORDER BY seq ASCwith no cooldown:deferred: 64reproduces the gate's own probe exactly. The other 8 tests in the module stayed green.On the weakened assertion the gate called out.
promotion_reads_are_bounded_and_never_repeateddid not test "never repeated" — it settled for
second <= capwith a comment conceding thatdeferred rows "ARE re-read". It now asserts
second == 1, and the number is the assertion:overis one past the cap, so exactly one row has never been read while the other 64 are insidetheir cooldown. My first attempt asserted
0and the fixture caught it — zero would mean anever-tried row was skipped, which is a different bug.
GATING 3 — SPEC §18.11a
Rewritten against the diff, not against intent:
SPEC.md:5408-5502.coins"coinsMUST retain exactly the semantics it has"Added because the code now says them: the coverage/admission split as a normative invariant; the
hint tier; the ordering and cooldown; the deliberate non-classification of a never-existing parent;
the claim-before-write rule.
HIGH 4 — the promotion path
See the
Closesdecision above.Refs #380; the live site now logs.FOLDED IN — #394, the third tier
refresh_tracked_coinsupsertedcoin_records_by_hintsstraight intocoinswithasset_id = NULL. A hint is chosen freely by whoever creates the coin, so this was the samefabricated-XCH and send-kill-switch primitive reachable with no peer at all — the coinset oracle
serves it — and live on
maintoday.Routed through the same staging table rather than given a third guard, which is the gate's
judgement and I agree with it: three guards that must agree is what produced this defect at three
tiers.
file:linecrates/dig-wallet/src/sage/cat_discovery.rs:196-260crates/dig-wallet/src/sage/rpc.rs:3105-3145crates/dig-wallet/src/sage/cat_discovery.rs:424-450A first cut of this was wrong and a green suite did not hide it. Staging only coins at a derived
hash for a known asset dropped every non-$DIG hinted CAT — and the hint read is the wallet's only
path to one.
refresh_tracked_coins_feeds_cat_selection_and_build_signfailed, which is exactlywhat it is for. Unpredicted coins are now staged with an empty sentinel and proven from the parent
spend alone: the reconstruction must name this coin id and hint it to an address this wallet
controls. Same strength of claim, reached without a prediction to check against.
Mutation transcript. Router bypassed (
(fetched_rows.clone(), Vec::new())):Non-gating 5, 6, 7 — all fixed, since the files were open
promote_cat_admissionnow DELETEs the staged rowfirst and gates the coin write on
rows_affected() == 1, in one transaction(
db.rs:1808-1860).promote_onegained a three-wayPromotionoutcome so a row that vanishedmid-read is counted deferred, not refused — "not promoted" was covering a terminal verdict and
a non-verdict alike (
cat_discovery.rs:399-460).covered_puzzle_hashes). Closed as a by-product of finding 1: coverage is recordedfrom
addresses, so the ninth puzzle-hash-shaped set no longer exists.SELECT).existing_coin_idsis oneIN (...)query (db.rs:1786-1806).It sits on the peer frame path, where the batch size is the peer's choice.
Blast radius checked
gitnexus has no index for this worktree; building one was not attempted inside the §2.0
ten-minute bound, so the radius was established by
git grepplus reading plus a fullcargo check --workspace --all-targets(clean). Stating the fallback per §2.0 bound 2.initial_sync_with_authoritySyncSession::catch_upaddresses; semantics narrowedstaged_cat_admissionsretry_cutoff; ordering changedpromote_staged_catsowned_p2promote_cat_admissionbool; deletes before writingpromote_onePromotionexisting_coin_idsChiaQueryLineage::parent_spendErron an unsuccessful read instead ofOk(None)reconstruct_allErrinstead of propagatingVerified NOT to leak outside the crate:
grepfor every changed symbol across the workspacereturns zero hits outside
crates/dig-wallet/.dig-node-coreanddig-runtimecompileunchanged.
Deliberately unchanged, each re-verified:
followed_puzzle_hashesandset_watchedare stilladdresses-only;
record_arrivalsstill receivessession.subscribed, which stays addresses-only,so the
sync.rs:957false-payment class remains unreachable by construction; the reorg rollbacktransaction, the staging eviction bound and the
promote_onecoin-id binding check are untouched —all four cleared by the round-6 gate and none of them in this diff's path.
detect_changes()unavailable for the same reason as gitnexus;git diff --stat 44bacb6e..HEADis9 files, all expected.
Gates
dig-wallet0.39.0 to 0.40.0,dig-node0.160.0 to 0.161.0. Under 0.x the minor slot isthe breaking slot, and this round makes three
pubsignatures breaking, so 0.40.0 still covers it.CHANGELOG.mdleft to git-cliff.Dependency freshness is unchanged from round 6 and was cleared there: the
chia-*set is held at0.36.1 because
chia-wallet-sdk0.36.0 is the published tip and moving the set alone would ship thecrate split across two chia lines;
dig-node-control-interfaceis a deliberate pin.What I could NOT prove by execution
Stated plainly, since a report that hides this is worse than one that names it.
initial_sync_with_authoritywith theexact vector the supervisor constructs, and through the supervisor harness — but not over a real
socket. Same one-seam-short position the round-6 gate recorded for its own probe.
948/1/0then{promoted: 8, refused: 0, deferred: 0}thendig_balance 3856455, XCH unchanged) is unchangedin its code path, but I did not re-execute it against a fresh replica copy on this head.
ordering makes the interleaving unobservable rather than tested; I did not build a harness that
drives a rollback into the window.
promote_staged_catsreads the clock internally, so the pinned-NOWtest exercisesstaged_cat_admissionsandrecord_promotion_attemptdirectly. The end-to-end tests observe thecooldown only in the direction real time gives them (a second pass inside the hour).
file:linein this diff by hand; the table above is that check, not a tool's output.Round 9 — the DID ownership guard was vacuous; bound, and the claim bounded
The round-9 gate found the ninth defect on the DID branch of the reconstruction, and its remedy is now
in. Read the scope of the claim before the fix, because both directions of overstatement are wrong.
This exploit is NOT a regression, and this PR is a net security improvement. At the merge-base
singleton.rs:436(reconstruct_coins) callsdb.upsert_didwith no ownership test at all, fed byrpc.rs:3105-3116upserting every hinted coin — the identical forged row lands onmaintoday. Nothingin this PR made a user worse off; what could not ship was a normative
SPEC.mdMUST claiming anownership property the code did not have.
And it is identity fabrication, not balance fabrication. The forged run wrote
coins rows = 0: nobalance, nothing selectable, no spend enabled. A fabricated
didsrow is still a real harm — everyidentity surface reading that table then asserts something false — but it is not the money path, and
conflating the two would be its own kind of dishonesty.
The defect
Did::parse_child(chia-sdk-driver-0.36.0/src/primitives/did.rs:245-259) assignsinfo.p2_puzzle_hash = hintverbatim from the parent spend'sCREATE_COINmemo, which anybody ableto spend any DID may write. The SDK's construction path checks that hint against reality
(
did.rs:80-82); its read path does not, andsingleton.rsadded no compensating check — so thedownstream ownership guard at
cat_discovery.rs:424tested an attacker-written value. The NFT arm wasalways sound:
nft.coinis derived fromnft.info, and the coin-id equality at that arm commits to it.The fix, and where it lives
crates/dig-wallet/src/sage/singleton.rs:169-186— recompute the singleton puzzle hash from the parsedinfo and require it to equal the real child coin's:
puzzle_hash()is curried overp2_puzzle_hash, so a lie about the owner cannot reproduce the on-chaincoin. An honest DID pays nothing: its child is built from that same hash, equal by construction.
It is in the reconstruction, not at the promotion site, and that placement is load-bearing. Fixing
only
promote_singletonwould have left the wider pre-existingreconstruct_coinspath open, whichwrites
didswith no ownership test of its own. Closing that path is a real improvement overmain, notmerely a fix to this branch, and
reconstruct_coins_writes_no_did_row_for_a_forged_hintproves it byexecution rather than by reading — the gate's own stated coverage gap.
Proven by mutation, entering above the narrowing
Binding mutated out (
if true || reconstructed_puzzle_hash == child.puzzle_hash), full suite:Restored:
git status --porcelainempty, 692 passed / 0 failed. The two pre-existing singletontests (
an_owned_nft_and_did_survive_the_point_read_tier,a_singleton_owned_by_a_stranger_is_refused) stayed ok under the mutation — they could not see this,which is exactly why it survived round 8.
Fixture design — the hint is varied INDEPENDENTLY of the true owner, which is the one thing no
previous fixture did.
mint_did_and_nfttransfers withDid::transfer, which derives the hint fromthe destination p2 hash, so hint and owner agree in every row it produces, and a fixture whose two
fields can never disagree cannot see a guard reading the wrong one.
mint_did_hinted_to_a_strangerspends Mallory's DID keeping her p2 while hinting the victim; the simulator accepts the spend, because a
memo is free-form data consensus does not constrain. The end-to-end test carries an honest DID
alongside the forged one, with both p2 hashes in
ours, so a guard that refused every DID — aregression that would break the wallet — fails the test rather than passing it.
Entry is
route_point_read_rows->stage_cat_admissions->promote_staged_cats, neverdb.upsert_coin. Three-for-three in this family.SPEC and docs
SPEC.md§18.11a gains "A reconstructed singleton MUST reproduce its own coin", statingthe binding as its own normative clause and naming the DID read path it exists for. The preceding
"never the hint" clause is now true as written.
nothing the wallet may hold at all (
cat_discovery.rs:498).StagedCatRow's doc comment restored (db.rs):PromotedSingletonhad been insertedbetween the block and the struct, so it wore
StagedCatRow's rustdoc andStagedCatRowhad none.Blast radius checked
reconstruct_parsed's upstream callers:reconstruct(singleton.rs) ->reconstruct_coins/reconstruct_all, andpromote_oneincat_discovery.rs. Both DID consumers are covered by the threenew tests. The change adds a refusal branch on one arm of one function; it cannot widen admission. Suite
692 / 22 / 8,
cargo fmt --all -- --checkexit 0,cargo clippy --workspace --all-targets0 errors.Not fixed, deliberately
is_asset_ownedbindsasset_idraw for thenfts/didsqueries while normalising it forcoins(
db.rs:2963-2979). Pre-existing and outside this diff — recorded here rather than fixed or filed.