Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
7e64e0e
chore(release): assign v0.253.0 for the cold-start census bound (#404)
MichaelTaylor3d Sep 2, 2026
d38f2aa
test(collateral): measure the cold-start census read budget (#404)
MichaelTaylor3d Sep 2, 2026
feac48f
refactor(collateral): carry the predecessor's stored envelope into th…
MichaelTaylor3d Sep 2, 2026
95cf122
docs(test): state what the cold-start budget does and does not measur…
MichaelTaylor3d Sep 2, 2026
de42b95
chore(lock): record the 0.253.0 workspace version in Cargo.lock
MichaelTaylor3d Sep 2, 2026
ce6b575
chore(merge): bring origin/main into the cold-start census branch (#404)
MichaelTaylor3d Sep 2, 2026
b202910
feat(census): seed the cold-start collateral-census height search (#404)
MichaelTaylor3d Sep 2, 2026
04a31cd
test(census): measure the seeded search against the bisecting one it …
MichaelTaylor3d Sep 2, 2026
fa7b1b6
Merge remote-tracking branch 'origin/main' into loop/404-census-cold-…
MichaelTaylor3d Sep 3, 2026
8a8c1d4
chore: renumber to 0.252.12, under the MSI ProductVersion ceiling (#521)
MichaelTaylor3d Sep 3, 2026
8a08ade
Merge remote-tracking branch 'origin/main' into loop/404-census-cold-…
MichaelTaylor3d Sep 3, 2026
a6e8555
Merge remote-tracking branch 'origin/main' into loop/404-census-cold-…
MichaelTaylor3d Sep 3, 2026
51d06fa
chore(release): merge main into loop/404-census-cold-start (keep 0.25…
MichaelTaylor3d Sep 3, 2026
2104531
chore(release): merge main into loop/404-census-cold-start (keep 0.25…
MichaelTaylor3d Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.252.42"
version = "0.252.80"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
14 changes: 14 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8420,6 +8420,20 @@ the epoch's start instant — and MUST derive the record with
`dig_mirror_collateral::EpochRecord::advance`. It MUST NOT restate either. A census height chosen
any other way is a fork, because every node must reach the same height without coordinating.

The node MUST locate that height with `dig_mirror_coin::census_height_seeded`, which returns exactly
what `census_height` returns for the same instant under every seed and differs only in how many
chain reads it pays. The seed MUST be the predecessor record's `census_height` when and only when
that record carries `censused` provenance; a record whose height was adopted from peers, or which
carries no provenance, MUST yield an unseeded search. A peer-supplied height comes from a trust
domain the chain source cannot check, and the seed's verification probe is a single uncorroborated
`block_timestamp` read, so accepting one would let a peer cohort and a stale or forked source
together prune the true height from below.

Corroborating the height search itself is a NAMED LIMITATION, not a claim: the corroborated chain
surface answers by coin id and does not serve `block_timestamp`, so every probe of the search comes
from one source whether it is seeded or not. Seeding therefore reduces the reads inside that trust
boundary and does not widen it.

The chain reads MUST be served through a `dig_chainsource_interface::ChainSource`. The node MUST NOT
open a second connection to the chain for this purpose: it takes a `ChainSource` view of the one
transport that already serves its wallet reads, so a node holds ONE peer pool.
Expand Down
96 changes: 88 additions & 8 deletions crates/dig-node-service/src/collateral_census.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
//! already agrees on, and arrives at the same records as a node that never stopped.

use dig_chainsource_interface::ChainSource;
use dig_mirror_coin::{census, census_height, CensusOutcome, Exclusions, MirrorError};
use dig_mirror_coin::{census, census_height_seeded, CensusOutcome, Exclusions, MirrorError};

use crate::collateral::{
EpochRecordStore, PutOutcome, RecordProvenance, StoredEpoch, StoredRecord, GENESIS_EPOCH,
Expand Down Expand Up @@ -373,13 +373,16 @@ fn record_one<S: ChainSource>(

let prior = prior_record(store, epoch)?;

let at = match census_height(source, epoch_start_unix_secs(epoch)) {
// The predecessor arrives as the STORED record rather than the bare consensus one, because its
// envelope carries the census height dig-node#404 seeds the next search with. `prior.record` is
// what the model consumes; `prior.census_height` is what the search consumes.
let at = match census_height_seeded(source, epoch_start_unix_secs(epoch), seed_from(&prior)) {
Ok(Some(at)) => at,
Ok(None) => return Err(CensusStop::EpochNotStartedOnChain { epoch }),
Err(e) => return Err(chain_stop(epoch, e)),
};

let counted = match census(source, &prior, at) {
let counted = match census(source, &prior.record, at) {
Ok(CensusOutcome::Final(counted)) => counted,
Ok(CensusOutcome::Pending {
census_height,
Expand Down Expand Up @@ -412,6 +415,7 @@ fn record_one<S: ChainSource>(
// protocol version on both sides, and produces the record — including the floor clamp that a
// restated formula loses.
let record = prior
.record
.advance(counted.census())
.map_err(|e| CensusStop::Arithmetic {
epoch,
Expand Down Expand Up @@ -439,10 +443,13 @@ fn record_one<S: ChainSource>(

/// The record `epoch` is derived from — epoch `epoch - 1` — refusing anything this build cannot
/// interpret.
fn prior_record(
store: &EpochRecordStore,
epoch: u64,
) -> Result<dig_mirror_collateral::EpochRecord, CensusStop> {
///
/// Returns the STORED record rather than the bare [`dig_mirror_collateral::EpochRecord`] it wraps.
/// The consensus record is what the model advances from, but the envelope also carries the height
/// the predecessor's census was taken at, and that height is a strict lower bound for this epoch's
/// — the seed that keeps a cold-start walk from re-bisecting the whole chain per epoch
/// (dig-node#404). Reading it here costs nothing: the line has already been fetched and parsed.
fn prior_record(store: &EpochRecordStore, epoch: u64) -> Result<StoredRecord, CensusStop> {
let prior_epoch = epoch.saturating_sub(1);
match store.get(prior_epoch) {
StoredEpoch::Found(stored) if !stored.is_interpretable() => {
Expand All @@ -451,12 +458,43 @@ fn prior_record(
protocol_version: stored.record.protocol_version.0,
})
}
StoredEpoch::Found(stored) => Ok(stored.record),
StoredEpoch::Found(stored) => Ok(*stored),
StoredEpoch::Absent => Err(CensusStop::PriorEpochMissing { epoch: prior_epoch }),
StoredEpoch::Unreadable => Err(CensusStop::PriorEpochUnreadable { epoch: prior_epoch }),
}
}

/// The lower bound the next height search may start from, or `None` for an unseeded search.
///
/// **Only a height this node censused ITSELF.** `census_height_seeded` already treats the seed as
/// untrusted and verifies it against the source, so a bad seed there costs work rather than
/// correctness. This narrower rule closes a different gap, one that verification alone cannot: a
/// record with [`RecordProvenance::AdoptedFromPeers`] carries a census height supplied by a peer
/// cohort — a SECOND trust domain, independent of the chain source — and its verification probe is
/// a single `block_timestamp` read that the chain source alone answers. Accepting a peer-supplied
/// seed would let a peer cohort and a stale or forked source combine to prune the true height from
/// below, which neither could do on its own.
///
/// A `Censused` height was established by this node from the same source the new search reads, so
/// seeding from it adds no trust the search does not already place. `Bootstrap` (epoch 1, taken at
/// no height) and the weakest-provenance default both yield `None`, so the first search of a cold
/// start is unseeded — correct, since there is nothing below it to bound.
///
/// # Named limitation
///
/// The seed-verification probe is not corroborated across the node's peer cohort, because the
/// corroborated surface (`dig_wallet::sage::CorroboratedChainSource`, dig-node#506) does not serve
/// `block_timestamp` at all — it answers by coin id, and returns `Unsupported` for timestamps. Every
/// probe of the height search therefore comes from one source whether it is seeded or not, so the
/// seed does not widen the trust boundary the search already has; it reduces the number of reads
/// inside it. Corroborating height reads is tracked separately.
fn seed_from(prior: &StoredRecord) -> Option<u32> {
match prior.provenance {
RecordProvenance::Censused => prior.census_height,
RecordProvenance::Bootstrap | RecordProvenance::AdoptedFromPeers { .. } => None,
}
}

/// Map a [`MirrorError`] onto the stop it describes.
///
/// Every variant lands on [`CensusStop::ChainUnavailable`] deliberately: from this walk's point of
Expand Down Expand Up @@ -487,6 +525,48 @@ fn epoch_start_unix_secs(epoch: u64) -> u64 {
#[cfg(test)]
mod tests {
use super::*;

/// **A peer-supplied census height is never used as a search seed.**
///
/// The discriminating test for `seed_from`. `census_height_seeded` verifies its seed against the
/// chain source, so this is not about a seed that is merely wrong — it is about WHOSE claim the
/// seed is. A record adopted from peers carries a height from a trust domain the chain source
/// cannot check, and its verification probe is a single uncorroborated `block_timestamp` read;
/// a gate written as `prior.census_height` alone would pass every other test in this file while
/// letting a peer cohort bound the search from below.
#[test]
fn only_a_height_this_node_censused_itself_may_seed_the_next_search() {
let record = EpochRecord::bootstrap();

let mine = StoredRecord::censused(record, 8_000);
assert_eq!(
seed_from(&mine),
Some(8_000),
"a height this node established from its own chain reads is exactly what the walk \
already knows and is what makes the search bounded"
);

let theirs = StoredRecord {
record,
census_height: Some(8_000),
provenance: RecordProvenance::AdoptedFromPeers {
agreed: 5,
sampled: 5,
},
};
assert_eq!(
seed_from(&theirs),
None,
"a peer-supplied height must not bound this node's search, however many peers \
agreed: agreement among peers is not a chain read"
);

assert_eq!(
seed_from(&StoredRecord::bootstrap()),
None,
"epoch 1 was taken at no height, so there is nothing below the first search to bound it"
);
}
use dig_chainsource_interface::CoinRecord;
use dig_mirror_collateral::EpochRecord;
use std::cell::RefCell;
Expand Down
Loading
Loading