Skip to content

feat(collateral): census the current epoch against a real chain source - #401

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
loop/400-census-wiring
Aug 29, 2026
Merged

feat(collateral): census the current epoch against a real chain source#401
MichaelTaylor3d merged 7 commits into
mainfrom
loop/400-census-wiring

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round not yet run. Stays DRAFT until the verdicts return.

Closes #400
Epic: https://github.com/DIG-Network/dig_ecosystem/issues/3173

What changed

A node now censuses the chain and records epoch n. Before this it recorded epoch 1 only —
derivable from nothing — so dign collateral requirement answered unknown / not_censused for the
current epoch correctly and permanently.

The §2.0 already-shipped check, which sized this ticket

chia-query already implements dig_chainsource_interface::ChainSource. ChiaQueryProvider is a
synchronous facade over the async router — chia-query/src/provider_registry/chia_query_provider.rs:62
(ChainSource), :144 (ChainSourceProvider), constructed as
ChiaQueryProvider::new(Arc<ChiaQuery>, tokio::runtime::Handle, ProviderInfo).

So no ChainSource was owed and this became a wiring job rather than an implementation. Writing one
here would have been the rival implementation the ticket warns about.

It also settled which client to wire: dig-node already holds exactly one ChiaQuery, built by
dig-wallet's ChainTransport (crates/dig-wallet/Cargo.toml:112, whose own comment records that a
second =0.5.1 pin was removed to collapse two chia-query lines). This PR takes a view of that one
transport rather than building a second — the two-pools-two-peaks defect dig_ecosystem#2761 removed.

The wiring

file:line what
crates/dig-wallet/src/sage/chain.rsChainTransport::chain_source a ChainSource view of the ONE shared client; CHAIN_SOURCE_PROVIDER_ID names it once
crates/dig-wallet/src/sage/service.rs:104 WalletService.chain exposed, so a consumer outside the wallet need not build a second client
crates/dig-node-service/src/collateral_census.rs (new, 1 file) catch_up — the sequential epoch walk, and CensusStop, the named reasons it writes nothing
crates/dig-node-service/src/server.rsspawn_collateral_census the production runner, on a MIRROR_ROUND_LENGTH_MS timer, gated on enable_chain_sync
crates/dig-node-service/src/server.rsAppState.wallet_chain the shared transport, held for the runner
crates/dig-node-service/src/collateral_sync.rs the collector's sample-size obligation, stated at the refusal (see below)
SPEC.md §24.8a normative: how a record for an epoch after the first is produced

Deps added to dig-node-service: dig-mirror-coin = "0.7" (latest published) and
dig-chainsource-interface = "0.3" (the line chia-query and dig-mirror-coin both compile against,
so all three unify on one crate). chia-protocol = "0.36.1" as a dev-dep for the test double.

No arithmetic is restated. Every figure comes from dig_mirror_coin::census and
EpochRecord::advance.

Blast radius checked

gitnexus is not indexed for this worktree, so the radius was established by grep + direct read and is
stated as such (§2.0 permits the fallback; it is named rather than implied).

  • WalletService — one construction site (service.rs:319), one field added. Every consumer names
    fields explicitly; no destructuring without .. was found.
  • AppState — one construction site (server.rs:561), verified by grep -rn "AppState {"; the
    other two hits are impl blocks.
  • ChainTransport — additive method only; no existing signature touched.
  • collateral_sync.rs — comment only, no code change.
  • adopt / verify / EpochRecordStore — unchanged. The new module is a CALLER of put.
  • Risk: LOW. Nothing existing changed behaviour; the census runner is new, detached, best-effort,
    and gated on enable_chain_sync so no test dials.

The end-to-end evidence — §2.6's bar, on a real machine against mainnet

A node built from this branch, run with an isolated, empty state dir (DIG_NODE_STATE_DIR), no
seeded file:

$ dign collateral requirement          # immediately after start-up
collateral requirement UNKNOWN — this node has not censused the epoch · run the census for this epoch

$ dign collateral requirement          # ~11 minutes later
epoch 104 (protocol v1) — 1.000 DIG per store, before any safety margin
  from 0 advertisement(s) across 0 collateralised owner(s) · multiplier 1.000000x · handicap 4.000 DIG

$ dign --json collateral requirement
{"ok":true,"action":"collateral","service":"dig-node","version":"0.165.0","state":"known",
 "epoch":104,"protocol_version":1,"required_per_store_dig_base_units":1000,"stores":0,"owners":0,
 "multiplier_micros":1000000,"handicap_dig_base_units":4000}

The epoch-104 record this node wrote, from its own census:

{"epoch":104,"protocol_version":1,"census":{"epoch":104,"stores":0,"owners":0,"locked":0},
 "signals":{"participation_micros":1000000,"volume_micros":1000000,"saturation_micros":1000000},
 "band":"inside","multiplier_micros":1000000,"handicap_dig_base_units":4000,
 "base_price_dig_base_units":5000,"required_per_store_dig_base_units":1000,
 "census_height":9196171,"provenance":{"kind":"censused"}}

provenance: censused, census_height: 9196171 — a real mainnet height, not a fixture. The node
walked epochs 2→104 in one pass, each at its own derived census height (epoch 2 at 5,906,783 … epoch
104 at 9,196,171).

The figure is different from the one this family previously showed, and that is the point

The epic's earlier evidence — 3.780 DIG per store, from 17 advertisement(s) across 820 collateralised
owner(s)
— rendered a hand-placed record. A real census of mainnet finds zero collateralised
stores and zero owners
, so the requirement sits at the floor, 1.000 DIG. The arithmetic was always
right; the inputs were invented. They are now measured.

Tests

cargo test -p dig-node-service --lib collateral49 passed, 0 failed. Four are new:

  • an_unreachable_chain_records_nothing_and_names_the_reason — asserts the STORE as well as the
    report, because a stop that still wrote a record would satisfy a report-only assertion.
  • a_store_already_at_the_target_reads_no_chain — asserted against a source that fails every read, so
    a single chain touch would turn stopped into Some.
  • a_prior_record_from_an_unimplemented_ruleset_is_refused — the protocol-version ceiling at the
    census boundary; the prior epoch is 2 rather than 1 so the refusal cannot be confused with genesis
    handling, and the target is 4 so a walk ignoring the ceiling would have a further epoch to attempt.
  • epoch_starts_follow_the_published_schedule — pinned from BOTH sides: the absolute genesis instant
    and the spacing.

cargo clippy -p dig-node-service --lib --all-targets — clean.

The plan.sample_size fold-in — what was actually possible

PR #398's note asks for the collected set to be capped at plan.sample_size at the collection site.
There is no collection site. dig.getCollateralEpoch is served (server.rs:1095) and never
requested; adopt is called only from its own tests. Truncating the responses inside adopt would be
wrong for the same reason PopulationExceeded refuses rather than trims — it would keep a prefix of a
set the attacker contributed to.

So what landed is the obligation, stated at the refusal it protects (collateral_sync.rs), naming
explicitly that no such collector exists yet. The cap itself belongs with the requesting half and is
not implemented here.
Filed as a realization rather than claimed as done.

Version

0.166.0, one above main at the time of the bump (0.165.0, 22f9f5a). Minor: new capability,
nothing removed or renamed.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, NOT THE VERDICT

Auditing head b29266cd651be7c438f7556d96133e53952407de, merge-base 22f9f5a8f2f47ac256d69f4fbdd24c47e6981d56 (= origin/main tip at read time).

Established so far

1. The ChainSource claim holds; no second client is created. Verified from the lock rather than the prose:
chia-query 0.19.0, dig-mirror-coin 0.7.0 and dig-node-service 0.166.0 all resolve to dig-chainsource-interface 0.3.2 — one line, so the impl genuinely applies. (0.1.0 is also in the lock, reached only by dig-merkle 0.4.5 and dig-store 0.5.1; pre-existing, untouched by this PR.) ChainTransport::chain_source calls self.shared_client() and hands the resulting Arc<ChiaQuery> to ChiaQueryProvider::new — a view, not a construction. No ChiaQuery::new / second pool appears anywhere in the diff.

2. The provider's reads are real, not stubs. chia-query-0.19.0/src/provider_registry/chia_query_provider.rs:73-87coin_records_by_puzzle_hash calls inner.get_coin_records_by_puzzle_hash(&hash, None, None, include_spent) through run_blocking. So the read path is wired to the router, and a 0 from it is not a hardcoded empty vec.

