From 17dbec3037144c7163754ca7471e9138e1e4c728 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 22:01:45 -0700 Subject: [PATCH 1/7] feat(collateral): census the current epoch against the node's own chain source Refs: https://github.com/DIG-Network/dig-node/issues/400 Co-Authored-By: Claude --- Cargo.lock | 168 +++--- crates/dig-node-service/Cargo.toml | 16 + .../dig-node-service/src/collateral_census.rs | 548 ++++++++++++++++++ .../dig-node-service/src/collateral_sync.rs | 12 + crates/dig-node-service/src/lib.rs | 3 + crates/dig-node-service/src/server.rs | 103 ++++ crates/dig-wallet/src/sage/chain.rs | 55 ++ crates/dig-wallet/src/sage/service.rs | 10 + 8 files changed, 844 insertions(+), 71 deletions(-) create mode 100644 crates/dig-node-service/src/collateral_census.rs diff --git a/Cargo.lock b/Cargo.lock index 6c76d329..acf0cfa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -783,7 +783,7 @@ dependencies = [ "hkdf", "linked-hash-map", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -937,7 +937,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", ] @@ -953,7 +953,7 @@ dependencies = [ "chia-ssl 0.26.0", "chia-traits 0.26.0", "futures-util", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -974,7 +974,7 @@ dependencies = [ "futures-util", "rustls", "rustls-pemfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -993,7 +993,7 @@ dependencies = [ "chia-traits 0.36.1", "futures-util", "native-tls", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -1039,7 +1039,7 @@ dependencies = [ "futures-util", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -1108,7 +1108,7 @@ dependencies = [ "num-bigint", "rand 0.8.7", "rand_chacha 0.3.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1143,7 +1143,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1161,7 +1161,7 @@ dependencies = [ "clvm-traits 0.26.0", "clvmr 0.14.0", "k256", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1181,7 +1181,7 @@ dependencies = [ "colored", "k256", "rue-lir 0.8.5", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1211,7 +1211,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1249,7 +1249,7 @@ dependencies = [ "serde", "serde_json", "signature 2.2.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -1274,7 +1274,7 @@ dependencies = [ "clvm_tools_rs", "clvmr 0.14.0", "hex-literal", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1299,7 +1299,7 @@ dependencies = [ "rue-compiler 0.6.0", "rue-lir 0.6.0", "rue-options 0.6.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1325,7 +1325,7 @@ dependencies = [ "rue-lir 0.8.5", "rue-options 0.8.5", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1339,7 +1339,7 @@ dependencies = [ "indexmap", "rand 0.8.7", "rand_chacha 0.3.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1354,7 +1354,7 @@ dependencies = [ "indexmap", "rand 0.9.5", "rand_chacha 0.9.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1369,7 +1369,7 @@ dependencies = [ "indexmap", "rand 0.9.5", "rand_chacha 0.9.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1492,7 +1492,7 @@ dependencies = [ "getrandom 0.4.3", "rcgen", "rsa 0.10.0-rc.18", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -1548,7 +1548,7 @@ checksum = "e012b4d5336fe5457be5157a655208a9671a41bd41c3fc72ac59c56073c21c18" dependencies = [ "chia-sha2 0.42.1", "chia_streamable_macro 0.42.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1948,7 +1948,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2561,7 +2561,7 @@ dependencies = [ "chia-wallet-sdk 0.36.0", "clvm-traits 0.36.1", "clvmr 0.16.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2576,7 +2576,7 @@ dependencies = [ "rustls-pemfile", "rustls-pki-types", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "x509-parser", @@ -2589,7 +2589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01084c5fb2b4a14db1569da4fef039de14e3db8c5588ded3504945d7333a133c" dependencies = [ "chia-protocol 0.26.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2599,7 +2599,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4651ba42e276da1b91ddb33a7f97c6b9784dd6d25d4d5877a232dfe8c7e0d49" dependencies = [ "chia-protocol 0.36.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2633,7 +2633,7 @@ dependencies = [ "clvmr 0.16.4", "dig-constants 0.10.1", "hex", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2682,7 +2682,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -2701,7 +2701,7 @@ dependencies = [ "clvm-traits 0.36.1", "clvm-utils 0.36.1", "dig-chainsource-interface 0.3.2", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2720,7 +2720,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -2762,7 +2762,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "socket2 0.5.10", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-tungstenite", @@ -2784,7 +2784,7 @@ dependencies = [ "chia-sdk-utils 0.34.0", "sha2 0.10.9", "sparse-merkle-tree", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2799,7 +2799,7 @@ dependencies = [ "chia-sdk-utils 0.36.0", "sha2 0.10.9", "sparse-merkle-tree", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2872,7 +2872,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "tracing-appender", @@ -2894,7 +2894,7 @@ dependencies = [ "clvm-traits 0.26.0", "dig-chainsource-interface 0.1.0", "hex-literal", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2937,6 +2937,29 @@ dependencies = [ "zstd", ] +[[package]] +name = "dig-mirror-coin" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f53968cacd4bbb5be4540aab0940b24a70e73b0b26f05cdfca9c8ccc6778e053" +dependencies = [ + "chia-bls 0.36.1", + "chia-protocol 0.36.1", + "chia-puzzle-types 0.36.1", + "chia-puzzles", + "chia-sdk-driver 0.36.0", + "chia-sdk-types 0.36.0", + "clvm-traits 0.36.1", + "clvm-utils 0.36.1", + "clvmr 0.16.4", + "dig-chainsource-interface 0.3.2", + "dig-mirror-collateral", + "hex-literal", + "indexmap", + "num-bigint", + "thiserror 2.0.20", +] + [[package]] name = "dig-mirror-collateral" version = "0.3.0" @@ -2944,7 +2967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1942a9df7f1a9d6f87179630995e84260533c214c18b93ccef58ba8453847c7" dependencies = [ "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2968,7 +2991,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-tungstenite", @@ -2988,7 +3011,7 @@ dependencies = [ "chia-wallet-sdk 0.36.0", "clvm-traits 0.36.1", "clvmr 0.16.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3074,10 +3097,13 @@ dependencies = [ "axum-server", "base64", "chia-bls 0.36.1", + "chia-protocol 0.36.1", "clap", "dig-cert", + "dig-chainsource-interface 0.3.2", "dig-constants 0.13.0", "dig-logging", + "dig-mirror-coin", "dig-mirror-collateral", "dig-node-control-interface", "dig-node-core", @@ -3124,7 +3150,7 @@ dependencies = [ "chia-wallet-sdk 0.36.0", "clvmr 0.16.4", "indexmap", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3138,7 +3164,7 @@ dependencies = [ "chia-puzzles", "chia-wallet-sdk 0.36.0", "clvmr 0.16.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3174,7 +3200,7 @@ dependencies = [ "chia_streamable_macro 0.36.1", "futures-util", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -3248,7 +3274,7 @@ checksum = "263eb6dbee54b80023f5a95bfd8293b43787c60222d4dc0147d418be5b1417f8" dependencies = [ "sha2 0.10.9", "sparse-merkle-tree", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3259,7 +3285,7 @@ checksum = "98310ba658af3dcfdd287da5cd0fa8608ba6ca37162f75a889f83d9e4a1df3a0" dependencies = [ "dig-smt", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3273,7 +3299,7 @@ dependencies = [ "dig-merkle", "dig-urn-protocol", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3287,7 +3313,7 @@ dependencies = [ "memmap2", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "uuid", ] @@ -3304,7 +3330,7 @@ dependencies = [ "clvmr 0.16.4", "dig-cat", "dig-constants 0.11.2", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3321,7 +3347,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki 0.102.8", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "x509-parser", @@ -3354,7 +3380,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "url", ] @@ -3436,7 +3462,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -5689,7 +5715,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5711,7 +5737,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5917,7 +5943,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6185,7 +6211,7 @@ dependencies = [ "rue-options 0.6.0", "rue-parser 0.6.0", "rue-types 0.6.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6211,7 +6237,7 @@ dependencies = [ "rue-options 0.8.5", "rue-parser 0.8.5", "rue-types 0.8.5", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6221,7 +6247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a953cccb3041f21c77e80ea8fdb03e7214b91195d939f3146f054e27020ea1a9" dependencies = [ "derive_more", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6230,7 +6256,7 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d25461b6850121106735e1abe334ecf2bf56e27244284d46ef5dfbc0e4bc2894" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6296,7 +6322,7 @@ dependencies = [ "num-integer", "sha2 0.10.9", "sha3", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6314,7 +6340,7 @@ dependencies = [ "num-integer", "sha2 0.10.9", "sha3", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6324,7 +6350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f94c541b1397b7fbc4aaba36440c0bc11ddb703b024a424dd2fe193ec413b7" dependencies = [ "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "toml 0.9.12+spec-1.1.0", ] @@ -6335,7 +6361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6c9855c49ee3f3f248004bfb2daf166ca0648a46820f95255fedb4e815b269d" dependencies = [ "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "toml 0.9.12+spec-1.1.0", ] @@ -6385,7 +6411,7 @@ dependencies = [ "log", "rstest", "rue-diagnostic 0.6.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6402,7 +6428,7 @@ dependencies = [ "log", "rstest", "rue-diagnostic 0.8.5", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -7008,7 +7034,7 @@ dependencies = [ "serde", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -7070,7 +7096,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "url", ] @@ -7230,11 +7256,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -7250,9 +7276,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -7634,7 +7660,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -8154,7 +8180,7 @@ dependencies = [ "pulley-interpreter", "smallvec", "target-lexicon", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasmparser 0.252.0", "wasmtime-environ", "wasmtime-internal-core", @@ -8879,7 +8905,7 @@ dependencies = [ "flate2", "indexmap", "memchr", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "zopfli", ] diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 35a8c982..a035e468 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -101,6 +101,17 @@ dig-node-control-interface = "0.24" # comes out of this crate so that a second derivation cannot drift from the consensus one. dig-mirror-collateral = "0.3" +# The chain half of the same model: `census` counts the collateralised network at a block height +# and hands `dig-mirror-collateral` the three integers its controller consumes (dig-node#400). +# Without it a node could only ever record epoch 1, which is derivable from nothing. +dig-mirror-coin = "0.7" + +# The canonical `ChainSource` trait `dig-mirror-coin`'s census is generic over. Declared, not +# implemented: `chia-query` already provides the implementation this node uses +# (`ChiaQueryProvider`), reached through `dig-wallet`'s one shared chain transport. Caret-matched +# to the line `chia-query` and `dig-mirror-coin` compiled against, so all three unify on one crate. +dig-chainsource-interface = "0.3" + # The OS CSPRNG for all authorization material — the control token, pairing ids/tokens # (§7), and the relay loop-probe id (`control::fill_random`). Wraps `getrandom(2)` / # `/dev/urandom` on Unix and `BCryptGenRandom` on Windows, one code path on every @@ -256,6 +267,11 @@ dig-wallet = { path = "../dig-wallet" } # (its series skips 0.32 -> 0.42) and pins the primitives to its own version, so it cannot # sit on the chia-wallet-sdk ceiling without re-splitting the family (dig_ecosystem#3161). chia-bls = "0.36.1" +# The census-runner unit tests implement `ChainSource` over a double, whose method signatures are +# spelled in `chia-protocol` types. The SAME 0.36.1 line `dig-chainsource-interface` and +# `dig-mirror-coin` compile against -- a second line would make the double's `Bytes32` a different +# type from the trait's and the impl would not apply. +chia-protocol = "0.36.1" hex = "0.4" # The crate's own integration tests need its test-construction seams (the `testkit` feature above). # Depending on self with the feature on is what keeps those seams available to `tests/` while staying diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs new file mode 100644 index 00000000..08591977 --- /dev/null +++ b/crates/dig-node-service/src/collateral_census.rs @@ -0,0 +1,548 @@ +//! **The census runner** — the half of the collateral record store that reads the chain. +//! +//! [`crate::collateral`] owns what a node STORES and what it will serve; this module owns how a +//! record for an epoch after the first comes to exist at all. Before it, a node's store held +//! exactly one record — [`dig_mirror_collateral::EpochRecord::bootstrap`], which is derivable from +//! nothing — so `control.collateral.requirement` answered `unknown / not_censused` for the current +//! epoch correctly and permanently (dig-node#400). +//! +//! # Nothing here is arithmetic +//! +//! Every number this module writes is produced by a call: [`dig_mirror_coin::census`] counts the +//! network, and [`dig_mirror_collateral::EpochRecord::advance`] derives the record. Restating +//! either — even as an apparently harmless `equilibrium × multiplier − handicap` — drops the floor +//! clamp and yields a second price the network does not agree with. This module chooses WHICH +//! epochs to compute and WHERE to put the answers, and nothing else. +//! +//! # A census that could not be taken is never a figure +//! +//! Each reason a catch-up stops is a distinct [`CensusStop`] variant carrying what an operator +//! would need to act on it, and a stop writes NOTHING. There is no default record, no zeroed +//! census, and no reuse of a neighbouring epoch's answer: those are all ways of turning "this node +//! could not look" into a number that reads exactly like one it did look up. The store's own +//! absence then surfaces as `unknown` with its reason, which is the honest answer. +//! +//! # The walk is sequential because the model is +//! +//! Epoch *n*'s record is derived from epoch *n-1*'s, so a node cannot skip forward to the current +//! epoch: it computes each intervening epoch in order, from the newest record it holds. A node +//! that has been offline for three epochs performs three censuses, at three heights consensus +//! 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, MirrorError}; + +use crate::collateral::{ + EpochRecordStore, PutOutcome, StoredEpoch, StoredRecord, GENESIS_EPOCH, +}; + +/// Why a catch-up stopped before reaching the target epoch. +/// +/// Every variant is a reason to write nothing, and each names a DIFFERENT remedy — which is why +/// they are not collapsed into one string. "Wait for the chain to bury the census height" and +/// "this node cannot reach a chain at all" are the same silence to a caller who only sees +/// `unknown`, and opposite situations to an operator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CensusStop { + /// A chain read could not be answered. The census is absent, not empty. + ChainUnavailable { + /// The epoch being computed when the read failed. + epoch: u64, + /// The source's own words. + detail: String, + }, + + /// The chain has not yet reached the epoch's start instant. + /// + /// The ordinary state of an epoch that has begun by the clock but not yet on chain, and not an + /// error: the remedy is to wait for a block. + EpochNotStartedOnChain { + /// The epoch whose start the chain has not reached. + epoch: u64, + }, + + /// The census height is not yet buried deeply enough for its answer to be safe to act on. + /// + /// A census taken at the tip is reorg-sensitive and this is a money path. The remedy is only to + /// wait — roughly ten minutes out of a seven-day epoch. + BehindFinalityDepth { + /// The epoch that would be censused. + epoch: u64, + /// The height it would be censused at. + census_height: u32, + /// The source's current peak. + peak_height: u32, + }, + + /// The candidate population at the shared mirror puzzle hash exceeds what can be authenticated. + /// + /// `dig-mirror-coin` REFUSES rather than censusing a prefix, because a prefix of an + /// attacker-writable set is a censorship primitive and two nodes keeping different prefixes + /// fork. This node reports the refusal for the same reason. + PopulationTooLarge { + /// The epoch that would be censused. + epoch: u64, + /// The height it would be censused at. + census_height: u32, + /// How many coins pay to the shared mirror puzzle hash. + candidates: usize, + /// How many distinct creating spends the survivors would have needed executed. + creating_spends: usize, + /// The bound that was exceeded. + limit: usize, + }, + + /// The predecessor epoch is not recorded, so there is nothing to derive this one from. + /// + /// Unreachable from the shipped bring-up, which writes the genesis record before this runs, and + /// kept because a store pruned to a retention boundary can reach it. + PriorEpochMissing { + /// The epoch whose record is absent. + epoch: u64, + }, + + /// The predecessor epoch's stored line could not be read. + PriorEpochUnreadable { + /// The epoch whose record could not be read. + epoch: u64, + }, + + /// The predecessor record names a ruleset this build does not implement. + /// + /// The protocol-version ceiling, applied at the CENSUS boundary as well as at the serve and + /// gossip boundaries. Deriving a successor from a record whose arithmetic this build does not + /// have would compute a figure under the wrong ruleset and store it as though it were checked. + PriorEpochUninterpretable { + /// The epoch whose record names the unimplemented ruleset. + epoch: u64, + /// The version it names. + protocol_version: u16, + }, + + /// The controller refused to derive the record. + Arithmetic { + /// The epoch being derived. + epoch: u64, + /// The controller's own words. + detail: String, + }, + + /// This node already holds a DIFFERENT record for the epoch it just computed. + /// + /// History is immutable, so the held record stands and the walk stops: every later epoch would + /// be derived from a record this node and its own store disagree about. + Contradiction { + /// The epoch the stored record disagrees about. + epoch: u64, + }, + + /// The record store could not be read or written. + Store { + /// The underlying I/O error. + detail: String, + }, +} + +/// What one catch-up did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatchUp { + /// The epochs newly recorded, in ascending order. Empty is the ordinary steady state: a node + /// already current has nothing to compute and performs no chain read at all. + pub recorded: Vec, + /// Why the walk stopped short of the target, or `None` when it reached it. + pub stopped: Option, +} + +/// Compute and record every epoch from the newest one `store` holds up to `target_epoch`. +/// +/// Returns what it recorded and, when it stopped early, why. **A stop writes nothing**: this +/// function never invents, defaults, or carries forward a figure. +/// +/// The walk performs NO chain read when the store is already current, so calling it on a timer is +/// cheap in the steady state. +/// +/// # The census height is derived, never chosen +/// +/// Each epoch is censused at the first transaction block at or after that epoch's start instant +/// ([`dig_mirror_coin::census_height`]). Every node therefore censuses at the same height without +/// coordinating, which is the property that makes the requirement a consensus figure rather than +/// each node's local opinion. +pub fn catch_up( + source: &S, + store: &EpochRecordStore, + target_epoch: u64, +) -> CatchUp { + let mut recorded = Vec::new(); + + let highest = match highest_recorded(store) { + Ok(highest) => highest, + Err(detail) => { + return CatchUp { + recorded, + stopped: Some(CensusStop::Store { detail }), + } + } + }; + + for epoch in (highest + 1)..=target_epoch { + match record_one(source, store, epoch) { + Ok(()) => recorded.push(epoch), + Err(stopped) => { + return CatchUp { + recorded, + stopped: Some(stopped), + } + } + } + } + + CatchUp { + recorded, + stopped: None, + } +} + +/// The newest epoch `store` holds a record for. +/// +/// Falls back to [`GENESIS_EPOCH`] when the store holds nothing, so a walk from a store the +/// bring-up has not yet seeded still starts at the only epoch that could follow one. +fn highest_recorded(store: &EpochRecordStore) -> Result { + let records = store.records().map_err(|e| e.to_string())?; + Ok(records + .iter() + .map(|stored| stored.record.epoch) + .max() + .unwrap_or(GENESIS_EPOCH)) +} + +/// Census `epoch` and write its record, or say why it could not be written. +fn record_one( + source: &S, + store: &EpochRecordStore, + epoch: u64, +) -> Result<(), CensusStop> { + let prior = prior_record(store, epoch)?; + + let at = match census_height(source, epoch_start_unix_secs(epoch)) { + 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) { + Ok(CensusOutcome::Final(counted)) => counted, + Ok(CensusOutcome::Pending { + census_height, + peak_height, + }) => { + return Err(CensusStop::BehindFinalityDepth { + epoch, + census_height, + peak_height, + }) + } + Ok(CensusOutcome::Incomplete { + census_height, + candidates, + creating_spends, + limit, + }) => { + return Err(CensusStop::PopulationTooLarge { + epoch, + census_height, + candidates, + creating_spends, + limit, + }) + } + Err(e) => return Err(chain_stop(epoch, e)), + }; + + // The whole derivation, in one call. `advance` applies this epoch's ruleset, re-checks the + // protocol version on both sides, and produces the record — including the floor clamp that a + // restated formula loses. + let record = prior + .advance(counted.census()) + .map_err(|e| CensusStop::Arithmetic { + epoch, + detail: e.to_string(), + })?; + + // `censused`, with the height the census was actually taken at — the two facts that make this + // record auditable and distinguish it from one adopted from peers. + let stored = StoredRecord::censused(record, counted.height()); + match store.put(&stored) { + Ok(PutOutcome::Written | PutOutcome::AlreadyPresent) => Ok(()), + Ok(PutOutcome::Conflict { .. }) => Err(CensusStop::Contradiction { epoch }), + Err(e) => Err(CensusStop::Store { + detail: e.to_string(), + }), + } +} + +/// The record `epoch` is derived from — epoch `epoch - 1` — refusing anything this build cannot +/// interpret. +fn prior_record( + store: &EpochRecordStore, + epoch: u64, +) -> Result { + let prior_epoch = epoch.saturating_sub(1); + match store.get(prior_epoch) { + StoredEpoch::Found(stored) if !stored.is_interpretable() => { + Err(CensusStop::PriorEpochUninterpretable { + epoch: prior_epoch, + protocol_version: stored.record.protocol_version.0, + }) + } + StoredEpoch::Found(stored) => Ok(stored.record), + StoredEpoch::Absent => Err(CensusStop::PriorEpochMissing { epoch: prior_epoch }), + StoredEpoch::Unreadable => Err(CensusStop::PriorEpochUnreadable { epoch: prior_epoch }), + } +} + +/// Map a [`MirrorError`] onto the stop it describes. +/// +/// Every variant lands on [`CensusStop::ChainUnavailable`] deliberately: from this walk's point of +/// view a malformed answer and an unreachable source are the same fact — the census could not be +/// taken — and neither may become a figure. The error's own words carry the difference to the log. +fn chain_stop(epoch: u64, error: MirrorError) -> CensusStop { + CensusStop::ChainUnavailable { + epoch, + detail: error.to_string(), + } +} + +/// The Unix-second instant `epoch` begins at. +/// +/// Delegated to `dig_constants::mirror_epoch_start_unix_ms`, never re-derived: the epoch schedule +/// is a consensus fact, and a node that placed an epoch boundary one second differently would +/// census at a different height and derive a different requirement from the same chain. +/// +/// `div_euclid` rather than `/`, so an instant before the Unix epoch would floor rather than +/// truncate toward zero. No epoch in the schedule is before 1970; the operator is chosen for the +/// property rather than for the case, because a truncating divide is silently wrong only where it +/// is hard to notice. +fn epoch_start_unix_secs(epoch: u64) -> u64 { + let start_ms = dig_constants::mirror_epoch_start_unix_ms(epoch as i64); + start_ms.div_euclid(1_000).max(0) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_chainsource_interface::CoinRecord; + use dig_mirror_collateral::EpochRecord; + use std::cell::RefCell; + + /// A source that answers nothing, for the reason it was asked. + /// + /// It counts its reads, because "the walk stopped" and "the walk never looked" are different + /// outcomes that produce the same empty store. + struct UnreachableSource { + reads: RefCell, + } + + impl UnreachableSource { + fn new() -> Self { + Self { + reads: RefCell::new(0), + } + } + + fn reads(&self) -> u32 { + *self.reads.borrow() + } + + fn refuse(&self) -> Result { + *self.reads.borrow_mut() += 1; + Err("no chain source reachable".to_string()) + } + } + + impl ChainSource for UnreachableSource { + type Error = String; + + fn coin_record( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.refuse() + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: chia_protocol::Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + self.refuse() + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.refuse() + } + + fn coin_spend( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.refuse() + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.refuse() + } + + fn peak_height(&self) -> Result, Self::Error> { + self.refuse() + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + self.refuse() + } + } + + /// A store in a fresh temp dir, holding only the genesis record the bring-up writes. + fn seeded_store(name: &str) -> (EpochRecordStore, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "dig-node-census-{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("create the scratch dir"); + let store = EpochRecordStore::at(dir.join("epochs.jsonl")); + store + .put(&StoredRecord::bootstrap()) + .expect("seed the genesis record"); + (store, dir) + } + + /// **A node that cannot reach a chain records NOTHING and says why.** + /// + /// The defect this whole module exists to avoid re-introducing: an unreachable source must not + /// become an empty census, which `advance` would happily turn into a real-looking requirement. + /// The assertion is on the STORE as well as the report — a stop that still wrote a record would + /// satisfy a report-only assertion. + #[test] + fn an_unreachable_chain_records_nothing_and_names_the_reason() { + let (store, dir) = seeded_store("unreachable"); + let source = UnreachableSource::new(); + + let outcome = catch_up(&source, &store, 5); + + assert!( + outcome.recorded.is_empty(), + "a node that could not read the chain recorded {:?}", + outcome.recorded + ); + assert!( + matches!( + outcome.stopped, + Some(CensusStop::ChainUnavailable { epoch: 2, .. }) + ), + "expected an unavailable-chain stop on the first uncomputed epoch, got {:?}", + outcome.stopped + ); + assert!(source.reads() > 0, "the walk never attempted a chain read"); + assert!( + matches!(store.get(2), StoredEpoch::Absent), + "epoch 2 was written despite the census failing" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + /// **A node already current performs NO chain read.** + /// + /// The steady state, and the property that makes running this on a timer cheap. It is asserted + /// against a source that fails on every read: if the walk touched the chain at all the target + /// would be unreachable and `stopped` would be `Some`. + #[test] + fn a_store_already_at_the_target_reads_no_chain() { + let (store, dir) = seeded_store("current"); + let source = UnreachableSource::new(); + + let outcome = catch_up(&source, &store, GENESIS_EPOCH); + + assert_eq!(outcome.recorded, Vec::::new()); + assert_eq!(outcome.stopped, None, "a current store stopped for a reason"); + assert_eq!( + source.reads(), + 0, + "a current store still performed a chain read" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + /// **The walk refuses to derive a successor from a record whose ruleset this build lacks.** + /// + /// The protocol-version ceiling at the census boundary. Without it, a record written under a + /// future ruleset would be advanced under v1 arithmetic and the result stored as `censused` — + /// a figure this node never had the rules to compute, wearing the provenance of one it did. + /// + /// The prior epoch is 2 rather than 1 so the refusal cannot be confused with the genesis + /// record's own handling, and the target is 4 so a walk that ignored the ceiling would have a + /// further epoch to attempt. + #[test] + fn a_prior_record_from_an_unimplemented_ruleset_is_refused() { + let (store, dir) = seeded_store("ceiling"); + + // A record for epoch 2 naming a ruleset far beyond anything implemented. Every field of it + // parses, which is exactly why the ceiling has to be checked rather than inferred. + let mut future = EpochRecord::bootstrap(); + future.epoch = 2; + future.protocol_version = dig_mirror_collateral::ProtocolVersion(u16::MAX); + store + .put(&StoredRecord::censused(future, 1_000)) + .expect("write the future-ruleset record"); + + let source = UnreachableSource::new(); + let outcome = catch_up(&source, &store, 4); + + assert!(outcome.recorded.is_empty()); + assert_eq!( + outcome.stopped, + Some(CensusStop::PriorEpochUninterpretable { + epoch: 2, + protocol_version: u16::MAX, + }), + ); + assert_eq!( + source.reads(), + 0, + "the ceiling was checked after a chain read rather than before one" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + /// **Epoch starts come from the schedule, and consecutive epochs are one epoch length apart.** + /// + /// Pinned because this is the one arithmetic in the module, and a census height derived from a + /// boundary one second out is a fork. Both sides are asserted: the absolute instant of epoch 1 + /// (so a wrong genesis is caught) and the spacing (so a wrong length is). + #[test] + fn epoch_starts_follow_the_published_schedule() { + let first = epoch_start_unix_secs(1); + let second = epoch_start_unix_secs(2); + + assert_eq!( + first, + (dig_constants::MIRROR_EPOCH_GENESIS_UNIX_MS.div_euclid(1_000)) as u64, + "epoch 1 does not start at the published genesis instant" + ); + assert_eq!( + second - first, + (dig_constants::MIRROR_EPOCH_LENGTH_MS.div_euclid(1_000)) as u64, + "consecutive epoch starts are not one epoch length apart" + ); + } +} diff --git a/crates/dig-node-service/src/collateral_sync.rs b/crates/dig-node-service/src/collateral_sync.rs index 85b3e00f..eac3d16a 100644 --- a/crates/dig-node-service/src/collateral_sync.rs +++ b/crates/dig-node-service/src/collateral_sync.rs @@ -313,6 +313,18 @@ pub fn adopt( responders, }; } + // THE COLLECTOR'S OBLIGATION, stated where the refusal is rather than where it would be + // discovered. This refusal is a BACKSTOP: it protects the threshold's meaning, and it protects + // it by refusing the whole sample. A collector that asked every peer it could reach and passed + // the lot in would therefore be denied adoption by any single extra identity answering once — + // the bound would be enforced against the honest node instead of the attacker. + // + // So whoever wires the requesting half MUST stop drawing at `sync_sample_plan(population) + // .sample_size` DISTINCT responders, chosen by this node. Truncating the responses here + // instead would keep a prefix of a set the attacker contributed to, which is exactly what + // `PopulationExceeded` above refuses to do. **No such collector exists yet** — as of this + // revision `dig.getCollateralEpoch` is served but never requested, so this paragraph is a + // contract for the wiring and not a description of one. // Verify, then tally by the FULL record. Two records that agree on the requirement but differ // anywhere else are different answers, and counting them together would let a disagreement diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index f6d09295..e482b812 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -36,6 +36,9 @@ pub mod cli; /// safety margin, and the funding advice built on them. Every figure comes out of /// `dig-mirror-collateral`; no formula is restated. pub mod collateral; +/// The census runner: the chain reads that let a node record an epoch after the first, and the +/// named reasons it declines to record one. Every stop writes nothing. +pub mod collateral_census; /// Adopting an epoch history from untrusted peers, and serving one to them: the /// re-derivation every candidate record must survive, and the sampling plan that bounds /// what a sample of peers is allowed to decide. diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 18155e61..3df4e7a4 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -100,6 +100,13 @@ pub struct AppState { /// The shared self-signed cert the wallet mTLS listener presents (Sage byte-parity, node-class /// clients). Held so [`serve_with_shutdown`] can bring up that sibling listener. wallet_cert: SharedCert, + /// The node's ONE chain transport, shared with the wallet rather than built beside it. + /// + /// Held so the collateral census (#400) can take a + /// [`ChainSource`](dig_chainsource_interface::ChainSource) view of the same peer pool the + /// wallet's reads ride. Building a second one would give a live node two independent pools with + /// two notions of the peak — the defect dig_ecosystem#2761 removed. + wallet_chain: Arc, /// The per-source INGRESS bound on OPEN, token-less `control.*` reads (dig_ecosystem#3051). /// /// The open reads present no credential, so before this existed an anonymous caller could @@ -580,6 +587,7 @@ pub async fn build_state(config: &Config) -> AppState { )), wallet: wallet_service.backend, wallet_cert: wallet_service.cert, + wallet_chain: wallet_service.chain, control_ingress: Arc::new(dig_node_core::rate_limit::MissRateLimiter::new( CONTROL_INGRESS_BURST, CONTROL_INGRESS_REFILL_PER_SEC, @@ -2107,6 +2115,18 @@ where // node in the network — a correct answer, and a permanently unchanging one. bring_up_collateral_records(); + // The CENSUS half (#400). `bring_up_collateral_records` writes epoch 1, which is derivable + // from nothing; this is what lets the node record epoch n. It runs detached and on a timer + // because a census depends on the chain having moved: an epoch that has begun by the clock is + // not yet censusable until a block carries its start instant and the census height is buried. + // + // Gated on `enable_chain_sync` for the same reason the wallet's sync is: that flag already + // means "this node talks to the Chia network", and an integration harness sets it false + // precisely so nothing dials. + if config.enable_chain_sync { + spawn_collateral_census(state.wallet_chain.clone()); + } + // §14 autonomous sync (#213): bring up the L7 peer network — the connected peer // pool, the content-location DHT + P2P content engine, PEX, and the chain-watch + // generation gap-fill loop — so a running node tracks the chain and PROACTIVELY @@ -2563,6 +2583,89 @@ fn bring_up_collateral_records() { } } + +/// How often the census runner re-attempts a catch-up. +/// +/// One mirror ROUND, taken from the schedule rather than written as a duration: the round is the +/// grain the mirror model already moves on, and a retune of it should carry this with it. It is +/// also comfortably shorter than the finality depth a `BehindFinalityDepth` stop waits out, so a +/// node that was a few blocks early records the epoch on its next pass rather than at the next +/// epoch. +/// +/// Re-attempting costs nothing once a node is current: `catch_up` performs NO chain read when the +/// store already holds the target epoch. +const COLLATERAL_CENSUS_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(dig_constants::MIRROR_ROUND_LENGTH_MS as u64); + +/// Run the collateral census on a timer, against the node's own chain transport. +/// +/// Detached and best-effort, exactly like the record bring-up above: a node that cannot census +/// still serves content, and every outcome is logged rather than swallowed. **A failure records +/// nothing** — `control.collateral.requirement` then answers `unknown` with its reason, which is +/// the honest answer and the one this node can defend. +/// +/// The provider's reads are synchronous, so each pass runs inside [`tokio::task::spawn_blocking`] +/// and never occupies an async worker. +fn spawn_collateral_census(chain: Arc) { + use crate::collateral::{current_epoch_now, CurrentEpoch, EpochRecordStore}; + + tokio::spawn(async move { + loop { + // Read the epoch on EVERY pass, not once: this task outlives an epoch boundary, and a + // target captured at start-up would leave the node permanently one epoch behind from + // the moment the schedule rolled over. + let CurrentEpoch::Final(target) = current_epoch_now() else { + tokio::time::sleep(COLLATERAL_CENSUS_INTERVAL).await; + continue; + }; + + match chain.chain_source(tokio::runtime::Handle::current()).await { + Ok(source) => { + let pass = tokio::task::spawn_blocking(move || { + crate::collateral_census::catch_up( + &source, + &EpochRecordStore::in_state_dir(), + target, + ) + }) + .await; + + match pass { + Ok(outcome) => { + if !outcome.recorded.is_empty() { + tracing::info!( + epochs = ?outcome.recorded, + "censused the collateral network and recorded the epoch(s)" + ); + } + if let Some(stop) = outcome.stopped { + // WARN rather than ERROR: several stops — an epoch the chain has + // not reached, a census height not yet buried — are ordinary + // states whose remedy is to wait. The variant says which. + tracing::warn!( + target_epoch = target, + reason = ?stop, + "the collateral census stopped short of the current epoch; no record was written" + ); + } + } + Err(e) => tracing::warn!( + error = %e, + "the collateral census task did not complete" + ), + } + } + Err(e) => tracing::warn!( + error = %e, + "no chain source for the collateral census; the requirement stays unknown" + ), + } + + tokio::time::sleep(COLLATERAL_CENSUS_INTERVAL).await; + } + }); +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/dig-wallet/src/sage/chain.rs b/crates/dig-wallet/src/sage/chain.rs index 22a81764..dd2b9670 100644 --- a/crates/dig-wallet/src/sage/chain.rs +++ b/crates/dig-wallet/src/sage/chain.rs @@ -35,6 +35,11 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use chia_protocol::SpendBundle; +// Through `chia-query`'s own re-export rather than a second declaration of +// `dig-chainsource-interface`: the provider descriptor must be the SAME type `chia-query` compiled +// against, and a separately declared version could resolve to a different line of the crate and +// fail to unify (§2.4b's split-family trap). +use chia_query::provider_registry::interface::{ProviderId, ProviderInfo, ProviderKind}; use super::fallback::{ ChainFallback, ChainPeerTier, CoinsetFallback, FallbackCoin, FallbackCoinSpend, @@ -166,6 +171,13 @@ pub trait SignedBundlePusher: Send + Sync { async fn push(&self, bundle: &SpendBundle) -> Result; } +/// The stable identifier this node's chain source registers itself under. +/// +/// Named once rather than written at the construction site: an identifier a registry orders and +/// de-duplicates on is a contract with whatever reads it, and two spellings of it are two +/// providers. +pub const CHAIN_SOURCE_PROVIDER_ID: &str = "dig-node/chia-query"; + /// A shared, lazily-built `chia_query` client serving the wallet's chain reads and its push. /// /// Also the wallet's [`ChainFallback`] tier, so a balance, a coin read and a push all speak to ONE @@ -240,6 +252,49 @@ impl ChainTransport { self.client().await } + /// This transport's chain reads presented as the canonical + /// [`ChainSource`](chia_query::provider_registry::interface::ChainSource) — the trait every DIG consumer of + /// chain state depends on. + /// + /// It is a VIEW of the one shared client, never a second one. `chia-query` already implements + /// the trait ([`ChiaQueryProvider`]), so nothing here re-derives a chain read; building a + /// separate source for a new consumer is what gave a live node two independent peer pools with + /// two notions of the peak (dig_ecosystem#2761), and this method exists so the next consumer + /// cannot repeat it. + /// + /// `handle` MUST belong to a **multi-thread** tokio runtime: the returned provider is + /// synchronous and bridges each read with `run_blocking`, which fails closed with a clear + /// error on a current-thread runtime rather than deadlocking. An async caller must additionally + /// wrap each read in [`tokio::task::spawn_blocking`] so a blocking read never occupies an async + /// worker. + /// + /// # Errors + /// + /// The lazy client build — i.e. this node could not reach a chain at all. Not cached, so a + /// later call tries again. + pub async fn chain_source( + &self, + handle: tokio::runtime::Handle, + ) -> Result { + let client = self.shared_client().await?; + Ok(chia_query::provider_registry::ChiaQueryProvider::new( + client, + handle, + ProviderInfo { + id: ProviderId(std::borrow::Cow::Borrowed(CHAIN_SOURCE_PROVIDER_ID)), + // `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. Naming either would + // describe the source's trust posture more precisely than this node can observe. + kind: ProviderKind::Custom, + priority: 0, + // Answers are believed because the tier that produced them was believed, not + // because they carry a proof this node checked. + trustless: false, + }, + )) + } + /// A transport that already HAS its client, so nothing in the test dials. /// /// Seeding the client is what makes pointer identity assertable: a consumer that quietly built diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs index d6298bc8..e0855eaa 100644 --- a/crates/dig-wallet/src/sage/service.rs +++ b/crates/dig-wallet/src/sage/service.rs @@ -121,6 +121,15 @@ pub struct WalletService { /// within one puzzle-hash poll; when addresses are already subscribed, additional /// registrations take effect at the next reconnect (dig_ecosystem#2826). pub watchlist: WatchRegistry, + /// The node's ONE chain transport, exposed so a consumer outside the wallet can take a + /// [`ChainSource`](chia_query::provider_registry::interface::ChainSource) view of it + /// ([`ChainTransport::chain_source`]) instead of building a second client. + /// + /// Exposed rather than duplicated on purpose. The collateral census (dig-node#400) needs chain + /// reads, and every other way of giving it some would have added a second peer pool with its + /// own notion of the peak — the defect dig_ecosystem#2761 removed. Sharing the transport keeps + /// exactly one. + pub chain: Arc, } impl WalletService { @@ -322,6 +331,7 @@ impl WalletService { cert, sync, watchlist, + chain, } } } From f6ac287a2d6cd35277b265374a6fea6343492b33 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 22:03:55 -0700 Subject: [PATCH 2/7] docs(spec): state how a record for an epoch after the first is produced Refs: https://github.com/DIG-Network/dig-node/issues/400 Co-Authored-By: Claude --- SPEC.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/SPEC.md b/SPEC.md index 3c9d16fd..7018218b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7666,6 +7666,45 @@ authoritative** — not by `control.collateral.requirement`, not by `dig.getColl into a verification. Every field of such a record parses, so nothing downstream would question its figures. +### 24.8a. Producing a record: the census + +A record for an epoch after the first MUST be produced by counting the chain, and by nothing else. + +The node MUST obtain the three census inputs from `dig_mirror_coin::census`, at the height +`dig_mirror_coin::census_height` derives for that epoch — the first transaction block at or after +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 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 with one notion of the +peak. + +**The walk is sequential.** Epoch *n* is derived from epoch *n-1*, so the node computes each +intervening epoch in order from the newest record it holds. It MUST NOT skip forward to the current +epoch, and it MUST NOT derive a successor from a record whose `protocol_version` exceeds what the +build implements — the ceiling of §24.8 applies at this boundary too. + +**A census that could not be taken MUST record nothing.** Each refusal is reported with its own +reason and its own remedy: + +| the census stopped because | remedy | +|---|---| +| a chain read could not be answered | reach a chain source | +| the chain has not yet reached the epoch's start | wait for a block | +| the census height is not yet buried to `CENSUS_FINALITY_DEPTH_BLOCKS` | wait | +| the candidate population exceeds what can be authenticated | refused whole; never censused as a prefix | +| the predecessor record is absent, unreadable, or names an unimplemented ruleset | that epoch first | +| the store already holds a DIFFERENT record for the computed epoch | the held record stands | + +None of these MUST EVER become a figure — not a zero, not a default, and not the neighbouring +epoch's answer. The store's own absence then surfaces through §24.2's `unknown` with its reason, +which is the only answer such a node can defend. + +Re-attempting MUST be cheap in the steady state: a node whose store already holds the target epoch +performs **no chain read at all**. + ### 24.9. Serving an epoch to a peer `dig.getCollateralEpoch` is an OPEN node method taking `{ epoch }` and returning `{ record }` or From 2c8610e63be9d171f9077d6362bc81a6a89746d5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 22:28:13 -0700 Subject: [PATCH 3/7] chore(release): v0.166.0 Refs: https://github.com/DIG-Network/dig-node/issues/400 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acf0cfa9..ab1e856a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.165.0" +version = "0.166.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 6e3e5238..19532f75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.165.0" +version = "0.166.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From a5f75a1a1458955b079c1eb85d98cdcbc4cd356e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 22:30:22 -0700 Subject: [PATCH 4/7] style: cargo fmt Co-Authored-By: Claude --- crates/dig-node-service/src/collateral_census.rs | 9 +++++---- crates/dig-node-service/src/server.rs | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs index 08591977..eb110dec 100644 --- a/crates/dig-node-service/src/collateral_census.rs +++ b/crates/dig-node-service/src/collateral_census.rs @@ -32,9 +32,7 @@ use dig_chainsource_interface::ChainSource; use dig_mirror_coin::{census, census_height, CensusOutcome, MirrorError}; -use crate::collateral::{ - EpochRecordStore, PutOutcome, StoredEpoch, StoredRecord, GENESIS_EPOCH, -}; +use crate::collateral::{EpochRecordStore, PutOutcome, StoredEpoch, StoredRecord, GENESIS_EPOCH}; /// Why a catch-up stopped before reaching the target epoch. /// @@ -472,7 +470,10 @@ mod tests { let outcome = catch_up(&source, &store, GENESIS_EPOCH); assert_eq!(outcome.recorded, Vec::::new()); - assert_eq!(outcome.stopped, None, "a current store stopped for a reason"); + assert_eq!( + outcome.stopped, None, + "a current store stopped for a reason" + ); assert_eq!( source.reads(), 0, diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 3df4e7a4..47c6130c 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2583,7 +2583,6 @@ fn bring_up_collateral_records() { } } - /// How often the census runner re-attempts a catch-up. /// /// One mirror ROUND, taken from the schedule rather than written as a duration: the round is the From 22d3c7d5e697e6b1cdf52e11caa0d83ccb41b9f5 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 23:09:16 -0700 Subject: [PATCH 5/7] fix(collateral): report what the census examined, and stop a rotted line 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 --- SPEC.md | 23 +- .../dig-node-service/src/collateral_census.rs | 271 +++++++++++++++++- crates/dig-node-service/src/server.rs | 31 +- crates/dig-wallet/src/sage/chain.rs | 31 +- 4 files changed, 337 insertions(+), 19 deletions(-) diff --git a/SPEC.md b/SPEC.md index 7018218b..68912f00 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7678,8 +7678,20 @@ any other way is a fork, because every node must reach the same height without c 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 with one notion of the -peak. +transport that already serves its wallet reads, so a node holds ONE peer pool. + +**One pool is not one notion of the peak, and this node has three.** The wallet's peak is settled by +NC-12 agreement across the full nodes this node dialled itself, and their failure to agree is +reported as not knowing. The census's peak is not: it is read through the `ChainSource`, whose router +asks `api.coinset.org` FIRST and consults this node's peers only when that read fails — so on a +reachable oracle the census's peak is one HTTPS endpoint's answer, and when the oracle is +unreachable it is a peer-tracked value carried with NO agreement step. That is the peak the +`CENSUS_FINALITY_DEPTH_BLOCKS` check above is measured against. + +A census provider MUST therefore be classified by what it can REACH rather than by its type: a +fabric that can fall through to the oracle shares the oracle's independence group, however many +peers it holds. A node MUST NOT count such a provider as an independent chain source, and MUST NOT +describe the census's peak as corroborated. **The walk is sequential.** Epoch *n* is derived from epoch *n-1*, so the node computes each intervening epoch in order from the newest record it holds. It MUST NOT skip forward to the current @@ -7697,6 +7709,13 @@ reason and its own remedy: | the candidate population exceeds what can be authenticated | refused whole; never censused as a prefix | | the predecessor record is absent, unreadable, or names an unimplemented ruleset | that epoch first | | the store already holds a DIFFERENT record for the computed epoch | the held record stands | +| the controller refused to derive the record from the census | a build that implements the ruleset | +| the record store could not be read or written | the state directory | +| the store's own line for the computed epoch cannot be read | repair or remove that line | + +The last of these MUST be detected BEFORE the chain is read. An unreadable line is invisible to the +scan that answers "the newest epoch held", so a node that did not check would recensus that epoch on +every attempt — the whole population and its spend executions — only to fail at the write, forever. None of these MUST EVER become a figure — not a zero, not a default, and not the neighbouring epoch's answer. The store's own absence then surfaces through §24.2's `unknown` with its reason, diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs index eb110dec..ba56ba02 100644 --- a/crates/dig-node-service/src/collateral_census.rs +++ b/crates/dig-node-service/src/collateral_census.rs @@ -22,6 +22,15 @@ //! could not look" into a number that reads exactly like one it did look up. The store's own //! absence then surfaces as `unknown` with its reason, which is the honest answer. //! +//! # A census that counted nothing says what it examined +//! +//! A stop is not the only ambiguous outcome. A census that SUCCEEDS and counts zero stores is +//! produced identically by an empty network, by a source answering at the wrong puzzle hash, and by +//! a degraded source whose candidates' creating spends were all unavailable — three situations with +//! opposite remedies, on the path that decides how much collateral this node posts. The walk +//! therefore carries [`CensusObservation`] out for every epoch it records, so the node reports what +//! was examined and why candidates were excluded, and not only the figure it arrived at. +//! //! # The walk is sequential because the model is //! //! Epoch *n*'s record is derived from epoch *n-1*'s, so a node cannot skip forward to the current @@ -30,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, MirrorError}; +use dig_mirror_coin::{census, census_height, CensusOutcome, Exclusions, MirrorError}; use crate::collateral::{EpochRecordStore, PutOutcome, StoredEpoch, StoredRecord, GENESIS_EPOCH}; @@ -139,14 +148,60 @@ pub enum CensusStop { /// The underlying I/O error. detail: String, }, + + /// The store already holds a line for the epoch being computed, and that line cannot be read. + /// + /// Distinct from [`Self::Store`], which is an I/O fault, and from [`Self::Contradiction`], + /// which is a readable disagreement — this is one rotted line, and its remedy is to repair or + /// remove that line. + /// + /// It is checked BEFORE any chain read, and it is the reason the walk is not silent here. + /// [`EpochRecordStore::records`] SKIPS unparseable lines while + /// [`EpochRecordStore::get`] reports them, so a line for epoch *n* truncated by a crash or a + /// full disk leaves `highest_recorded` answering *n-1*. Without this check the walk would + /// recompute *n* — a whole population read and its spend executions — and then fail at `put`, + /// on every timer tick, forever, advancing nothing and reporting only a generic store fault. + EpochLineUnreadable { + /// The epoch whose stored line cannot be read. + epoch: u64, + }, +} + +/// What one epoch's census found, beyond the figures that went into its record. +/// +/// # Why a count of nothing is not self-explanatory +/// +/// `stores: 0` has at least three causes that call for opposite responses: the network is +/// genuinely empty; the source answered at the WRONG puzzle hash (`foreign_puzzle`); or every +/// candidate's creating spend was unavailable, so a DEGRADED source described a smaller network +/// than exists (`unreadable`). The record alone cannot tell them apart, and this is the path that +/// decides how much collateral a node posts — so the census's own account of what it examined and +/// why candidates were dropped is carried out of the walk and reported, rather than discarded at +/// the point the record is written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CensusObservation { + /// The epoch that was censused and recorded. + pub epoch: u64, + /// The height it was censused at. + pub census_height: u32, + /// The qualifying advertisement count the record was derived from. + pub stores: u64, + /// How many candidate coins at the shared mirror puzzle hash were examined to reach it. + /// + /// A [`CensusOutcome::Final`] examines the WHOLE population, never a prefix, so + /// `examined == 0` is the one reading that means the network is empty. + pub examined: usize, + /// Why the examined candidates that did not qualify did not qualify. + pub excluded: Exclusions, } /// What one catch-up did. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CatchUp { - /// The epochs newly recorded, in ascending order. Empty is the ordinary steady state: a node - /// already current has nothing to compute and performs no chain read at all. - pub recorded: Vec, + /// What each newly recorded epoch's census found, in ascending epoch order. Empty is the + /// ordinary steady state: a node already current has nothing to compute and performs no chain + /// read at all. + pub recorded: Vec, /// Why the walk stopped short of the target, or `None` when it reached it. pub stopped: Option, } @@ -184,7 +239,7 @@ pub fn catch_up( for epoch in (highest + 1)..=target_epoch { match record_one(source, store, epoch) { - Ok(()) => recorded.push(epoch), + Ok(observed) => recorded.push(observed), Err(stopped) => { return CatchUp { recorded, @@ -218,7 +273,14 @@ fn record_one( source: &S, store: &EpochRecordStore, epoch: u64, -) -> Result<(), CensusStop> { +) -> Result { + // Before any chain read: a rotted line for THIS epoch is invisible to `highest_recorded` and + // would otherwise cost a full census per timer tick, forever, to reach a `put` that cannot + // succeed. See `CensusStop::EpochLineUnreadable`. + if matches!(store.get(epoch), StoredEpoch::Unreadable) { + return Err(CensusStop::EpochLineUnreadable { epoch }); + } + let prior = prior_record(store, epoch)?; let at = match census_height(source, epoch_start_unix_secs(epoch)) { @@ -269,8 +331,15 @@ fn record_one( // `censused`, with the height the census was actually taken at — the two facts that make this // record auditable and distinguish it from one adopted from peers. let stored = StoredRecord::censused(record, counted.height()); + let observed = CensusObservation { + epoch, + census_height: counted.height(), + stores: record.census.stores, + examined: counted.examined(), + excluded: counted.excluded(), + }; match store.put(&stored) { - Ok(PutOutcome::Written | PutOutcome::AlreadyPresent) => Ok(()), + Ok(PutOutcome::Written | PutOutcome::AlreadyPresent) => Ok(observed), Ok(PutOutcome::Conflict { .. }) => Err(CensusStop::Contradiction { epoch }), Err(e) => Err(CensusStop::Store { detail: e.to_string(), @@ -405,6 +474,85 @@ mod tests { } } + /// A source that answers a chain, at a peak far past finality, holding exactly the coin records + /// it was built with. + /// + /// Every height carries a timestamp at or after epoch 2's start, so `census_height` settles on + /// height 0 without the fixture having to model a block schedule — the height is not what these + /// tests are about. + struct PopulatedSource { + records: Vec, + } + + impl PopulatedSource { + fn holding(records: Vec) -> Self { + Self { records } + } + } + + impl ChainSource for PopulatedSource { + type Error = String; + + fn coin_record( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: chia_protocol::Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(self.records.clone()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(Some(10_000)) + } + + fn block_timestamp(&self, height: u32) -> Result, Self::Error> { + Ok(Some(epoch_start_unix_secs(2) + u64::from(height))) + } + } + + /// A coin record at `puzzle_hash`, confirmed well before any census height these tests use. + fn record_at(puzzle_hash: chia_protocol::Bytes32) -> CoinRecord { + CoinRecord { + coin: chia_protocol::Coin { + parent_coin_info: chia_protocol::Bytes32::new([7u8; 32]), + puzzle_hash, + amount: 1_000_000, + }, + confirmed_height: Some(0), + spent_height: None, + timestamp: Some(epoch_start_unix_secs(2)), + coinbase: false, + } + } + /// A store in a fresh temp dir, holding only the genesis record the bring-up writes. fn seeded_store(name: &str) -> (EpochRecordStore, std::path::PathBuf) { let dir = std::env::temp_dir().join(format!( @@ -469,7 +617,7 @@ mod tests { let outcome = catch_up(&source, &store, GENESIS_EPOCH); - assert_eq!(outcome.recorded, Vec::::new()); + assert_eq!(outcome.recorded, Vec::::new()); assert_eq!( outcome.stopped, None, "a current store stopped for a reason" @@ -525,6 +673,113 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// **Two censuses that count nothing are told apart by what the walk reports.** + /// + /// This is the whole reason `CensusObservation` exists. Both halves record a `stores` of 0 and + /// both write a record carrying the floor requirement — identical on every field that reaches + /// the record. What separates them is `examined` and the exclusion counts: + /// + /// * the EMPTY chain examined nothing, so the network really is empty; + /// * the WRONG-puzzle-hash source examined a candidate and dropped it as `foreign_puzzle`, + /// which is a fact about the source and says nothing about the network. + /// + /// The second is a broken instrument rendered as a reassuring answer, on the path that decides + /// how much collateral this node posts. Asserting the two observations DIFFER is what fails if + /// the fields are dropped again: an assertion on `stores` alone passes in both cases, which is + /// exactly the state this test was written against. + #[test] + fn a_census_of_nothing_reports_whether_it_examined_anything() { + let (empty_store, empty_dir) = seeded_store("examined-empty"); + let empty = catch_up(&PopulatedSource::holding(Vec::new()), &empty_store, 2); + + let (foreign_store, foreign_dir) = seeded_store("examined-foreign"); + let foreign = catch_up( + &PopulatedSource::holding(vec![record_at(chia_protocol::Bytes32::new([9u8; 32]))]), + &foreign_store, + 2, + ); + + let [empty] = &empty.recorded[..] else { + panic!("the empty chain recorded {:?}", empty) + }; + let [foreign] = &foreign.recorded[..] else { + panic!("the foreign-puzzle chain recorded {:?}", foreign) + }; + + // The figure that reaches the record is the same in both. That is the problem. + assert_eq!(empty.stores, 0); + assert_eq!(foreign.stores, 0); + + assert_eq!(empty.examined, 0, "an empty chain examined a candidate"); + assert_eq!(empty.excluded, Exclusions::default()); + + assert_eq!( + foreign.examined, 1, + "the candidate the source answered was not counted as examined" + ); + assert_eq!( + foreign.excluded.foreign_puzzle, 1, + "a record at the wrong puzzle hash was not reported as such" + ); + + assert_ne!( + (empty.examined, empty.excluded), + (foreign.examined, foreign.excluded), + "an empty network and a source answering at the wrong puzzle hash are indistinguishable" + ); + + let _ = std::fs::remove_dir_all(empty_dir); + let _ = std::fs::remove_dir_all(foreign_dir); + } + + /// **A rotted line for the epoch being computed stops the walk BEFORE any chain read.** + /// + /// `records()` skips unparseable lines while `get()` reports them, so a line for epoch 2 + /// truncated by a crash or a full disk leaves `highest_recorded` answering 1 and the walk + /// heading straight back at epoch 2. Without the pre-check that walk performs a whole + /// population read and its spend executions, fails at `put`, and does it all again on the next + /// timer tick — forever, advancing nothing. + /// + /// `reads() == 0` is the load-bearing assertion, and it is what a fix placed anywhere later + /// than this would fail: a stop reported AFTER the census is still a correct-looking stop, and + /// still burns the census every ten minutes. + #[test] + fn a_rotted_line_for_the_target_epoch_stops_the_walk_before_reading_the_chain() { + let (store, dir) = seeded_store("rotted"); + + // Valid JSON that names epoch 2 — so the store can attribute it — and is not a + // `StoredRecord`. This is the shape a half-written append leaves behind. + { + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(dir.join("epochs.jsonl")) + .expect("open the store for the rotted append"); + writeln!(f, "{{\"epoch\":2}}").expect("append the rotted line"); + } + assert!( + matches!(store.get(2), StoredEpoch::Unreadable), + "the fixture did not produce an unreadable line for epoch 2" + ); + + let source = UnreachableSource::new(); + let outcome = catch_up(&source, &store, 3); + + assert!(outcome.recorded.is_empty()); + assert_eq!( + outcome.stopped, + Some(CensusStop::EpochLineUnreadable { epoch: 2 }), + "the rotted line was not named as the reason" + ); + assert_eq!( + source.reads(), + 0, + "a full census was performed against a line that could never be written" + ); + + let _ = std::fs::remove_dir_all(dir); + } + /// **Epoch starts come from the schedule, and consecutive epochs are one epoch length apart.** /// /// Pinned because this is the one arithmetic in the module, and a census height derived from a diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 47c6130c..a7c775f0 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2631,10 +2631,32 @@ fn spawn_collateral_census(chain: Arc) match pass { Ok(outcome) => { - if !outcome.recorded.is_empty() { + // One line per epoch, carrying what the census EXAMINED and why it + // excluded what it excluded — not only the figure it arrived at. A + // `stores = 0` is produced identically by an empty network, by a + // source answering at the wrong puzzle hash, and by a degraded source + // whose candidates' creating spends were all unavailable; those are + // opposite situations on the path that decides what this node posts, + // and only the exclusion counts separate them. + for observed in &outcome.recorded { tracing::info!( - epochs = ?outcome.recorded, - "censused the collateral network and recorded the epoch(s)" + epoch = observed.epoch, + census_height = observed.census_height, + stores = observed.stores, + examined = observed.examined, + excluded_foreign_puzzle = observed.excluded.foreign_puzzle, + excluded_unreadable = observed.excluded.unreadable, + excluded_unattributed = observed.excluded.unattributed, + excluded_wrong_epoch = observed.excluded.wrong_epoch, + excluded_not_yet_created = observed.excluded.not_yet_created, + excluded_spent_by_census_height = + observed.excluded.spent_by_census_height, + excluded_undated = observed.excluded.undated, + excluded_block_reward = observed.excluded.block_reward, + excluded_below_requirement_unauthenticated = + observed.excluded.below_requirement_unauthenticated, + excluded_superseded = observed.excluded.superseded, + "censused the collateral network and recorded the epoch" ); } if let Some(stop) = outcome.stopped { @@ -2644,7 +2666,8 @@ fn spawn_collateral_census(chain: Arc) tracing::warn!( target_epoch = target, reason = ?stop, - "the collateral census stopped short of the current epoch; no record was written" + "the collateral census stopped short of the current epoch; \ + no record was written" ); } } diff --git a/crates/dig-wallet/src/sage/chain.rs b/crates/dig-wallet/src/sage/chain.rs index dd2b9670..28683ec8 100644 --- a/crates/dig-wallet/src/sage/chain.rs +++ b/crates/dig-wallet/src/sage/chain.rs @@ -282,10 +282,23 @@ impl ChainTransport { handle, ProviderInfo { id: ProviderId(std::borrow::Cow::Borrowed(CHAIN_SOURCE_PROVIDER_ID)), - // `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. Naming either would - // describe the source's trust posture more precisely than this node can observe. + // `Custom` rather than `LocalNode`, because the router behind it is not this + // node's peers: with `coinset_fallback_enabled` — the default every production + // fabric is built from — it asks `api.coinset.org` FIRST and consults the peers + // this node dialled only when that read fails. It is not a race, and the peers do + // not corroborate the answer. + // + // So this provider's ANSWERS are the oracle's whenever the oracle is reachable, + // and a peer-tracked value with no agreement step when it is not. It belongs to + // the oracle's independence group for exactly that reason + // (`super::sources::independence_group_for`, which derives the group from what a + // fabric can REACH after registering one as its own group made a 2-of-2 + // independent-group custody quorum satisfiable by a single HTTPS endpoint — + // measured on a client holding no peers at all). + // + // Nothing registers this provider in a `ProviderRegistry` today. Anything that + // does MUST take its group from `ChiaQueryProvider::independence_group()` rather + // than from this `kind`, or it repeats that incident. kind: ProviderKind::Custom, priority: 0, // Answers are believed because the tier that produced them was believed, not @@ -339,7 +352,15 @@ impl ChainTransport { /// /// 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. + /// silently retained: no WALLET read in production takes it. + /// + /// The router's peak IS taken in production elsewhere, and by exactly one caller: the + /// collateral census reads through [`Self::chain_source`], which hands `ChiaQueryProvider` + /// straight to the router (dig-node#400). Its peak is therefore the oracle-first one described + /// above — uncorroborated — and it gates that path's reorg-finality check. Extending the + /// corroborated reads to cover the census's population read is tracked as a sequencing + /// constraint against the mirror-coin mint; until it lands, no surface may describe the + /// census's peak as agreed across this node's peers. pub async fn peak_height(&self) -> Result> { if let Some(peers) = &self.peer_reads { return Ok(peers.peak_height().await); From 4f93c90e0cd208a357224573ecc1775ceae89047 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 23:15:14 -0700 Subject: [PATCH 6/7] test(collateral): assert the operator-visible line, not only the observation 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 --- SPEC.md | 13 +++- .../dig-node-service/src/collateral_census.rs | 70 +++++++++++++++++++ crates/dig-node-service/src/server.rs | 62 +++++++++------- 3 files changed, 116 insertions(+), 29 deletions(-) diff --git a/SPEC.md b/SPEC.md index 68912f00..dafb3f50 100644 --- a/SPEC.md +++ b/SPEC.md @@ -7685,8 +7685,8 @@ NC-12 agreement across the full nodes this node dialled itself, and their failur reported as not knowing. The census's peak is not: it is read through the `ChainSource`, whose router asks `api.coinset.org` FIRST and consults this node's peers only when that read fails — so on a reachable oracle the census's peak is one HTTPS endpoint's answer, and when the oracle is -unreachable it is a peer-tracked value carried with NO agreement step. That is the peak the -`CENSUS_FINALITY_DEPTH_BLOCKS` check above is measured against. +unreachable it is a peer-tracked value carried with NO agreement step. It is that peak the +`CENSUS_FINALITY_DEPTH_BLOCKS` refusal below is measured against. A census provider MUST therefore be classified by what it can REACH rather than by its type: a fabric that can fall through to the oracle shares the oracle's independence group, however many @@ -7709,7 +7709,7 @@ reason and its own remedy: | the candidate population exceeds what can be authenticated | refused whole; never censused as a prefix | | the predecessor record is absent, unreadable, or names an unimplemented ruleset | that epoch first | | the store already holds a DIFFERENT record for the computed epoch | the held record stands | -| the controller refused to derive the record from the census | a build that implements the ruleset | +| the controller refused to derive the record from the census | its own reason, reported verbatim: the census is not the successor epoch, no activation row governs that epoch, or the version is unimplemented | | the record store could not be read or written | the state directory | | the store's own line for the computed epoch cannot be read | repair or remove that line | @@ -7721,6 +7721,13 @@ None of these MUST EVER become a figure — not a zero, not a default, and not t epoch's answer. The store's own absence then surfaces through §24.2's `unknown` with its reason, which is the only answer such a node can defend. +**A census that counted nothing MUST say what it examined.** A `stores` of zero is produced +identically by an empty network, by a source answering at a puzzle hash other than the one it was +asked for, and by a source that could not supply the creating spends its candidates needed. Those +call for opposite responses and only the first is a fact about the network, so for every epoch it +records a node MUST report the census's examined count and its per-rule exclusion counts alongside +the recorded figure. Reporting the figure alone renders a broken instrument as an answer. + Re-attempting MUST be cheap in the steady state: a node whose store already holds the target epoch performs **no chain read at all**. diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs index ba56ba02..1d6d1afa 100644 --- a/crates/dig-node-service/src/collateral_census.rs +++ b/crates/dig-node-service/src/collateral_census.rs @@ -732,6 +732,76 @@ mod tests { let _ = std::fs::remove_dir_all(foreign_dir); } + /// **The line an operator reads carries the exclusion counts, not just the figure.** + /// + /// Asserted on the RENDERED event rather than on the struct, because the struct being right is + /// not what an operator sees. The observation is a real one — produced by a census of a source + /// answering at the wrong puzzle hash — so the line under test is the one that would be written + /// on a node in exactly that state. + #[test] + fn the_recorded_epoch_is_logged_with_what_the_census_examined() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct Captured(Arc>>); + + impl Write for Captured { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().expect("the capture buffer").extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Captured { + type Writer = Captured; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + let (store, dir) = seeded_store("logged"); + let outcome = catch_up( + &PopulatedSource::holding(vec![record_at(chia_protocol::Bytes32::new([9u8; 32]))]), + &store, + 2, + ); + let [observed] = &outcome.recorded[..] else { + panic!("nothing was recorded to log: {outcome:?}") + }; + + let buffer = Captured(Arc::new(Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_writer(buffer.clone()) + .with_ansi(false) + .without_time() + .finish(); + tracing::subscriber::with_default(subscriber, || { + crate::server::log_census_observation(observed); + }); + + let line = String::from_utf8(buffer.0.lock().expect("the capture buffer").clone()) + .expect("the rendered line is utf-8"); + println!("{line}"); + + for field in [ + "examined=1", + "excluded_foreign_puzzle=1", + "excluded_unreadable=0", + "stores=0", + ] { + assert!( + line.contains(field), + "the logged line does not carry {field}: {line}" + ); + } + + let _ = std::fs::remove_dir_all(dir); + } + /// **A rotted line for the epoch being computed stops the walk BEFORE any chain read.** /// /// `records()` skips unparseable lines while `get()` reports them, so a line for epoch 2 diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index a7c775f0..9739cd2c 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2583,6 +2583,41 @@ fn bring_up_collateral_records() { } } +/// Report one recorded epoch, including what its census EXAMINED and why it excluded what it did. +/// +/// # Why the exclusion counts are on the line and not only the figure +/// +/// A `stores` of zero is produced identically by three situations with opposite remedies: an empty +/// network, a source answering at a puzzle hash other than the one it was asked for +/// (`excluded_foreign_puzzle`), and a source that could not supply its candidates' creating spends +/// (`excluded_unreadable`). Only the first is a fact about the network; the other two are a broken +/// instrument rendering a reassuring answer, on the path that decides how much collateral this node +/// posts. `examined` alone separates "nothing was there" from "everything was dropped", and the +/// per-rule counts say which rule dropped it. +/// +/// A free function so the line an operator actually reads can be asserted against, rather than +/// living only inside the timer loop where nothing can reach it. +pub(crate) fn log_census_observation(observed: &crate::collateral_census::CensusObservation) { + tracing::info!( + epoch = observed.epoch, + census_height = observed.census_height, + stores = observed.stores, + examined = observed.examined, + excluded_foreign_puzzle = observed.excluded.foreign_puzzle, + excluded_unreadable = observed.excluded.unreadable, + excluded_unattributed = observed.excluded.unattributed, + excluded_wrong_epoch = observed.excluded.wrong_epoch, + excluded_not_yet_created = observed.excluded.not_yet_created, + excluded_spent_by_census_height = observed.excluded.spent_by_census_height, + excluded_undated = observed.excluded.undated, + excluded_block_reward = observed.excluded.block_reward, + excluded_below_requirement_unauthenticated = + observed.excluded.below_requirement_unauthenticated, + excluded_superseded = observed.excluded.superseded, + "censused the collateral network and recorded the epoch" + ); +} + /// How often the census runner re-attempts a catch-up. /// /// One mirror ROUND, taken from the schedule rather than written as a duration: the round is the @@ -2631,33 +2666,8 @@ fn spawn_collateral_census(chain: Arc) match pass { Ok(outcome) => { - // One line per epoch, carrying what the census EXAMINED and why it - // excluded what it excluded — not only the figure it arrived at. A - // `stores = 0` is produced identically by an empty network, by a - // source answering at the wrong puzzle hash, and by a degraded source - // whose candidates' creating spends were all unavailable; those are - // opposite situations on the path that decides what this node posts, - // and only the exclusion counts separate them. for observed in &outcome.recorded { - tracing::info!( - epoch = observed.epoch, - census_height = observed.census_height, - stores = observed.stores, - examined = observed.examined, - excluded_foreign_puzzle = observed.excluded.foreign_puzzle, - excluded_unreadable = observed.excluded.unreadable, - excluded_unattributed = observed.excluded.unattributed, - excluded_wrong_epoch = observed.excluded.wrong_epoch, - excluded_not_yet_created = observed.excluded.not_yet_created, - excluded_spent_by_census_height = - observed.excluded.spent_by_census_height, - excluded_undated = observed.excluded.undated, - excluded_block_reward = observed.excluded.block_reward, - excluded_below_requirement_unauthenticated = - observed.excluded.below_requirement_unauthenticated, - excluded_superseded = observed.excluded.superseded, - "censused the collateral network and recorded the epoch" - ); + log_census_observation(observed); } if let Some(stop) = outcome.stopped { // WARN rather than ERROR: several stops — an epoch the chain has From 10c56fd77504a61d121a4764f280d7623b65c3c2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 28 Aug 2026 23:18:30 -0700 Subject: [PATCH 7/7] style: cargo fmt Co-Authored-By: Claude --- crates/dig-node-service/src/collateral_census.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs index 1d6d1afa..3864b16f 100644 --- a/crates/dig-node-service/src/collateral_census.rs +++ b/crates/dig-node-service/src/collateral_census.rs @@ -748,7 +748,10 @@ mod tests { impl Write for Captured { fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().expect("the capture buffer").extend_from_slice(buf); + self.0 + .lock() + .expect("the capture buffer") + .extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> {