feat(collateral): census the current epoch against a real chain source - #401
Conversation
loop-security — IN PROGRESS, NOT THE VERDICTAuditing head Established so far1. The 2. The provider's reads are real, not stubs. 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".
let record = prior.advance(counted.census())...
let stored = StoredRecord::censused(record, counted.height());
The crate author wrote those fields for precisely this reason, verbatim at
Concrete consequence. The PR's own mainnet run reports
All three produce the identical Note this is not an arithmetic defect: the figure Still to come: whether the zero can be made to move; the epoch-walk failure modes; the |
loop-security — IN PROGRESS, NOT THE VERDICT (2/n)Head FINDING 2 — GATING. The census reads bypass NC-12 peer corroboration, and the diff makes its own file's claim false
Production attaches the corroborated path at let chain = Arc::new(ChainTransport::new().with_peer_reads(db.clone()));and The new let client = self.shared_client().await?;
Ok(chia_query::provider_registry::ChiaQueryProvider::new(client, handle, ProviderInfo { ... trustless: false }))
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. ( Concrete exploit A — defeat the reorg guard with an inflated 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 Concrete exploit B — omission lowers the requirement, and it is PERMANENTThe 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:
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 ( Not marked downVerified and clean: the single-client claim holds ( 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. |
loop-security — IN PROGRESS, NOT THE VERDICT (3/n)Head 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 ( The fixture is not hand-written: Three tests, all green: What each establishes:
Additionally the double records which puzzle hash it was asked for, and the probe asserts that set contains Verdict on the primary question
What I did NOT prove, stated plainlyThe probe substitutes a
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 FALSEThe 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 FALSE clause:
One pool: true. One notion of the peak: false, in this same diff. The wallet's peak is Incomplete: the stop table lists six rows for ten Still to come: the |
loop-security VERDICT: CHANGES-REQUIREDAudited head: 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 MEASUREMENTI made it move. Probe built in my own detached worktree (
Not vacuous -- proved by mutation. Forcing 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 places1a.
|
| 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
ChiaQuerybuilt withcoinset_fallback_enabledasksapi.coinset.orgfirst 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 amax_peers: 0client, 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_sourcecallsshared_client()(a view);shared_clientstayspub(crate), so only aChainSourceescapes the crate.sources.rs:194-202is the sole productionChiaQuery::new, guarded by asole_owner_teststripwire that is green. dig_ecosystem#2761 is not reopened. - One
dig-chainsource-interfaceline where it matters. Lock:chia-query 0.19.0,dig-mirror-coin 0.7.0anddig-node-service 0.166.0all resolve to 0.3.2, so the impl genuinely applies. (0.1.0also exists, reached only bydig-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); everynew_current_threadinservice_control.rsis inside#[test], andcontrol_client.rs:47is the CLI, which never spawns the census.Handle::current()is taken inside the spawned task, and each pass is wrapped inspawn_blockingasrun_blockingrequires.run_blockingalso fails closed rather than deadlocking. - No stop becomes a figure.
CensusOutcomeis not#[non_exhaustive], so therecord_onematch cannot silently fall through on a future variant -- a new one is a compile error.highest_recordedusesunwrap_or(GENESIS_EPOCH), notunwrap_or(0). A non-Finalcurrent_epoch_now()skips the pass. EveryCensusStoppath writes nothing; absence surfaces asunknownwith its reason. - The walk creates no gaps.
record_onereturningErrreturns from the loop immediately, so epochs are strictly sequential and a mid-walk failure leaves no hole. No self-Contradictionis reachable: within a pass epochs are distinct, and across passeshighest_recordedis re-read. The only wedge is N2, which surfaces asStore, notContradiction. - No arithmetic restated. Every figure comes from
dig_mirror_coin::census/EpochRecord::advance; the floor clamp lives insiderequired_per_store. Confirmed by readingrecord.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.
adoptreceives responses it did not choose; truncating there keeps a prefix of an attacker-contributed set, which is exactly whatPopulationExceededrefuses. The cap belongs at the collection site, and there is none -- I confirmedadopthas no production caller anddig.getCollateralEpochis 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.
WalletServicehas exactly one struct-literal site (service.rs:328) andAppStateone (server.rs:568); both updated.wallet_chainappears only atserver.rs:109/590/2127.dig-wallet/src/lib.rs:110is a different, privateAppState.ChainTransportgained one additive method. The workspacedig-walletis a path dep and not the crates.iodig-wallet, so the newpubfield 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 withrccaptured 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
ChiaQueryProviderwas never executed against live mainnet by me. The probe substitutes aChainSourcedouble, 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-87forwards to a real router call, not a stub. The mainnet run in the PR corroboratespeak_heightandblock_timestamp, sincecensus_heightreturned a concrete 9196171 rather thanNoneor an error.census_height: 9196171was 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.- The ~11-minute cold start was not measured. N3 reasons about its shape, not its wall clock.
dig-mirror-coin 0.7.0census 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.- No clippy/fmt run of my own -- CI covers both and both are green by name.
- I did not exercise the
ContradictionorPopulationTooLargestops 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.
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
Verified after cleanup, in the shared checkout: HEAD No file in this PR was read through a shared working tree -- everything came from git objects ( |
Round 2 — both gating findings fixed, plus N2 and N4Head GATING 1 — the trust posture, corrected in three places (one more than reported)1a. Verified independently before writing it, not taken from the gate:
1b. 1c — not in the verdict, found while fixing 1a/1b. Clause-by-clause recheck of §24.8a, each against a
Two clauses I wrote in the first commit were wrong and corrected in the second: the GATING 2 — the zero now has provenance
Real output, from
N2 — fixed, and it was the cheaper half that mattered
N4 — both halvesThe wrapped log literal is a proper continuation, and the §24.8a stop table gained the missing N3 — one comment, not a fixNot taken, deliberately. The right retry policy differs per stop — Evidence
Blast radiusgitnexus's The changed symbol is Version
|
loop-security (narrow re-gate) — IN PROGRESS, not the verdictAuditing head Scope of this round (per dispatch): SPEC §24.8a clauses, the Resolved so far:
Next: cut a detached worktree at the head, read the full diff, and check every row of the |
loop-security (narrow re-gate) — IN PROGRESS, not the verdict (2/2)Head audited: The stop table — checked row by row against source, not prose
Remedies verified independently:
The three corrected claims1a verified and NOT overcorrected. 1b actionable. 1c swept, and the neighbours are clean. GATING 2 - the fields are wired and the log is complete
N2's assertion is the strong one, and its instrument is connected
Baseline re-run in my worktree: Next: the mutation probes (revert each fix, watch the right tests go red), clippy, and merge |
loop-security (narrow re-gate): PASSHead audited: Scope, per dispatch: the SPEC 24.8a clauses, the The stop table — every row checked against a file:line, not the prose around it
The three corrected claims1a — verified, and it does NOT overcorrect. 1b — the requirement is actionable, not aspirational. 1c — corrected, and the neighbours are clean. GATING 2 — the zero now carries provenance, and the fields are genuinely wired
Wiring proved by mutation, not by reading: defaulting The fixture discriminates as claimed: one actor varied (empty source vs one foreign-hash record), N2 — necessary, and its instrument is connectedMutation: moved the guard from Re-executed
Merge preconditions (unpiped, rc captured,
|
…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>
dcb6b8e to
10c56fd
Compare
loop-security addendum — PASS carries to the new head
|
| 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 name — Test + 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.
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 requirementansweredunknown / not_censusedfor thecurrent epoch correctly and permanently.
The §2.0 already-shipped check, which sized this ticket
chia-queryalready implementsdig_chainsource_interface::ChainSource.ChiaQueryProvideris asynchronous facade over the async router —
chia-query/src/provider_registry/chia_query_provider.rs:62(
ChainSource),:144(ChainSourceProvider), constructed asChiaQueryProvider::new(Arc<ChiaQuery>, tokio::runtime::Handle, ProviderInfo).So no
ChainSourcewas owed and this became a wiring job rather than an implementation. Writing onehere 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 bydig-wallet's
ChainTransport(crates/dig-wallet/Cargo.toml:112, whose own comment records that asecond
=0.5.1pin was removed to collapse two chia-query lines). This PR takes a view of that onetransport rather than building a second — the two-pools-two-peaks defect dig_ecosystem#2761 removed.
The wiring
file:linecrates/dig-wallet/src/sage/chain.rs—ChainTransport::chain_sourceChainSourceview of the ONE shared client;CHAIN_SOURCE_PROVIDER_IDnames it oncecrates/dig-wallet/src/sage/service.rs:104WalletService.chainexposed, so a consumer outside the wallet need not build a second clientcrates/dig-node-service/src/collateral_census.rs(new, 1 file)catch_up— the sequential epoch walk, andCensusStop, the named reasons it writes nothingcrates/dig-node-service/src/server.rs—spawn_collateral_censusMIRROR_ROUND_LENGTH_MStimer, gated onenable_chain_synccrates/dig-node-service/src/server.rs—AppState.wallet_chaincrates/dig-node-service/src/collateral_sync.rsSPEC.md§24.8aDeps added to
dig-node-service:dig-mirror-coin = "0.7"(latest published) anddig-chainsource-interface = "0.3"(the linechia-queryanddig-mirror-coinboth 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::censusandEpochRecord::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 namesfields explicitly; no destructuring without
..was found.AppState— one construction site (server.rs:561), verified bygrep -rn "AppState {"; theother two hits are
implblocks.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 ofput.and gated on
enable_chain_syncso 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), noseeded file:
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 nodewalked 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 collateral— 49 passed, 0 failed. Four are new:an_unreachable_chain_records_nothing_and_names_the_reason— asserts the STORE as well as thereport, 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, soa single chain touch would turn
stoppedintoSome.a_prior_record_from_an_unimplemented_ruleset_is_refused— the protocol-version ceiling at thecensus 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 instantand the spacing.
cargo clippy -p dig-node-service --lib --all-targets— clean.The
plan.sample_sizefold-in — what was actually possiblePR #398's note asks for the collected set to be capped at
plan.sample_sizeat the collection site.There is no collection site.
dig.getCollateralEpochis served (server.rs:1095) and neverrequested;
adoptis called only from its own tests. Truncating the responses insideadoptwould bewrong for the same reason
PopulationExceededrefuses rather than trims — it would keep a prefix of aset the attacker contributed to.
So what landed is the obligation, stated at the refusal it protects (
collateral_sync.rs), namingexplicitly 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 abovemainat the time of the bump (0.165.0,22f9f5a). Minor: new capability,nothing removed or renamed.