FINDING 1 (open, severity: HIGH on a money path — observability, not arithmetic)

The PR discards the exact field that distinguishes "the network is empty" from "the instrument is broken".

dig_mirror_coin::MirrorCensus carries three things: census(), height(), examined() and excluded() -> Exclusions. crates/dig-node-service/src/collateral_census.rs:270-273 consumes only the first two:

let record = prior.advance(counted.census())...
let stored = StoredRecord::censused(record, counted.height());

examined and Exclusions are read nowhere in dig-node-service (grep: zero hits). They are not stored, not logged, not surfaced on control.collateral.requirement.

The crate author wrote those fields for precisely this reason, verbatim at dig-mirror-coin-0.7.0/src/census.rs:82-85:

"Exclusions are not errors and not noise. A census that counts nothing is indistinguishable from a census whose every candidate failed one rule unless the failures are reported, and those two situations call for very different responses from an operator."

Concrete consequence. The PR's own mainnet run reports stores: 0, owners: 0 and therefore the floor 1.000 DIG. With examined discarded, an operator — and this audit — cannot distinguish:

  • examined == 0 — the source returned no coins at the mirror puzzle hash (the network is genuinely empty), from
  • examined == N, excluded.foreign_puzzle == N — the source answered with records at a different puzzle hash, i.e. a wrong-hash or misrouted read, from
  • examined == N, excluded.unreadable == N — every creating spend was unavailable, i.e. a partially-degraded source.

All three produce the identical stores: 0 and the identical floor figure. The first is a measurement; the second and third are a broken instrument that has been rendered as a reassuring answer. This is the "unknown rendered as a reassuring answer" shape the module's own header docs commit to avoiding — the module is scrupulous about it for CensusStop, and then drops it for the case where the census succeeded and counted nothing.

Note this is not an arithmetic defect: the figure advance produces is correct given the census, and a truly-empty network genuinely warrants the floor. The defect is that the PR removes the only evidence that would tell the two apart, on the path that decides how much collateral a node posts.

Still to come: whether the zero can be made to move; the epoch-walk failure modes; the Contradiction wedge; SPEC §24.8a clause-by-clause; the multi-thread runtime requirement.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, NOT THE VERDICT (2/n)

Head b29266cd651be7c438f7556d96133e53952407de.

FINDING 2 — GATING. The census reads bypass NC-12 peer corroboration, and the diff makes its own file's claim false

ChainTransport has TWO read paths, and the difference between them is deliberate and documented. crates/dig-wallet/src/sage/chain.rs:336-342, pre-existing text, unchanged by this PR:

"NC-12 asks for agreement across several concurrently-queried untrusted peers; a single third party is the shape it exists to prevent. A transport with no peer reads — a bare one built by a test — still falls through to the router. That path is the oracle-first one, and it is documented as such rather than silently retained: nothing in production takes it."

Production attaches the corroborated path at crates/dig-wallet/src/sage/service.rs:183:

let chain = Arc::new(ChainTransport::new().with_peer_reads(db.clone()));

and ChainTransport routes through it at chain.rs:344 (peak_height), :506 (coin_record_by_id), :515 (coin_spend).

The new chain_source takes the other path. chain.rs:274-295:

let client = self.shared_client().await?;
Ok(chia_query::provider_registry::ChiaQueryProvider::new(client, handle, ProviderInfo { ... trustless: false }))

shared_client() is the raw Arc<ChiaQuery>. peer_reads is never consulted. Every read the census performs — peak_height, coin_records_by_puzzle_hash, and every coin_spend that qualify needs to authenticate a candidate — is answered by whichever single tier wins the router's race, with no agreement step. The PR's own ProviderInfo says so: trustless: false, "Answers are believed because the tier that produced them was believed, not because they carry a proof this node checked."

So the sentence "nothing in production takes it" is false in the same commit that leaves it in place — this PR is what puts production on that path, on the money path, for the figure the epic says a node posts as collateral. Two of the three reads have a corroborated equivalent sitting in the same struct and are not using it. (coin_records_by_puzzle_hash genuinely has none — peer_reads.rs offers only coin_record_by_id, coin_spend, peak_height — which is a real gap but a different one.)

Concrete exploit A — defeat the reorg guard with an inflated peak

dig-mirror-coin-0.7.0/src/census.rs:335-346 gates on finality using the source's own peak:

let final_at = u64::from(at.height) + CENSUS_FINALITY_DEPTH_BLOCKS;   // = 32
if u64::from(peak) < final_at { return Ok(CensusOutcome::Pending { .. }) }

The crate states the reason: "A census taken at the tip is reorg-sensitive, and this is a money path."

State → action → impact: the node is censusing epoch n; the winning tier reports a peak inflated by ≥32 blocks (a stale/forked coinset view, a MITM'd peer session, or simply the losing race of two tiers with different peaks). census believes it, skips Pending, and takes the census at a tip-adjacent, reorg-sensitive height. The reorg protection that exists specifically because this is money is bypassed by one un-corroborated integer. ChainTransport::peak_height would have required NC-12 agreement for exactly this; the census does not call it.

Concrete exploit B — omission lowers the requirement, and it is PERMANENT

The census crate records that omission is free: "a hostile source can already delete any coin for free by omission", and "a pruned source that silently omits spends would otherwise report a smaller network, which is the direction that lowers the requirement for everyone."

State → action → impact:

  1. During this node's census window the answering tier returns a subset of the coins at mirror_coin_puzzle_hash(), or fails to return some creating spends (those land in Exclusions::unreadable and the census proceeds — only an Err aborts it).
  2. census returns Final with a smaller stores/owners/locked. advance reads an under-saturated network and derives a lower requirement.
  3. collateral_census.rs:274 writes StoredRecord::censused(record, counted.height()). putWritten.
  4. It can never be corrected. catch_up starts at highest_recorded + 1 (collateral_census.rs:167,207-213), so a recorded epoch is never re-censused. And EpochRecordStore::put (collateral.rs:460-470) only lets AdoptedFromPeersCensused supersede; a correct record arriving from peers is stamped AdoptedFromPeers by collateral_sync::adopt and so lands on PutOutcome::Conflict, held record kept.
  5. Every later epoch is derived from the corrupted one (prior.advance(...)), so the node's entire future collateral schedule is off the network's — silently, with no Conflict ever raised, because nothing re-computes it.

A transient degradation lasting one census window produces a permanent divergence. The node then posts collateral against a figure the rest of the network disagrees with; its own mirror coins fail C5 on every other node's census, so it is invisible to the network while believing it complied.

Combined with FINDING 1 (examined and Exclusions discarded), step 2 leaves no evidence at all on the node: the record shows stores: 0, and nothing says whether the source returned zero coins or returned N and excluded all N.

Not marked down

Verified and clean: the single-client claim holds (shared_client() is a view; shared_client stays pub(crate), so only a ChainSource view escapes the crate). The lock unifies chia-query 0.19.0, dig-mirror-coin 0.7.0 and dig-node-service 0.166.0 on dig-chainsource-interface 0.3.2. cargo test -p dig-node-service --lib compiles clean at this head.

Still to come: whether the zero can be made to move; the walk's wedge behaviour; SPEC §24.8a clause-by-clause; the multi-thread runtime requirement.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, NOT THE VERDICT (3/n)

Head b29266cd651be7c438f7556d96133e53952407de.

THE ZERO MOVES. The instrument is connected.

This was the primary question, and it is now answered by execution rather than by reading. I built a probe in my own detached worktree (C:\tmp\worktrees\sec401, cut at the head SHA; nothing written to any shared checkout) that drives the PR's own collateral_census::catch_up against a chain that must produce a count.

The fixture is not hand-written: dig-mirror-coin 0.7.0 ships its integration-test support in the published crate (tests/support/mod.rs), so the mirror coin in this probe is created by a genuine CAT spend whose puzzle is executed through CLVM — the same execution MirrorCoin::read_parent_outputs performs. A hand-built CoinRecord could not have exercised the authentication path at all.

Three tests, all green:

running 3 tests
test an_empty_chain_records_a_zero_census ... ok
test a_record_at_a_foreign_puzzle_hash_is_not_counted ... ok
test a_mirror_coin_on_chain_moves_the_census_and_the_requirement_off_the_empty_answer ... ok

test result: ok. 3 passed; 0 failed

What each establishes:

  1. CONTROL — an empty chain reproduces the mainnet result exactly: catch_up records the epoch with (stores, owners, locked) == (0, 0, 0). Without this the probe below could not claim a number moved, only that it had a value.
  2. THE PROBE — one qualifying mirror coin on chain produces (stores, owners, locked) == (1, 1, 1_000_000), and the required_per_store_dig_base_units the record carries is assert_ne! different from the empty-chain figure. So the money figure is genuinely a function of what is on chain, not a constant wearing a census's clothes.
  3. THE QUERY IS NOT TOO BROAD — the double volunteers a record at a foreign puzzle hash for every query (a source answering with records it was not asked about). It is not counted; stores stays 1. So the census would not over-count once real coins exist.

Additionally the double records which puzzle hash it was asked for, and the probe asserts that set contains dig_mirror_coin::mirror_coin_puzzle_hash(). The census asks for the right hash.

Verdict on the primary question

stores: 0 on mainnet is a MEASUREMENT, not a broken instrument — for the in-process half of the chain, which is the half this PR owns. Corroborated independently: nothing has ever minted a mirror coin in production (the creation path, dig-node#377, is still open as draft PR #379, and dig-mirror-coin only reached 0.7.0 today), so an empty chain is the expected answer.

What I did NOT prove, stated plainly

The probe substitutes a ChainSource double for ChiaQueryProvider. It therefore proves the walk, the census, the qualification rules and the record write; it does not execute ChiaQueryProvider against live mainnet. That last link is verified by reading only — chia-query-0.19.0/src/provider_registry/chia_query_provider.rs:73-87 forwards to inner.get_coin_records_by_puzzle_hash(&hash, None, None, include_spent), which is a real router call and not a stub. The PR's own mainnet run additionally proves peak_height and block_timestamp answer for real, since census_height returned a concrete height (9196171) rather than None or an error.

census_height: 9196171 is also consistent by construction: census_height returns the first transaction block at or after the epoch start, and the run showed it ~24k blocks behind the peak, which is the expected distance into a seven-day epoch.

A mutation run (forcing the coin read to return empty) is in flight to confirm these assertions are load-bearing rather than passing for an unrelated reason; result to follow.

SPEC.md §24.8a — checked clause by clause, one clause is FALSE

The section is a clean 39-line pure addition, no deletions, no CRLF flip (0 CR bytes on both sides of the diff).

Verified TRUE against this diff: census inputs come from dig_mirror_coin::census at the census_height-derived height (collateral_census.rs:224-256); the record is derived by EpochRecord::advance with no restatement (:266); reads go through ChainSource; the walk is sequential and never skips (:167); the protocol-version ceiling is applied at this boundary (prior_record, :281-297); a stop writes nothing; and re-attempting performs no chain read when already current — that last one is asserted by the PR's own test against a source that fails every read.

FALSE clause:

"it takes a ChainSource view of the one transport that already serves its wallet reads, so a node holds one peer pool with one notion of the peak."

One pool: true. One notion of the peak: false, in this same diff. The wallet's peak is ChainTransport::peak_height (chain.rs:343-352), which answers from peer_reads — NC-12 agreement across concurrently-queried peers. The census's peak is ChiaQueryProvider::peak_height, which answers from the raw router with no agreement step. Same pool, two derivations, two possible answers — and the census's is the one that gates the reorg-finality check. This is FINDING 2 restated as a normative claim, which is worse than the code defect alone: a future reader will rely on the sentence.

Incomplete: the stop table lists six rows for ten CensusStop variants. Collapsing the three PriorEpoch* variants is fine, but Arithmetic and Store appear nowhere, while the prose promises "Each refusal is reported with its own reason and its own remedy". Minor, documentation-only.

Still to come: the Contradiction wedge, the multi-thread runtime requirement, the cold-start cost as a denial vector, the sample-size-cap judgement.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED

Audited head: b29266cd651be7c438f7556d96133e53952407de (resolved from gh pr view 401 --json headRefOid; re-checked at the end of the audit and unchanged). Merge-base 22f9f5a8f2f47ac256d69f4fbdd24c47e6981d56, still origin/main tip at verdict time.

Two GATING findings, both small, both about the diff describing a trust posture the code does not have. Four non-gating, one of which must land before dig-node#377 puts real mirror coins on chain.


THE PRIMARY QUESTION IS ANSWERED: the zero is a MEASUREMENT

I made it move. Probe built in my own detached worktree (C:\tmp\worktrees\sec401, cut at the head SHA); no shared checkout was written or mutated. It drives the catch_up under audit, using the tests/support/mod.rs that dig-mirror-coin 0.7.0 ships in its published crate, so the mirror coin is created by a genuine CAT spend executed through CLVM -- the same execution MirrorCoin::read_parent_outputs performs.

test an_empty_chain_records_a_zero_census ... ok
test a_record_at_a_foreign_puzzle_hash_is_not_counted ... ok
test a_mirror_coin_on_chain_moves_the_census_and_the_requirement_off_the_empty_answer ... ok
test result: ok. 3 passed; 0 failed
  • Control: an empty chain reproduces mainnet exactly -- (stores, owners, locked) == (0,0,0).
  • The probe: one qualifying mirror coin gives (1, 1, 1_000_000), and the recorded required_per_store_dig_base_units is assert_ne! different from the empty-chain figure. The money number is a function of the chain, not a constant.
  • Not too broad: a record volunteered at a foreign puzzle hash is not counted; stores stays 1.
  • The double records which hash it was asked for; the probe asserts that set contains mirror_coin_puzzle_hash().

Not vacuous -- proved by mutation. Forcing coin_records_by_puzzle_hash to return empty turns both discriminating tests RED with the intended messages (a qualifying mirror coin was not counted -- the zero did NOT move, left (0,0,0) right (1,1,1000000)). The control correctly stays green, which is the whole point: an empty read and an empty chain are indistinguishable, and that is why the probe was necessary.

Corroborated independently: nothing has ever minted a mirror coin in production (#377 is still draft PR #379), so an empty chain is the expected answer.


GATING 1 -- the diff asserts a trust posture the code does not have, in two places

1a. SPEC.md §24.8a, the census-transport clause -- FALSE as written

"it takes a ChainSource view of the one transport that already serves its wallet reads, so a node holds one peer pool with one notion of the peak."

One pool: true. One notion of the peak: false, and there are three, not two:

consumer how it gets the peak
wallet chain.rs:343-352 -> peer_reads.peak_height() -> NC-12 agreement across DialedPeerSample (peer_reads/dialed.rs:204), a sample of independently discovered full nodes, settled by quorum::settled_peak
census (normal) ChiaQueryProvider::peak_height -> peak_height_opt -> get_blockchain_state, which asks api.coinset.org FIRST (coinset_fallback_enabled: true -- dig-node uses ChiaQueryConfig::default() at sources.rs:116-118, and chia-query-0.19.0/src/lib.rs:124 defaults it on)
census (coinset down) the router peer-tracked NewPeakWallet value, with no agreement step

The census peak is the one that gates the reorg-finality check (census.rs:335-346, CENSUS_FINALITY_DEPTH_BLOCKS = 32), which dig-mirror-coin justifies with "this is a money path."

Correct the sentence rather than delete it. The honest version names which source each half trusts and what happens when the preferred one is unavailable -- not "one notion of the peak".

1b. crates/dig-wallet/src/sage/chain.rs:283-287 -- the ProviderKind comment mis-describes the router

// `Custom` rather than `PublicOracle` or `LocalNode`, because the router behind it
// is neither: it races this node's own dialled Chia peers against the coinset.org
// tier, and which one answered is not knowable from here.

It is not a race. The router asks coinset first and consults peers only on failure. This repo already knows that, and already knows why it matters -- crates/dig-wallet/src/sage/sources.rs:124-135:

"A ChiaQuery built with coinset_fallback_enabled asks api.coinset.org first and consults its peers only when that fails, so such a fabric IS a view of coinset.org -- however many peers it holds. Registering it as its own group made a 2-of-2 independent-group custody quorum satisfiable by one HTTPS endpoint: measured on a max_peers: 0 client, which holds no peers at all, the custody view returned a peak."

independence_group_for (sources.rs:137-143) therefore puts such a fabric in the ORACLE group. ChiaQueryProvider even exposes independence_group() (chia_query_provider.rs:59-61) for exactly this, and the PR does not use it.

No quorum is corrupted today, because this provider is not registered in a ProviderRegistry -- which is the only reason 1b is a documentation defect rather than a repeat of the measured incident. But it is the same wrong characterisation that produced that incident, written fresh onto a money path, and the next lane that registers this provider inherits it.


GATING 2 -- crates/dig-node-service/src/collateral_census.rs:266-274: the zero has no recoverable provenance

let record = prior.advance(counted.census())...
let stored = StoredRecord::censused(record, counted.height());

dig_mirror_coin::MirrorCensus also carries examined() and excluded() -> Exclusions. Neither is read anywhere in dig-node-service (grep: zero hits) -- not stored, not logged, not on control.collateral.requirement. The crate author wrote them for precisely this, census.rs:82-85:

"A census that counts nothing is indistinguishable from a census whose every candidate failed one rule unless the failures are reported, and those two situations call for very different responses from an operator."

Concrete consequence. These three produce an identical stores: 0 and an identical floor figure, on the path that decides how much collateral a node posts:

  • examined == 0 -- the network is genuinely empty (what is true today);
  • examined == N, excluded.foreign_puzzle == N -- the source answered at the wrong puzzle hash;
  • examined == N, excluded.unreadable == N -- every creating spend was unavailable, so a degraded source reported a smaller network.

The second and third are a broken instrument rendered as a reassuring answer. This is the exact shape six instances of which were fixed in this family this week, and this gate had to write a probe to answer a question that five extra fields in the log line would answer on any running node. The fix is small: log examined and Exclusions beside the recorded epoch, and ideally carry examined on the record.

The module is scrupulous about this for every CensusStop, and then drops it for the one case where the census succeeded and counted nothing.


NON-GATING -- follow-up tickets

N1 (HIGH, must land before #377 puts real coins on chain). The census reads bypass the NC-12 corroboration sitting in the same struct. ChainTransport::coin_record_by_id (chain.rs:506) and coin_spend (:515) go through peer_reads; the census equivalents go straight to the router. chain.rs:336-342 says of that path, in text this PR leaves in place: "a single third party is the shape it exists to prevent... nothing in production takes it." This PR is what puts production on it.

Exploit, once coins exist: the answering tier returns a subset of coins at the mirror puzzle hash, or withholds creating spends (those land in Exclusions::unreadable and the census proceeds -- only an Err aborts). The census reports a smaller network; advance reads under-saturation and derives a lower requirement. collateral_census.rs:274 writes it. It is then permanent: catch_up starts at highest_recorded + 1 (:167) so a recorded epoch is never re-censused, and EpochRecordStore::put (collateral.rs:460-470) only lets AdoptedFromPeers -> Censused supersede, so a correct record from peers lands on Conflict and the held one stands. Every later epoch derives from the corrupted one. A transient degradation produces a permanent divergence, silently -- and with GATING 2 unfixed, with no evidence on the node. coin_records_by_puzzle_hash has no corroborated equivalent at all (peer_reads.rs offers only the three above), which is why this is a design job rather than a line change, and why it is not gating under §2.6.

N2 (MEDIUM, availability). One unreadable line permanently wedges the walk and burns a full census every 10 minutes forever. records() skips unparseable lines (collateral.rs:564-575) while get() reports them Unreadable (:524-549). So a truncated line for epoch n (crash mid-append, ENOSPC) makes highest_recorded return n-1; the walk recomputes n -- a full puzzle-hash population read plus spend executions -- and then put returns Err(InvalidData) -> CensusStop::Store. Repeats every MIRROR_ROUND_LENGTH_MS (10 min) indefinitely, never advancing.

N3 (MEDIUM, cost asymmetry). The timer has no backoff and the per-retry work is attacker-sized. The population read is include_spent: true, so it never shrinks, and anyone can add candidates for dust -- the crate states an ordinary XCH CREATE_COIN at the mirror hash costs mojos while the amount filter compares raw u64 against a requirement currently at the 1-DIG floor. An attacker who parks the distinct-creating-spend count just under MAX_CANDIDATES (10_000) makes every node perform up to 10k spend fetches and CLVM executions every 10 minutes, forever, for a one-time cost. The MAX_CANDIDATES limiter itself is correctly placed -- it runs before the expensive pass -- but nothing bounds the retry cadence or the cumulative cost. Sharper than, and composing with, the cold-start item the lane already declared.

N4 (LOW, docs/nits). The census-stopped log message in server.rs carries a wrapped-literal artifact: "...current epoch; no record was written". And the §24.8a stop table lists six rows while CensusStop has ten variants -- collapsing the three PriorEpoch* is fine, but Arithmetic and Store appear nowhere, against prose promising "Each refusal is reported with its own reason and its own remedy."


Verified clean -- not marked down

  • Single client, transitively. chain_source calls shared_client() (a view); shared_client stays pub(crate), so only a ChainSource escapes the crate. sources.rs:194-202 is the sole production ChiaQuery::new, guarded by a sole_owner_tests tripwire that is green. dig_ecosystem#2761 is not reopened.
  • One dig-chainsource-interface line where it matters. Lock: chia-query 0.19.0, dig-mirror-coin 0.7.0 and dig-node-service 0.166.0 all resolve to 0.3.2, so the impl genuinely applies. (0.1.0 also exists, reached only by dig-merkle 0.4.5 / dig-store 0.5.1 -- pre-existing, untouched.)
  • Multi-thread runtime requirement satisfied. Production entrypoints are new_multi_thread (entrypoint.rs:1303, win_service.rs:129); every new_current_thread in service_control.rs is inside #[test], and control_client.rs:47 is the CLI, which never spawns the census. Handle::current() is taken inside the spawned task, and each pass is wrapped in spawn_blocking as run_blocking requires. run_blocking also fails closed rather than deadlocking.
  • No stop becomes a figure. CensusOutcome is not #[non_exhaustive], so the record_one match cannot silently fall through on a future variant -- a new one is a compile error. highest_recorded uses unwrap_or(GENESIS_EPOCH), not unwrap_or(0). A non-Final current_epoch_now() skips the pass. Every CensusStop path writes nothing; absence surfaces as unknown with its reason.
  • The walk creates no gaps. record_one returning Err returns from the loop immediately, so epochs are strictly sequential and a mid-walk failure leaves no hole. No self-Contradiction is reachable: within a pass epochs are distinct, and across passes highest_recorded is re-read. The only wedge is N2, which surfaces as Store, not Contradiction.
  • No arithmetic restated. Every figure comes from dig_mirror_coin::census / EpochRecord::advance; the floor clamp lives inside required_per_store. Confirmed by reading record.rs:111-174.
  • No log injection. Attacker-influenced text reaches the log only as reason = ?stop, i.e. Debug, which escapes newlines.
  • Sample-size cap: the lane call is correct, do not change it. adopt receives responses it did not choose; truncating there keeps a prefix of an attacker-contributed set, which is exactly what PopulationExceeded refuses. The cap belongs at the collection site, and there is none -- I confirmed adopt has no production caller and dig.getCollateralEpoch is served (server.rs:1095) but never requested. Stating the obligation at the refusal is right; file it as a ticket as well, because a comment is not a gate for the future wiring lane.
  • SPEC diff hygiene. Pure 39-line addition, zero deletions, no CRLF flip (0 CR bytes on both sides).
  • Blast radius re-derived, not accepted. WalletService has exactly one struct-literal site (service.rs:328) and AppState one (server.rs:568); both updated. wallet_chain appears only at server.rs:109/590/2127. dig-wallet/src/lib.rs:110 is a different, private AppState. ChainTransport gained one additive method. The workspace dig-wallet is a path dep and not the crates.io dig-wallet, so the new pub field is not an external semver break.
  • Tests and preconditions at this SHA. cargo test -p dig-node-service --lib -> 510 passed, 0 failed; --lib collateral -> 49 passed, 0 failed, matching the lane. check-merge-preconditions.sh --allow-draft, run unpiped with rc captured before reading the output: EXIT=0, all five required contexts asserted by name as SUCCESS, 0 unresolved threads, mergeStateStatus=CLEAN.

Coverage I did NOT achieve -- stated plainly

  1. ChiaQueryProvider was never executed against live mainnet by me. The probe substitutes a ChainSource double, so it proves the walk, the census, the qualification rules and the record write. The last link is verified by reading only: chia_query_provider.rs:73-87 forwards to a real router call, not a stub. The mainnet run in the PR corroborates peak_height and block_timestamp, since census_height returned a concrete 9196171 rather than None or an error.
  2. census_height: 9196171 was checked for consistency, not recomputed. It is the first transaction block at or after the epoch start by construction, and ~24k blocks behind the peak is the expected distance into a seven-day epoch. I did not independently re-derive it against mainnet block timestamps.
  3. The ~11-minute cold start was not measured. N3 reasons about its shape, not its wall clock.
  4. dig-mirror-coin 0.7.0 census internals were read, not gated. It is a published crate with its own prior gate; I read enough to judge the wiring and to build the probe.
  5. No clippy/fmt run of my own -- CI covers both and both are green by name.
  6. I did not exercise the Contradiction or PopulationTooLarge stops end to end; both are reasoned from the code and from the dig-mirror-coin suite.

Disclosure

All probe work was done in C:\tmp\worktrees\sec401, a worktree I cut detached at b29266c and removed afterwards. dn-400 and da-303-308 were not touched. No shared checkout was mutated; no file in this PR was edited.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Disclosure addendum (loop-security, precision on the previous verdict)

My verdict says "no shared checkout was mutated." That is true of every working tree, file and commit, and I want to be exact about the two ref-level operations I did run in the shared dig-node checkout, since neither is nothing:

  1. git fetch origin --prune -- pruned stale remote-tracking refs for branches already deleted on the remote (origin/loop/382-attributor-wiring, origin/loop/385-collateral-control, origin/loop/387-epoch-record, origin/loop/390-cat-staging, origin/loop/dig-logging-0.2.0). This removes only remote-tracking pointers to branches the remote no longer has; it touches no local branch, no stash and no working tree.
  2. git fetch origin pull/401/head:pr401-audit-b29266c, then git branch -D pr401-audit-b29266c -- a temporary local ref at the PR head, created to compute the merge-base and now deleted.

Verified after cleanup, in the shared checkout: HEAD 4efa57fceb6b242e9eb7e3faaa8a67d50a2fdf62 (unchanged), no pr401-audit* branch, no new stash (the three present are pre-existing and dated), and the only untracked entry is a pre-existing .claude/loop/ I never wrote to. My worktree C:\tmp\worktrees\sec401 is removed and pruned; dn-400 and da-303-308 were never touched.

No file in this PR was read through a shared working tree -- everything came from git objects (git show <sha>:<path>, git diff <base>..<head>) or from my own detached worktree.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Round 2 — both gating findings fixed, plus N2 and N4

Head dcb6b8e. Still DRAFT.

GATING 1 — the trust posture, corrected in three places (one more than reported)

1a. SPEC.md §24.8a. "one peer pool with one notion of the peak" is replaced. The section now
keeps the true half (one pool) and states the false half honestly: the wallet's peak is NC-12-agreed
across dialled full nodes; the census's is read through the ChainSource, whose router asks
api.coinset.org FIRST and falls back to a peer-tracked value carried with no agreement step
and it is that peak the finality refusal is measured against. A new clause requires a census provider
to be classified by what it can REACH rather than by its type, and forbids describing the census's
peak as corroborated.

Verified independently before writing it, not taken from the gate:

  • chia-query-0.19.0/src/router.rs:844-871get_blockchain_state tries coinset first, then
    self.peer.peak_height() with no quorum.
  • chia-query-0.19.0/src/lib.rs:124coinset_fallback_enabled: true by default.
  • crates/dig-wallet/src/sage/sources.rs:116-118 — dig-node builds from ChiaQueryConfig::default().

1b. crates/dig-wallet/src/sage/chain.rs:283-300. The "race" comment is gone. It now says the
router asks coinset first and consults peers only on failure, points at sources.rs:137-143's
reach-derived grouping and the measured incident behind it, and states that anything registering this
provider MUST take its group from ChiaQueryProvider::independence_group() rather than from this
kind.

1c — not in the verdict, found while fixing 1a/1b. chain.rs's peak_height doc said the
oracle-first path is one "nothing in production takes". This PR is what puts production on it.
Corrected to "no WALLET read in production takes it", naming the census as the one caller that does
and why its peak is uncorroborated. Leaving that sentence while repairing two others in the same file
is exactly the failure this round exists to prevent.

Clause-by-clause recheck of §24.8a, each against a file:line in this diff:

clause pinned to
inputs from dig_mirror_coin::census, height from census_height, derive with advance, restate neither collateral_census.rscensus_height call, census call, prior.advance(counted.census())
reads served through a ChainSource; no second connection chain.rs:275-281shared_client() handed to ChiaQueryProvider::new; no ChiaQuery::new in the diff
the census's peak is not NC-12-agreed chia_query_provider.rs:135-137peak_height_optrouter.rs:844
finality refusal measured against that peak CensusStop::BehindFinalityDepth, collateral_census.rs:71-82
provider classified by what it can reach; not counted as independent sources.rs:137-143; asserted in chain.rs:283-300
walk sequential, no skipping, version ceiling catch_up's (highest + 1)..=target_epoch; prior_record's PriorEpochUninterpretable
a stop writes nothing record_one returns Err before every store.put
every stop reason has its own remedy table now covers all eleven CensusStop variants
a census that counted nothing says what it examined CensusObservation; logged by server.rs's log_census_observation
re-attempt cheap in the steady state a_store_already_at_the_target_reads_no_chain asserts reads() == 0

Two clauses I wrote in the first commit were wrong and corrected in the second: the Arithmetic
remedy named a ruleset problem when advance refuses for three different reasons
(dig-mirror-collateral-0.2.0/src/record.rs:99-105), and the finality refusal was described as
appearing "above" its own table. Both were caught by re-reading, which is the only reason they are
not in this branch.

GATING 2 — the zero now has provenance

collateral_census.rs:266-274 discarded examined and Exclusions. The walk now carries
CensusObservation { epoch, census_height, stores, examined, excluded } per recorded epoch, and
server.rs's log_census_observation writes all five plus every exclusion counter.

Real output, from the_recorded_epoch_is_logged_with_what_the_census_examined, on a census of a
source answering at the wrong puzzle hash:

 INFO dig_node_service::server: censused the collateral network and recorded the epoch
 epoch=2 census_height=0 stores=0 examined=1 excluded_foreign_puzzle=1 excluded_unreadable=0
 excluded_unattributed=0 excluded_wrong_epoch=0 excluded_not_yet_created=0
 excluded_spent_by_census_height=0 excluded_undated=0 excluded_block_reward=0
 excluded_below_requirement_unauthenticated=0 excluded_superseded=0

stores=0 beside examined=1, excluded_foreign_puzzle=1 — the reading the gate needed a probe to
obtain. log_census_observation is a free function precisely so the line an operator reads can be
asserted, rather than living only inside the timer loop where nothing can reach it.

N2 — fixed, and it was the cheaper half that mattered

record_one now checks store.get(epoch) for Unreadable before any chain read and stops with a
new CensusStop::EpochLineUnreadable. That turns a silent forever-wedge that burned a full population
read plus spend executions every 10 minutes into a stop naming the rotted line, at zero chain cost.

N4 — both halves

The wrapped log literal is a proper continuation, and the §24.8a stop table gained the missing
Arithmetic and Store rows plus the new one, so the prose's promise that "each refusal is reported
with its own reason and its own remedy" is now true of every variant.

N3 — one comment, not a fix

Not taken, deliberately. The right retry policy differs per stop — EpochNotStartedOnChain and
BehindFinalityDepth are "wait for a block" and retrying at the 10-minute round is correct for them,
while a population-bounded refusal is not — so a blanket backoff would either delay the ordinary cases
or under-protect the expensive one. That is a policy decision, not a line change, and §2.6 says log
it. Worth recording that N2's fix removes the worst repeated cost the gate found: the rotted line no
longer buys a census per tick. The residual is the attacker parking the distinct-creating-spend count
just under MAX_CANDIDATES, which stands.

Evidence

  • cargo test -p dig-node-service --lib513 passed, 0 failed (510 baseline + 3 new).
  • cargo clippy -p dig-node-service --lib --all-features -- -D warnings — clean.
  • Revert-proof, committed first, restored by file copy (never git checkout). Reverting only the
    two fixes — removing the EpochLineUnreadable pre-check, and setting examined: 0, excluded: Exclusions::default() as the pre-fix code effectively did — turns all three new tests red and
    nothing else: a_census_of_nothing_reports_whether_it_examined_anything,
    the_recorded_epoch_is_logged_with_what_the_census_examined, and
    a_rotted_line_for_the_target_epoch_stops_the_walk_before_reading_the_chain. 4 passed, 3 failed.
    Tree restored and verified clean afterwards.
  • The discriminating fixture varies one actor and keeps a truthful control: the empty-chain half
    and the wrong-puzzle-hash half differ only in the coin the source answers with, and both record
    stores: 0. The assertion is that the two observations differ — an assertion on stores alone
    passes in both, which is the state this PR was in.
  • a_rotted_line... asserts reads() == 0, which fails for any fix placed later than the pre-check:
    a stop reported after the census is still a correct-looking stop and still burns the census.
  • SPEC.md diff: 65 insertions, 0 deletions — a pure addition, no CRLF flip.

Blast radius

gitnexus's dig-node index predates this moduleimpact({target: "catch_up"}) returned "Target
not found"
, and detect_changes(compare, main) reported 65 files and symbols this branch never
touches (dig-wallet/src/sage/db.rs, singleton.rs, …). Per §2.0 bound 2 the analysis was done by
grep plus direct read, and this says so rather than reporting a tool answer that is stale.

The changed symbol is CatchUp.recorded, Vec<u64>Vec<CensusObservation>. Complete consumer
set: the server.rs timer loop and four in-file test assertions. catch_up/record_one are
module-scoped; no other crate reads either. git diff --stat origin/main...HEAD covers the same ten
files as round 1 — no new file, no unexpected symbol.

Version

main is at 0.165.0; this branch is at 0.166.0 — one increment above, minor for the additive
CensusObservation, the new CensusStop variant and the new SPEC clause. If a peer merges and moves
main to 0.166.0, the version gate goes red and this bumps then.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security (narrow re-gate) — IN PROGRESS, not the verdict

Auditing head dcb6b8e2258041c28d42ce1af332e48b8c9fb545.

Scope of this round (per dispatch): SPEC §24.8a clauses, the chain.rs doc corrections,
the new CensusObservation logging, and N2's fix. The prior full adversarial gate at
b29266c cleared the rest by execution and is not re-opened.

Resolved so far:

  • head SHA from remote = dcb6b8e2258041c28d42ce1af332e48b8c9fb545 (matches dispatch).
  • main tip = 06e871f4a091e7f527fdeddcfeafa6ab607e2f68 — re-read live, will diff against it.

Next: cut a detached worktree at the head, read the full diff, and check every row of the
CensusStop remedy table against a file:line in the source it describes.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security (narrow re-gate) — IN PROGRESS, not the verdict (2/2)

Head audited: dcb6b8e2258041c28d42ce1af332e48b8c9fb545. Own worktree, detached, at C:\tmp\worktrees\dn401-secgate.

The stop table — checked row by row against source, not prose

CensusStop has eleven variants, and every one has a construction site:
ChainUnavailable collateral_census.rs:376 - EpochNotStartedOnChain :288 -
BehindFinalityDepth :298 - PopulationTooLarge :310 - PriorEpochMissing :365 -
PriorEpochUnreadable :366 - PriorEpochUninterpretable :359 - Arithmetic :326 -
Contradiction :343 - Store :235 and :344 - EpochLineUnreadable :281.
The table's nine rows collapse the three predecessor variants into one. The count is right.

Remedies verified independently:

  • Arithmetic (the row that was born-false last round) is now CORRECT. The build resolves
    dig-mirror-collateral 0.3.0, not the 0.2.0 the lane cited. I checked 0.3.0 anyway:
    advance has exactly three refusal paths - NonSequentialEpoch returned directly, plus
    implemented()? and version_for_epoch()? yielding UnknownProtocolVersion and
    EpochNotGoverned (dig-mirror-collateral-0.3.0/src/record.rs:99-110 doc, :111 fn,
    src/error.rs:20-57 enum, #[non_exhaustive], exactly three variants). The SPEC's three named
    reasons map 1:1. detail: e.to_string() at collateral_census.rs:328 makes "reported verbatim" true.
  • Store row says "read or written" and both are real - :235 is a read failure
    (highest_recorded -> store.records()), :344 a write failure (store.put).
  • Row 1's remedy "reach a chain source" is right, and deliberately so. I checked whether an
    attacker-planted coin could reach it and be misdiagnosed: it cannot. dig-mirror-coin-0.7.0
    census.rs:679-684 folds NotDigCollateral and Malformed into excluded.unreadable and
    returns Ok(None); only Unauthenticated/ChainUnavailable/Driver propagate, and the crate
    documents Unauthenticated as "a gap in the SOURCE". Not a censorship primitive.
  • Rows 2/3/4 match dig-mirror-coin-0.7.0 census.rs:241 (Ok(None) = chain has not reached the
    epoch start), :187-198 (Pending), :200-228 (Incomplete, "a refusal, not a truncation").
  • The finality sentence now says "below" and the table is below it. Correct.

The three corrected claims

1a verified and NOT overcorrected. chia-query-0.19.0/src/router.rs:844-871 is coinset-first
then self.peer.peak_height() with no quorum; src/lib.rs:124 defaults
coinset_fallback_enabled: true; sources.rs:116-118 builds from ChiaQueryConfig::default().
The SPEC's counter-claim that the wallet's peak is NC-12-agreed also holds:
chain.rs:365-366 routes to peer_reads, and quorum.rs:612-623 settled_peak returns None
below the corroboration floor or when the credible band loses its majority - failure to agree is
reported as not knowing.

1b actionable. ChiaQueryProvider::independence_group() exists at
chia-query-0.19.0/src/provider_registry/chia_query_provider.rs:57, delegating to lib.rs:222.
dig-node's own independence_group_for is at sources.rs:137-143 as cited. The two
implementations return identical strings ("coinset.org" / "chia-peers"), so the requirement
does not produce inconsistent groups.

1c swept, and the neighbours are clean. chain.rs:355 now says "no WALLET read in production
takes it" and :357-363 names the census. I checked the claim "exactly one caller": chain_source(
has exactly one production call site, server.rs:2656. The second peak_height_opt caller,
fallback.rs:342, is CoinsetFallback's impl - and ChainTransport's own ChainFallback::peak_height
delegates to ChainTransport::peak_height (corroborated path), so the production RPC read at
rpc.rs:1975 does not take the router's peak. The claim holds.

GATING 2 - the fields are wired and the log is complete

Exclusions in dig-mirror-coin-0.7.0/src/census.rs:93-142 has exactly 10 fields, and
server.rs:log_census_observation logs all 10 plus epoch/census_height/stores/examined. No
per-rule counter is dropped - which matters, because a missing one would hide one of the three
causes of stores=0.

N2's assertion is the strong one, and its instrument is connected

collateral_census.rs:848 asserts source.reads() == 0. The counter is not an unwired zero:
UnreachableSource::refuse() (:423-426) increments on every ChainSource method, and
:599 in the same module asserts source.reads() > 0 on a walk that does read. Non-zero control present.

Baseline re-run in my worktree: cargo test -p dig-node-service --lib -> 513 passed / 0 failed.

Next: the mutation probes (revert each fix, watch the right tests go red), clippy, and merge
preconditions. Verdict to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security (narrow re-gate): PASS

Head audited: dcb6b8e2258041c28d42ce1af332e48b8c9fb545 (resolved from the remote myself;
main re-read at 06e871f4a091e7f527fdeddcfeafa6ab607e2f68 / v0.165.0 — it has not moved).

Scope, per dispatch: the SPEC 24.8a clauses, the chain.rs doc corrections, the new
CensusObservation logging, and N2's fix. The prior adversarial gate at b29266c cleared the rest
by execution; not re-opened. No security defect found in the audited surface.


The stop table — every row checked against a file:line, not the prose around it

CensusStop has eleven variants and every one has a construction site:
ChainUnavailable collateral_census.rs:376 · EpochNotStartedOnChain :288 ·
BehindFinalityDepth :298 · PopulationTooLarge :310 · PriorEpochMissing :365 ·
PriorEpochUnreadable :366 · PriorEpochUninterpretable :359 · Arithmetic :326 ·
Contradiction :343 · Store :235 and :344 · EpochLineUnreadable :281.
Nine rows collapse the three predecessor variants into one. The count is right.

  • The Arithmetic remedy — the cell that was born-false last round — is now CORRECT, and I
    checked it against the version that is actually BUILT.
    The lock resolves
    dig-mirror-collateral 0.3.0, not the 0.2.0 the lane cited. In 0.3.0, advance
    (src/record.rs:111) has exactly three refusal paths: NonSequentialEpoch returned directly,
    plus implemented()? and version_for_epoch()? yielding UnknownProtocolVersion and
    EpochNotGoverned. src/error.rs:20-57 is #[non_exhaustive] with exactly those three
    variants. The SPEC's three named reasons map 1:1, and detail: e.to_string()
    (collateral_census.rs:328) makes "reported verbatim" true.
  • Store says "read or written" and both are real:235 is a read failure
    (highest_recorded then store.records()), :344 a write failure (store.put).
  • Row 1's "reach a chain source" is right, and deliberately so. I probed whether an
    attacker-planted coin could land there and be misdiagnosed as an operator's broken chain source —
    it cannot. dig-mirror-coin-0.7.0/src/census.rs:679-684 folds NotDigCollateral and Malformed
    into excluded.unreadable and returns Ok(None); only Unauthenticated / ChainUnavailable /
    Driver propagate, and the crate documents Unauthenticated as "a gap in the SOURCE". Not a
    censorship primitive.
  • Rows 2/3/4 match dig-mirror-coin-0.7.0/src/census.rs:241 (Ok(None) = chain has not reached the
    epoch start), :187-198 (Pending), :200-228 (Incomplete — "a refusal, not a truncation").
  • The finality sentence now says "below" and the table is below it.

The three corrected claims

1a — verified, and it does NOT overcorrect. chia-query-0.19.0/src/router.rs:844-871 is
coinset-first then self.peer.peak_height() with no quorum; src/lib.rs:124 defaults
coinset_fallback_enabled: true; sources.rs:116-118 builds from ChiaQueryConfig::default().
The counter-claim that the wallet's peak IS NC-12-agreed also holds: chain.rs:365-366 routes to
peer_reads, and quorum.rs:612-623 settled_peak returns None below the corroboration
floor or when the credible band loses its majority — failure to agree is reported as not knowing.

1b — the requirement is actionable, not aspirational.
ChiaQueryProvider::independence_group() exists at
chia-query-0.19.0/src/provider_registry/chia_query_provider.rs:57, delegating to lib.rs:222.
dig-node's own independence_group_for is at sources.rs:137-143 as cited, and the two
implementations return identical strings, so taking the group from the method cannot produce an
inconsistent grouping.

1c — corrected, and the neighbours are clean. chain.rs:355 now reads "no WALLET read in
production takes it" and :357-363 names the census as the caller. I swept the rest of the file for
the same shape and tested the strongest remaining claim, "exactly one caller": chain_source( has
exactly one production call site, server.rs:2656. The second peak_height_opt caller,
fallback.rs:342, belongs to CoinsetFallback — and ChainTransport's own
ChainFallback::peak_height delegates to ChainTransport::peak_height (the corroborated path), so
the production RPC read at rpc.rs:1975 does not take the router's peak. The claim holds.

GATING 2 — the zero now carries provenance, and the fields are genuinely wired

Exclusions (dig-mirror-coin-0.7.0/src/census.rs:93-142) has exactly 10 fields and
server.rs log_census_observation logs all 10 plus epoch / census_height / stores / examined.
No per-rule counter is dropped — a missing one would have hidden one of the three causes of a zero.

Wiring proved by mutation, not by reading: defaulting examined: 0, excluded: Default::default()
at collateral_census.rs:338-339 gave 511 passed / 2 failed, exactly
a_census_of_nothing_reports_whether_it_examined_anything and
the_recorded_epoch_is_logged_with_what_the_census_examined, nothing else.

The fixture discriminates as claimed: one actor varied (empty source vs one foreign-hash record),
both halves assert stores == 0, and the assert_ne! is on the (examined, excluded) pair. The
log test asserts on the rendered line through a captured subscriber, so the fields are proven to
reach the operator, not just the struct.

N2 — necessary, and its instrument is connected

Mutation: moved the guard from collateral_census.rs:280-282 to after the census, giving
512 passed / 1 failed — exactly
a_rotted_line_for_the_target_epoch_stops_the_walk_before_reading_the_chain, nothing else. The
counter is not an unwired zero: UnreachableSource::refuse() (:423-426) increments on every
ChainSource method, and :599 asserts source.reads() > 0 on a walk that does read.
Non-zero control present.

Re-executed

  • cargo test -p dig-node-service --lib at the head, in my own worktree: 513 passed / 0 failed.
  • cargo clippy -p dig-node-service --lib --all-features -- -D warnings: Finished, 0 warnings.
  • Restored by file copy; blob hash of the restored file equals the one in dcb6b8e
    (3864b16fb042ff387b4dba3616298a9e99752b9d), tracked tree clean.
  • SPEC.md: 65 insertions, 0 deletions, no CRLF flip (both sides plain UTF-8, no CRLF terminators).
  • Version 0.166.0 vs main 0.165.0 — correct minor for a new capability.
  • Lock diff: exactly one new package (dig-mirror-coin 0.7.0, a DIG-Network crate already in
    the collateral family) plus thiserror / thiserror-impl 2.0.19 to 2.0.20. No git dep
    introduced, no pinned version loosened.
  • Zero unwrap / expect / panic! in the non-test production code of the new module or the new
    server.rs function.
  • Reachability: the census is a local timer gated on config.enable_chain_sync; no peer can
    trigger it
    . MIRROR_ROUND_LENGTH_MS = 10 min, and catch_up performs no chain read when the
    store is current. The log carries counts only — no key, address or secret.

Merge preconditions (unpiped, rc captured, --allow-draft)

RC=1 — BLOCKED, correctly, and expected mid-round. All five required contexts are present by
name
: Lint commit messages / Check version increment / Rustfmt / Clippy all SUCCESS;
Test + coverage IN_PROGRESS. unresolvedReviewThreads=0, draft=true,
mergeStateStatus=BEHIND — a rebase onto main is required before merge, and the gates re-run
after it.


Findings — none gating

F1 · collateral_census.rs:816-818 · doc accuracy · NOT gating. The comment claims
reads() == 0 "is the load-bearing assertion, and it is what a fix placed anywhere later than this
would fail". Measured: it is not, for this fixture. With the guard moved after the census, the
test failed at :839 — the variant assertion (left: Some(ChainUnavailable { epoch: 2, ... }),
right: Some(EpochLineUnreadable { epoch: 2 })) — and reads() == 0 was never evaluated, because
UnreachableSource refuses every read so the stop can never still be EpochLineUnreadable.
reads() == 0 remains worth keeping — it is the only assertion that would catch a late-placed guard
against a source that answers — but the sentence about which assertion carries the proof is wrong.
Same shape as the two clauses corrected this round: a claim about its own guard that nobody tested.
One sentence to fix; no code change.

F2 · SPEC.md 24.8a stop table, predecessor row · precision · NOT gating. The row collapses
PriorEpochMissing, PriorEpochUnreadable and PriorEpochUninterpretable under the remedy "that
epoch first". For the uninterpretable case the predecessor record is present and readable — the
build is behind, and the remedy is a newer build (the 24.8 ceiling the paragraph above the table
already states). A charitable reading of "attend to that epoch first" covers all three, which is why
I do not call it false; but it is the least precise cell in the table, and it sits in the column
that already produced two errors this round.

F3 · server.rs:2632 + collateral_census.rs:310 · defense-in-depth · follow-up, NOT gating.
This PR is what puts the census on a production timer, and the interval is a flat 10 minutes for
every stop. After a PopulationTooLarge refusal the next pass re-fetches the whole
attacker-writable candidate set
(the fetch happens before the bound is applied) and refuses
again — indefinitely, since that population "never shrinks" by the crate's own doc. An attacker who
has already paid to flood the mirror puzzle hash therefore also buys a recurring large read on every
node, every 10 minutes, forever. It is not an amplification vector against third parties, and the
node still records nothing and reports unknown, so no money is misstated — hardening, not a live
defect. It is, however, a concrete rationale for N3, which I agree was correctly deferred:
the lane's argument that the right backoff differs per stop is right, and this is the stop where it
differs most.

F4 · provenance · observation. The lane's narrative cites
dig-mirror-collateral-0.2.0/src/record.rs:99-105; the build resolves 0.3.0. The claim holds in
both, so nothing is wrong — but a citation should name the version that is actually linked.

F5 · rival implementation · pre-existing, observation. Two independence_group_for
implementations exist: chia-query-0.19.0/src/provider_registry/registry.rs:85 and
dig-wallet/src/sage/sources.rs:137. They agree today. If chia-query's constants ever change,
dig-node's copy diverges silently and a quorum could be miscounted. Not introduced by this PR.


For the next lane — the dig-node gitnexus index is STALE, and I confirmed it

The lane reported detect_changes giving a confident wrong blast radius. I reproduced the shape,
and it is worse than "unavailable": the index answers confidently about the pre-PR tree.
impact(record_one, repo: dig-node) returns Target 'record_one' not found — it exists at
collateral_census.rs:272 — while impact(build_state, repo: dig-node) resolves fine and reports
risk CRITICAL, 10 impacted, 3 processes. So it is stale rather than broken: old symbols answer,
new ones do not, and a caller who does not notice gets a real-looking answer about code that has
moved. Falling back to grep plus a direct read was correct per section 2.0 bound 2.
Reindex before trusting it.

Coverage I did NOT achieve

  • No integration or live run. Everything here is the unit suite plus two targeted mutations;
    nobody watched a real node census a real epoch against mainnet. spawn_collateral_census itself
    (server.rs:2637+) has no test — the timer loop, the enable_chain_sync gate, the
    spawn_blocking bridge and the Handle::current() requirement are covered only by reading.
  • Test + coverage was still IN_PROGRESS on CI at this head; my local --lib run is not that
    gate and says nothing about the coverage floor.
  • Scope honoured: I did not re-execute the b29266c round's zero-moves, tripwire, runtime or
    blast-radius probes.
  • The chain.rs neighbour sweep targeted production-reachability claims ("nothing in production",
    "only caller", "unreachable", "nobody"), not a line-by-line re-reading of the whole file.
  • I did not audit dig-mirror-coin 0.7.0 or dig-mirror-collateral 0.3.0 as wholes — only the
    exact paths the SPEC's clauses assert things about.

No mutation of any shared checkout: all work in C:\tmp\worktrees\dn401-secgate, cut detached from
dcb6b8e, restored byte-identical, git status clean on tracked files. dn-400 untouched.

MichaelTaylor3d and others added 7 commits August 28, 2026 23:43
…in source

Refs: #400

Co-Authored-By: Claude <noreply@anthropic.com>
Refs: #400

Co-Authored-By: Claude <noreply@anthropic.com>
Refs: #400

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ine before the chain read

GATING 2 -- a `stores: 0` was written with no recoverable provenance. `MirrorCensus::examined`
and `::excluded` were dropped at the point the record was built, so an empty network, a source
answering at the wrong puzzle hash, and a degraded source whose candidates' creating spends were
all unavailable produced an identical figure on the path that decides how much collateral a node
posts. The walk now carries a `CensusObservation` per recorded epoch and the service logs it.

GATING 1 -- SPEC 24.8a claimed "one peer pool with one notion of the peak" and chain.rs called the
router a race. Neither is true: the router asks api.coinset.org first and consults this node's
peers only on failure, so the census's peak is uncorroborated where the wallet's is NC-12-agreed.
Both now say which source each half trusts, and `peak_height`'s "nothing in production takes it"
is corrected -- this change is what puts production on that path.

N2 -- one unreadable line wedged the walk forever and re-ran a full census every 10 minutes.
`records()` skips unparseable lines while `get()` reports them, so `highest_recorded` answered
n-1 and the walk recomputed n only to fail at `put`. Detected before any chain read.

N4 -- the wrapped log literal, and the 24.8a stop table's missing Arithmetic and Store rows.

Co-Authored-By: Claude <noreply@anthropic.com>
…rvation struct

The rendered `tracing` event is what an operator reads, so the census-provenance fields are
asserted on it. `log_census_observation` becomes a free function for that reason.

Also corrects two clauses written in the previous commit while repairing others: the Arithmetic
remedy named a ruleset problem when `advance` refuses for three different reasons, and the
finality-depth refusal was described as appearing "above" its own table.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 29, 2026 06:57
@MichaelTaylor3d
MichaelTaylor3d merged commit c7618e7 into main Aug 29, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/400-census-wiring branch August 29, 2026 06:57
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security addendum — PASS carries to the new head 10c56fd

The head moved while I was finishing, and the PR was undrafted. My verdict above was posted
against dcb6b8e2258041c28d42ce1af332e48b8c9fb545; the tip is now
10c56fd77504a61d121a4764f280d7623b65c3c2 with draft=false. A PASS against a stale tree is
worse than useless, so I re-derived the delta rather than letting the earlier verdict stand by
implication.

The branch was force-pushed, and the tree delta is one generated changelog entry

git merge-base --is-ancestor dcb6b8e 10c56fdNO: the branch was rewritten, not
fast-forwarded, so every commit SHA changed. But the content delta is a single file:

git diff --name-status dcb6b8e 10c56fd
M	CHANGELOG.md          (+8, -0)

Every file my audit touched is byte-identical at the new head — verified by blob hash, not by
eye:

file dcb6b8e vs 10c56fd
SPEC.md IDENTICAL
Cargo.toml, Cargo.lock IDENTICAL
crates/dig-node-service/Cargo.toml IDENTICAL
crates/dig-node-service/src/collateral_census.rs IDENTICAL
crates/dig-node-service/src/server.rs IDENTICAL
crates/dig-node-service/src/lib.rs IDENTICAL
crates/dig-node-service/src/collateral_sync.rs IDENTICAL
crates/dig-wallet/src/sage/chain.rs IDENTICAL
crates/dig-wallet/src/sage/sources.rs IDENTICAL
crates/dig-wallet/src/sage/service.rs IDENTICAL

The --name-status listing is the guard against the case a blob-by-blob check would miss — a file
ADDED at the new head that I never audited. There is none.

The changelog delta is a git-cliff entry for the PREVIOUS release (0.165.0), listing
already-merged work (#393, #395, #398, #237, #391). It says nothing about this PR, carries no
credential, and asserts nothing about this PR's behaviour.

So every probe I executed — the two mutations, the 513/0 suite, the stop-table row checks, the
byte-identity restore — was executed against the exact bytes that are now at the tip.

CHANGELOG.md is not compiled and is not on any audited path. The PASS carries. Findings F1-F5
stand unchanged and none of them gates.

A correction to my own evidence, so the record is exact

In my verdict I reported cargo clippy -p dig-node-service --lib --all-features -- -D warnings as
"Finished, 0 warnings". Re-examining that read: the tail I quoted ended
Checking dig-wallet v0.41.0 then Finished, with no Checking dig-node-service line — and I
then deleted the worktree, so I cannot re-inspect that log. The Finished line and the zero
warning/error count are real, but I cannot now prove from my own run that the target crate's lib was
freshly linted rather than served from cache.

This does not weaken the verdict, because a stronger and independent instrument covers it: the
repo's own Clippy required check is SUCCESS at this head, asserted by name. I am recording
the ambiguity rather than leaving an overstated claim in a security verdict — the local run should
be read as corroboration, not as the proof.

Merge preconditions at 10c56fd (unpiped, rc captured, --allow-draft)

tip 10c56fd77504a61d121a4764f280d7623b65c3c2  base main
draft=false  mergeStateStatus=UNKNOWN  unresolvedReviewThreads=0
  Lint commit messages               SUCCESS
  Check version increment            SUCCESS
  Rustfmt                            SUCCESS
  Clippy                             SUCCESS
  Test + coverage                    SUCCESS
RESULT: BLOCKED — do not merge  [EXIT 1]

All five required contexts are now SUCCESS by nameTest + coverage has since finished, so
the one coverage gap I declared is now closed by CI. Zero unresolved threads.

RC=1 is not a security objection. The script blocks on mergeStateStatus=UNKNOWN, which is
GitHub still computing mergeability after the force-push, not a failing gate. Re-run
check-merge-preconditions.sh and merge only on a genuine exit 0
— do not read this BLOCKED as
"the security gate refused", and equally do not read "all five green" as permission to skip the
re-check. Note the earlier BEHIND is gone: the rewrite rebased the branch onto main.

Security verdict for 10c56fd: PASS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire dig-mirror-coin's census to a ChainSource — a node can record epoch 1 and nothing after it

1 participant