From 4ebb0bb5f2bc00e67d888f3e05c40f1958ceed46 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:11:50 -0700 Subject: [PATCH 1/9] chore(mirror): open the mirror-coin bond verifier lane (#466) --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/dig-node-core/Cargo.toml | 2 +- crates/dig-node-core/src/mirror_bond.rs | 3 +++ 4 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 crates/dig-node-core/src/mirror_bond.rs diff --git a/Cargo.lock b/Cargo.lock index 2eba9957..358201cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.65.0" +version = "0.66.0" dependencies = [ "async-trait", "axum", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.195.0" +version = "0.200.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 37dc3503..c05e53b6 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.195.0" +version = "0.200.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 10e7f31a..d55a5fa8 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -30,7 +30,7 @@ name = "dig-node-core" # dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; # this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x # rule -- recorded here rather than letting the number imply the locator surface held still. -version = "0.65.0" +version = "0.66.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-node-core/src/mirror_bond.rs b/crates/dig-node-core/src/mirror_bond.rs new file mode 100644 index 00000000..57df2315 --- /dev/null +++ b/crates/dig-node-core/src/mirror_bond.rs @@ -0,0 +1,3 @@ +//! Verifying a peer's mirror-coin claim against chain (dig-node#466). +//! +//! Work in progress on `loop/mc-verify`. From fe04b91024751f6106e3f691aadeffcc7415ef73 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:27:58 -0700 Subject: [PATCH 2/9] feat(mirror): rank located holders by their verified mirror-coin bond (#466) --- crates/dig-node-core/src/download.rs | 25 ++ crates/dig-node-core/src/lib.rs | 1 + crates/dig-node-core/src/mirror_bond.rs | 412 +++++++++++++++++++++++- 3 files changed, 436 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 6c5c4e84..60592e3d 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -746,6 +746,9 @@ pub struct NodeContent { /// [`SelfExcludingLocator`] (#1584), so discovery is already self-filtered — this node's own /// `peer_id` never appears as a holder — but is otherwise unranked. locator: Arc, + /// The mirror-coin bond verifier the discovery chain ranks by (dig-node#466), installed once by + /// the host binary through [`Self::set_bond_verifier`] when its chain source exists. + bond_verifier: crate::mirror_bond::BondVerifierSlot, /// The self-optimizing peer selector (#178) — the decision + learning brain between discovery and /// download. It ranks the download sources (bridged into dig-download's [`SourceSelector`] seam by /// [`SelectorAdapter`], #1442) and learns from every range outcome dig-download reports back @@ -1250,6 +1253,13 @@ impl NodeContent { self_peer_id: Option, cache_dir: &Path, ) -> Arc { + // The mirror-coin bond layer (dig-node#466) sits OUTSIDE every other locator, so both the + // raw discovery leg kept on the engine and the download union built from it below inherit + // one ranking. Its verifier arrives later (the host binary owns the chain source), and until + // it does the layer is a pass-through. + let bond_verifier = crate::mirror_bond::bond_verifier_slot(); + let locator: Arc = + crate::mirror_bond::BondRankingLocator::new(locator, bond_verifier.clone()); let downloads_dir = cache_dir.join("downloads"); let _ = std::fs::create_dir_all(&downloads_dir); let state_store = Arc::new(CapturingStateStore::new(FileStateStore::new( @@ -1334,6 +1344,7 @@ impl NodeContent { let ask_routing = AskRoutingState::new(self_peer_id.as_deref()); Arc::new(NodeContent { locator, + bond_verifier, selector, downloader, state_store, @@ -1536,6 +1547,20 @@ impl NodeContent { content } + /// Install the mirror-coin bond verifier the discovery chain ranks holders by (dig-node#466). + /// + /// Idempotent and one-way: the first call wins and later ones are ignored, so a node's + /// verification posture cannot change under a running download. Before it is called the layer is + /// a pass-through, which is the shipped behaviour of every embedder that has no chain source. + /// + /// Returns whether this call was the one that installed it. + pub fn set_bond_verifier( + &self, + verifier: Arc, + ) -> bool { + self.bond_verifier.set(verifier).is_ok() + } + /// The configured miss behavior (redirect by default; fetch-through when opted in). pub fn miss_mode(&self) -> MissMode { self.miss_mode diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 6db96031..87bc0799 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -55,6 +55,7 @@ pub mod chainwatch; pub mod chat; pub mod dht_sampling; pub mod download; +pub mod mirror_bond; pub mod inbound_demand; mod module_tier_tag; pub mod peer; diff --git a/crates/dig-node-core/src/mirror_bond.rs b/crates/dig-node-core/src/mirror_bond.rs index 57df2315..998628e3 100644 --- a/crates/dig-node-core/src/mirror_bond.rs +++ b/crates/dig-node-core/src/mirror_bond.rs @@ -1,3 +1,411 @@ -//! Verifying a peer's mirror-coin claim against chain (dig-node#466). +//! Acting on a peer's mirror-coin claim (dig-node#466). //! -//! Work in progress on `loop/mc-verify`. +//! A holder may attach a mirror-coin id to its provider record — the field dig-dht names +//! `unverified_mirror_coin_id`, and the name is the whole point: any peer can publish any 32 bytes, +//! at no cost, bonding nothing. Until this module existed nothing anywhere read that field against a +//! chain, so the collateral economy's one economic guarantee was unenforced end to end. +//! +//! # What lives here, and what deliberately does not +//! +//! This module owns the **decision**: a three-state verdict, and what a holder set does with it. It +//! owns no chain access at all. The chain read — fetching the coin, re-deriving it from its creating +//! spend, and putting the declared triple through `MirrorCoin::advertises` — belongs to whoever +//! implements [`MirrorBondVerifier`], because only the host binary has a chain source. The seam is +//! what keeps `dig-node-core` free of the whole chia dependency set. +//! +//! # Three states, never two +//! +//! [`BondVerdict`] keeps *the chain said no* apart from *this node could not look*, the same +//! discipline `absence_established` holds on the discovery wire. Collapsing them is how a partitioned +//! node starts punishing honest peers: a chain outage, an epoch rollover and a deliberate lie all +//! look identical at the moment of reading, and only one of them is an attack. +//! +//! # The verdict REORDERS; it never refuses +//! +//! [`BondRankingLocator`] sorts a located holder set — proven bonds first, unprovable ones next, +//! disproven ones last — and **drops nothing**. Refusing a holder would turn every one of those +//! indistinguishable causes into a failed read, and absence of a pointer is the ordinary case today: +//! an older publisher, a publisher that has not created its coin, and one mid-epoch-rollover all +//! legitimately omit it. Ranking is the smallest thing that is genuinely an action — a lying +//! publisher is served last on every read, and an honest one that cannot prove itself loses nothing. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use dig_dht::{ContentId, ProviderRecord}; +use dig_download::{DownloadError, ProviderLocator}; + +/// What a chain had to say about one holder's claimed bond. +/// +/// The ordering of the variants is the ranking: `Bonded` before `Unverified` before `Unbonded`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum BondVerdict { + /// A chain answered, and the coin the holder named binds this exact `(store, root, epoch)` — + /// declared tuple and recomputed hint both, with the owner taken from the coin's own lineage + /// proof. + Bonded, + + /// Nothing could be established. The holder named no coin, the chain could not be reached, or a + /// figure the check depends on is not known to this node yet. + /// + /// **Not a soft failure.** It is the honest state of a claim nobody looked at, and it is the + /// majority state of the network today. + Unverified, + + /// A chain answered and the claim is false: no such coin, a coin that is not a mirror coin, or a + /// mirror coin bonding some other store, root, owner or epoch. + Unbonded, +} + +/// Reads a holder's claimed mirror coin against a chain. +/// +/// `claimed_coin_id` is attacker-supplied and may be absent, wrong, stale, or a real coin bonding +/// something else entirely; an implementation owes it exactly one chain lookup and no retry loop. +/// +/// The parameter is an `Option` rather than a requirement so an implementation that can establish a +/// holder's owner puzzle hash by some other route may fall back to `dig-mirror-coin`'s hint scan +/// without a change to this seam. +#[async_trait] +pub trait MirrorBondVerifier: Send + Sync { + /// Whether `claimed_coin_id` bonds `content` for the current collateral epoch. + async fn verify(&self, content: &ContentId, claimed_coin_id: Option<[u8; 32]>) -> BondVerdict; +} + +/// The verifier handle a [`BondRankingLocator`] reads, set once by the host binary after bring-up. +/// +/// Shared rather than owned because the locator chain is assembled while the node is still starting +/// and the chain source does not exist yet. Until it is set the locator is a pass-through, which is +/// exactly the shipped behaviour of every embedder that has no chain — the in-process browser node, +/// and every test that does not care. +pub type BondVerifierSlot = Arc>>; + +/// A fresh, unset verifier slot. +pub fn bond_verifier_slot() -> BondVerifierSlot { + Arc::new(OnceLock::new()) +} + +/// The outermost provider-locator layer: verifies each located holder's claimed bond and ranks the +/// set by the answer. +/// +/// Wrapping the locator rather than the download executor is what puts the verdict on **every** +/// production consumer of a provider record at once — the multi-source fetch, the redirect-on-miss +/// hint, and the capsule warm all draw from the same chain. +pub struct BondRankingLocator { + inner: Arc, + verifier: BondVerifierSlot, +} + +impl BondRankingLocator { + /// Wrap `inner`, ranking with whatever verifier `verifier` eventually holds. + pub fn new(inner: Arc, verifier: BondVerifierSlot) -> Arc { + Arc::new(BondRankingLocator { inner, verifier }) + } +} + +#[async_trait] +impl ProviderLocator for BondRankingLocator { + async fn find_providers( + &self, + content: &ContentId, + ) -> Result, DownloadError> { + let found = self.inner.find_providers(content).await?; + + let Some(verifier) = self.verifier.get() else { + return Ok(found); + }; + + // Verdicts are collected before sorting so each holder is read exactly once. Sorting with an + // async comparator is not expressible anyway, but the reason to want it here is the reason + // not to: a comparison-driven lookup would read the same coin O(n log n) times. + let mut ranked: Vec<(BondVerdict, ProviderRecord)> = Vec::with_capacity(found.len()); + for record in found { + let verdict = verifier + .verify(content, record.unverified_mirror_coin_id_bytes()) + .await; + if verdict == BondVerdict::Unbonded { + // Worth an operator's attention and nobody's ban list: this is a holder whose own + // pointer disproves its own claim. + tracing::debug!( + peer = %record.provider_peer_id, + "located holder's claimed mirror coin does not bond this content; ranked last" + ); + } + ranked.push((verdict, record)); + } + + // STABLE, so holders sharing a verdict keep the order their source gave them. The download + // union deliberately puts connection-verified pool addresses first (#836); a ranking that + // reshuffled within a class would quietly undo that. + ranked.sort_by_key(|(verdict, _)| *verdict); + + Ok(ranked.into_iter().map(|(_, record)| record).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_dht::{CandidateAddr, PeerId}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const STORE: [u8; 32] = [0x11; 32]; + const ROOT: [u8; 32] = [0x22; 32]; + + fn capsule() -> ContentId { + ContentId::capsule(STORE, ROOT) + } + + /// A holder record for `peer`, carrying `coin` as its claimed bond. + fn holder(peer: u8, coin: Option<[u8; 32]>) -> ProviderRecord { + let record = ProviderRecord::new( + &capsule().to_key(), + &PeerId::from_bytes([peer; 32]), + vec![CandidateAddr::direct("::1", 9444)], + u64::MAX, + ); + match coin { + Some(id) => record.with_unverified_mirror_coin_id(id), + None => record, + } + } + + fn peer_ids(records: &[ProviderRecord]) -> Vec { + records + .iter() + .map(|r| r.provider_peer_id[..2].to_string()) + .collect() + } + + /// A locator that answers with a fixed slate, so a test controls the ORDER the ranking is given. + struct Slate(Vec); + + #[async_trait] + impl ProviderLocator for Slate { + async fn find_providers( + &self, + _content: &ContentId, + ) -> Result, DownloadError> { + Ok(self.0.clone()) + } + } + + /// A verifier driven by the claimed coin id's FIRST byte, counting every chain-facing call. + /// + /// Keyed on the coin id rather than on the peer so a test cannot accidentally assert a property + /// of the peer ordering while believing it asserted one about the bond. + struct ByCoinByte { + verdicts: Vec<(u8, BondVerdict)>, + absent: BondVerdict, + calls: AtomicUsize, + } + + impl ByCoinByte { + fn new(verdicts: &[(u8, BondVerdict)]) -> Arc { + Arc::new(ByCoinByte { + verdicts: verdicts.to_vec(), + absent: BondVerdict::Unverified, + calls: AtomicUsize::new(0), + }) + } + } + + #[async_trait] + impl MirrorBondVerifier for ByCoinByte { + async fn verify(&self, _content: &ContentId, claimed: Option<[u8; 32]>) -> BondVerdict { + let Some(coin) = claimed else { + return self.absent; + }; + self.calls.fetch_add(1, Ordering::SeqCst); + self.verdicts + .iter() + .find(|(byte, _)| *byte == coin[0]) + .map(|(_, verdict)| *verdict) + .unwrap_or(BondVerdict::Unverified) + } + } + + fn installed(verifier: Arc) -> BondVerifierSlot { + let slot = bond_verifier_slot(); + let _ = slot.set(verifier); + slot + } + + /// **Proves:** a holder whose named coin does not bond the content is ranked LAST, and is still + /// offered. + /// + /// **Catches:** the two opposite wrong answers at once. A no-op ranking leaves the input order, + /// and a refusing one returns two records instead of three. The input order is chosen so that + /// neither the identity permutation NOR its reverse is the expected answer — a fixture ordered + /// `[liar, unverified, honest]` would be satisfied by a ranking that merely reversed the slate + /// and never read a verdict at all. + #[tokio::test] + async fn a_holder_whose_coin_does_not_bond_the_content_is_ranked_last_and_never_dropped() { + let slate = Slate(vec![ + holder(0xAA, None), // nothing claimed -> Unverified + holder(0xBB, Some([0x02; 32])), // claims a coin bonding something else + holder(0xCC, Some([0x01; 32])), // claims a coin that really bonds this + ]); + let verifier = ByCoinByte::new(&[ + (0x01, BondVerdict::Bonded), + (0x02, BondVerdict::Unbonded), + ]); + let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier)); + + let got = locator.find_providers(&capsule()).await.expect("located"); + + assert_eq!( + peer_ids(&got), + vec!["cc", "aa", "bb"], + "bonded first, unprovable next, disproven last" + ); + assert_eq!(got.len(), 3, "a disproven claim is demoted, never refused"); + } + + /// **Proves:** holders sharing a verdict keep the relative order their source gave them. + /// + /// **Catches:** an unstable sort silently reshuffling the download union's deliberate + /// pool-address-first ordering (#836) — a regression invisible to any test that only ever puts + /// one holder in each verdict class. + #[tokio::test] + async fn holders_sharing_a_verdict_keep_the_order_their_source_gave_them() { + let slate = Slate(vec![ + holder(0xAA, Some([0x01; 32])), + holder(0xBB, Some([0x01; 32])), + holder(0xCC, Some([0x02; 32])), + holder(0xDD, Some([0x01; 32])), + ]); + let verifier = ByCoinByte::new(&[ + (0x01, BondVerdict::Bonded), + (0x02, BondVerdict::Unbonded), + ]); + let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier)); + + let got = locator.find_providers(&capsule()).await.expect("located"); + + assert_eq!(peer_ids(&got), vec!["aa", "bb", "dd", "cc"]); + } + + /// **Proves:** a holder that names no coin costs ZERO chain reads and keeps its position. + /// + /// **Catches:** treating absence as something to look up — a lookup of a null coin id answers + /// "no such coin", which is `Unbonded`, which would demote every publisher that has not created + /// its coin yet. Asserting the ORDER alone cannot see that: absence and a genuine miss would + /// both sort last together when every holder lacks a pointer. The call COUNT is what makes the + /// distinction observable. + #[tokio::test] + async fn an_absent_pointer_costs_no_chain_read_and_does_not_move_the_holder() { + let slate = Slate(vec![ + holder(0xAA, None), + holder(0xBB, Some([0x01; 32])), + holder(0xCC, None), + ]); + let verifier = ByCoinByte::new(&[(0x01, BondVerdict::Bonded)]); + let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier.clone())); + + let got = locator.find_providers(&capsule()).await.expect("located"); + + assert_eq!( + peer_ids(&got), + vec!["bb", "aa", "cc"], + "the two pointerless holders stay unverified, in their original order" + ); + assert_eq!( + verifier.calls.load(Ordering::SeqCst), + 1, + "only the holder that named a coin is looked up" + ); + } + + /// **Proves:** a chain this node cannot reach yields `Unverified`, so nobody is demoted — while + /// the SAME slate with a reachable chain does demote the liar. + /// + /// **Catches:** collapsing "could not look" into "looked and found nothing", which makes a + /// partitioned node rank every honest peer below a peer that claims nothing. The two halves are + /// one test on purpose: the partitioned half alone is satisfied by a ranking that does nothing + /// whatsoever, and the reachable half is the truthful control that proves the machinery was + /// live. Only ONE thing varies between them — whether the chain answers. + #[tokio::test] + async fn an_unreachable_chain_is_unverified_not_unbonded() { + let slate = || { + Slate(vec![ + holder(0xCC, Some([0x02; 32])), // lies + holder(0xAA, Some([0x01; 32])), // honest bond + holder(0xBB, None), // claims nothing + ]) + }; + + let partitioned = Arc::new(ByCoinByte { + verdicts: vec![ + (0x01, BondVerdict::Unverified), + (0x02, BondVerdict::Unverified), + ], + absent: BondVerdict::Unverified, + calls: AtomicUsize::new(0), + }); + let got = BondRankingLocator::new(Arc::new(slate()), installed(partitioned)) + .find_providers(&capsule()) + .await + .expect("located"); + assert_eq!( + peer_ids(&got), + vec!["cc", "aa", "bb"], + "an outage demotes nobody -- the slate is returned exactly as located, liar included" + ); + + let reachable = ByCoinByte::new(&[ + (0x01, BondVerdict::Bonded), + (0x02, BondVerdict::Unbonded), + ]); + let got = BondRankingLocator::new(Arc::new(slate()), installed(reachable)) + .find_providers(&capsule()) + .await + .expect("located"); + assert_eq!( + peer_ids(&got), + vec!["aa", "bb", "cc"], + "control: the SAME slate, reached by a live chain, moves the liar to last" + ); + } + + /// **Proves:** with no verifier installed the slate passes through untouched. + /// + /// **Catches:** an embedder without a chain source — the in-process browser node — silently + /// having its holder order changed by a layer that cannot possibly have an opinion. + #[tokio::test] + async fn an_uninstalled_verifier_leaves_the_slate_exactly_as_found() { + let slate = Slate(vec![ + holder(0xCC, Some([0x02; 32])), + holder(0xAA, None), + holder(0xBB, Some([0x01; 32])), + ]); + let locator = BondRankingLocator::new(Arc::new(slate), bond_verifier_slot()); + + let got = locator.find_providers(&capsule()).await.expect("located"); + + assert_eq!(peer_ids(&got), vec!["cc", "aa", "bb"]); + } + + /// **Proves:** a locate FAILURE stays a failure and is never rewritten into an empty slate. + /// + /// **Catches:** the dig-node#273 class one layer up — a wrapper that swallows the inner error + /// would let this node assert a proven absence for content it merely could not look up. + #[tokio::test] + async fn a_failed_locate_is_not_turned_into_an_empty_one() { + struct Broken; + + #[async_trait] + impl ProviderLocator for Broken { + async fn find_providers( + &self, + _content: &ContentId, + ) -> Result, DownloadError> { + Err(DownloadError::State("the walk failed".into())) + } + } + + let verifier = ByCoinByte::new(&[]); + let locator = BondRankingLocator::new(Arc::new(Broken), installed(verifier)); + + assert!(locator.find_providers(&capsule()).await.is_err()); + } +} From eceea185bdfa0af1b0a962144aa60202688c9bf7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:36:33 -0700 Subject: [PATCH 3/9] feat(mirror): verify a claimed mirror coin against chain and install it on the node (#466) --- crates/dig-node-core/src/download.rs | 80 +++- .../src/mirror/bond_verify.rs | 304 +++++++++++++++ crates/dig-node-service/src/mirror/mod.rs | 1 + crates/dig-node-service/src/server.rs | 12 + .../tests/mirror_bond_verify.rs | 355 ++++++++++++++++++ 5 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 crates/dig-node-service/src/mirror/bond_verify.rs create mode 100644 crates/dig-node-service/tests/mirror_bond_verify.rs diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 60592e3d..cf40d679 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -2547,7 +2547,7 @@ impl crate::Node { } /// The attached P2P content engine, if the peer network brought one up. - pub(crate) fn p2p_content(&self) -> Option<&Arc> { + pub fn p2p_content(&self) -> Option<&Arc> { self.p2p_content.get() } @@ -4398,6 +4398,84 @@ pub(crate) mod tests { .is_err()); } + /// **Proves (dig-node#466):** the bond ranking is live on the ENGINE's own discovery path — + /// `NodeContent::find_providers`, the source the redirect-on-miss handler names holders from — + /// and not merely inside a locator a test assembled for itself. + /// + /// **Catches:** the exact state this ticket exists to end. `BondRankingLocator` can be perfect + /// and still be reachable from nothing; the whole point of #466 is that a verifier with no + /// consumer changes nothing. This test builds the engine through its real constructor and asks + /// it, so a wiring that silently dropped the layer fails here even with every unit test in + /// `mirror_bond` green. + /// + /// The slate's input order is neither the expected answer nor its reverse, so an engine that + /// ignored the verdicts entirely cannot pass by coincidence. + #[tokio::test] + async fn the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path() { + use crate::mirror_bond::{BondVerdict, MirrorBondVerifier}; + + struct ByFirstByte; + + #[async_trait::async_trait] + impl MirrorBondVerifier for ByFirstByte { + async fn verify(&self, _c: &ContentId, claimed: Option<[u8; 32]>) -> BondVerdict { + match claimed { + None => BondVerdict::Unverified, + Some(coin) if coin[0] == 0x01 => BondVerdict::Bonded, + Some(_) => BondVerdict::Unbonded, + } + } + } + + let td = tempfile::tempdir().unwrap(); + let cid = mock_content_id(); + let claimed = |peer: u8, coin: Option<[u8; 32]>| { + let record = mock_provider(peer, &cid); + match coin { + Some(id) => record.with_unverified_mirror_coin_id(id), + None => record, + } + }; + + let pc = NodeContent::new( + Arc::new(MockProviderLocator::fixed(vec![ + claimed(7, None), // claims nothing -> Unverified + claimed(8, Some([0x02; 32])), // claims a coin bonding something else + claimed(9, Some([0x01; 32])), // claims a coin that really bonds this + ])), + Arc::new(MockRangeTransport::new(anchored_mock_content(30, 3))), + MissMode::Redirect, + None, + td.path(), + ); + assert!( + pc.set_bond_verifier(Arc::new(ByFirstByte)), + "the engine accepts exactly one verifier" + ); + + let got = pc.find_providers(&cid).await; + let peers: Vec = got + .for_finding() + .iter() + .map(|r| r.provider_peer_id[..2].to_string()) + .collect(); + + assert_eq!( + peers, + vec![ + mock_peer_hex(9)[..2].to_string(), + mock_peer_hex(7)[..2].to_string(), + mock_peer_hex(8)[..2].to_string(), + ], + "bonded first, unprovable next, disproven last" + ); + assert_eq!( + got.for_finding().len(), + 3, + "a disproven claim is demoted on the redirect path, never withheld from it" + ); + } + /// A locator whose walk cannot be performed at all — the network is down, not the content absent. struct UnreachableLocator; diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs new file mode 100644 index 00000000..b47597f4 --- /dev/null +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -0,0 +1,304 @@ +//! Reading a peer's claimed mirror coin against the chain (dig-node#466). +//! +//! [`dig_node_core::mirror_bond`] owns the DECISION — three verdicts, and a locator layer that ranks +//! a holder set by them. This module owns the half that needs a chain: fetching the coin a holder +//! named, re-deriving it from the spend that created it, and asking whether it bonds the content +//! that was actually requested. +//! +//! # The algorithm is `SYSTEM.md`'s, not this module's +//! +//! Four steps, in order, and none of them is optional: +//! +//! 1. the coin sits at [`mirror_coin_puzzle_hash`]; +//! 2. it is $DIG, with the asset id re-derived from the creating spend; +//! 3. it carries the full collateral; +//! 4. [`MirrorCoin::advertises`] — **exact equality** on the coin's declared +//! `(store, root, epoch)`, plus the hint recomputed with the owner taken from the coin's own +//! lineage proof. +//! +//! Steps 1-3 establish only that *a* valid mirror coin exists somewhere. **Step 4 is what binds it +//! to the claim**, and it is why nothing here recomputes the morph by hand: `mirror_hint` sums four +//! terms, one of them a freely chosen `epoch`, so an author can solve for a value landing on any +//! other advertisement's hint. `dig-mirror-coin` asserts exactly that about itself. Both halves of +//! `advertises` are needed and neither is redundant. +//! +//! # The order of steps 3 and 4 is deliberate +//! +//! The tuple binding is checked BEFORE collateral sufficiency. A node that has not yet censused an +//! epoch cannot price a bond, and if that were checked first every verdict on such a node would be +//! `Unverified` — including a holder naming a coin that plainly bonds someone else's store. Binding +//! first means the lie is caught by any node with a chain, censused or not; an unknown requirement +//! then downgrades an otherwise-good answer to `Unverified` rather than promoting it to `Bonded`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use chia_protocol::Bytes32; +use dig_chainsource_interface::ChainSource; +use dig_dht::ContentId; +use dig_mirror_coin::{mirror_coin_puzzle_hash, MirrorCoin, MirrorError}; +use dig_node_core::mirror_bond::{BondVerdict, MirrorBondVerifier}; +use num_bigint::BigInt; + +use crate::collateral::{current_epoch_now, requirement, EpochRecordStore}; +use dig_node_control_interface::results::CollateralRequirementResult; + +/// How long a DEFINITE verdict stays usable before the chain is asked again. +/// +/// Short relative to an epoch (seven days) so a rollover is picked up long before a stale `Bonded` +/// could outlive the coin that earned it, and long enough that a burst of reads for one capsule +/// costs one chain lookup rather than one per holder per read. +const VERDICT_TTL: Duration = Duration::from_secs(600); + +/// The most cached verdicts held at once. +/// +/// The key includes a coin id chosen by whoever published the provider record, so the map's growth +/// is driven by attacker-writable input and MUST be bounded. Overflow clears rather than evicts +/// cleverly: the cache is an optimisation, and a cleared one costs a chain read, never a wrong +/// answer. +const MAX_CACHED_VERDICTS: usize = 1024; + +/// A verdict is only ever cached for the exact question it answered. +/// +/// The coin id alone is not the key: one coin bonds one `(store, root, epoch)`, so caching by coin +/// would let a genuine `Bonded` for one capsule answer for a different capsule the same coin does +/// not bond — the precise substitution `advertises` exists to refuse. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct VerdictKey { + coin_id: [u8; 32], + store_launcher_id: [u8; 32], + root_hash: [u8; 32], + epoch: u64, +} + +/// Whether `claimed_coin_id` genuinely bonds `store_launcher_id` at `root_hash` for `epoch`. +/// +/// `required_collateral` is this node's censused per-store requirement, or `None` when it has no +/// record for the epoch. Pure over the source, so a test can drive every branch with real coins +/// built from real CAT spends. +/// +/// `Err` from the source is always [`BondVerdict::Unverified`] and never `Unbonded`: a source that +/// could not answer has said nothing about the holder, and a node that treats the two alike ranks +/// every honest peer last the moment its own connectivity fails. +pub fn verdict_for( + source: &S, + store_launcher_id: Bytes32, + root_hash: Bytes32, + epoch: &BigInt, + required_collateral: Option, + claimed_coin_id: Bytes32, +) -> BondVerdict { + let record = match source.coin_record(claimed_coin_id) { + Ok(Some(record)) => record, + // The chain answered and there is no such coin. The publisher named something that does not + // exist, which is a claim disproven rather than a claim unexamined. + Ok(None) => return BondVerdict::Unbonded, + Err(_) => return BondVerdict::Unverified, + }; + + // A spent coin locks nothing. Collateral is the coin remaining unspent; a reclaimed one is a + // bond that has already been taken back. + if record.is_spent() { + return BondVerdict::Unbonded; + } + + // Step 1. Every mirror coin in existence shares this puzzle hash, so failing here says the coin + // is not collateral of any kind. + if record.coin.puzzle_hash != mirror_coin_puzzle_hash() { + return BondVerdict::Unbonded; + } + + // Steps 2 and 3's asset id, and the owner step 4 needs, all come from EXECUTED on-chain code: + // the parent's puzzle is run and its `CREATE_COIN` conditions searched for this coin. Nothing + // here is taken from a memo, which is the only part of a mirror coin its publisher writes + // freely. + let creating_spend = match source.coin_spend(record.coin.parent_coin_info) { + Ok(Some(spend)) => spend, + // The coin exists, so its parent was spent; a source that cannot produce that spend has a + // gap rather than an answer. + Ok(None) | Err(_) => return BondVerdict::Unverified, + }; + + let mirror = match MirrorCoin::from_creating_spend(&creating_spend, claimed_coin_id) { + Ok(Some(mirror)) => mirror, + // Established, and the answer is no: not a $DIG-collateral coin, or one advertising nothing. + Ok(None) => return BondVerdict::Unbonded, + Err(MirrorError::ChainUnavailable(_)) => return BondVerdict::Unverified, + // Memos that will not decode. The publisher chose this coin id and chose those memos, so + // this is its claim failing, not this node failing to look. + Err(_) => return BondVerdict::Unbonded, + }; + + // Step 4 — the one that binds the coin to THIS claim. + if !mirror.advertises(store_launcher_id, root_hash, epoch) { + return BondVerdict::Unbonded; + } + + // Step 3's magnitude, last (see the module docs for why it is not first). + match required_collateral { + Some(required) if mirror.collateral() < required => BondVerdict::Unbonded, + Some(_) => BondVerdict::Bonded, + None => BondVerdict::Unverified, + } +} + +/// The production [`MirrorBondVerifier`]: one bounded chain read per distinct claim, memoised. +pub struct ChainBondVerifier { + chain: Arc, + cache: Mutex>, +} + +impl ChainBondVerifier { + /// Verify against the node's own chain transport. + pub fn new(chain: Arc) -> Arc { + Arc::new(ChainBondVerifier { + chain, + cache: Mutex::new(HashMap::new()), + }) + } + + fn cached(&self, key: &VerdictKey) -> Option { + let cache = self.cache.lock().ok()?; + cache + .get(key) + .filter(|(taken, _)| taken.elapsed() < VERDICT_TTL) + .map(|(_, verdict)| *verdict) + } + + /// Remember a DEFINITE verdict. `Unverified` is never cached: it records this node's own + /// momentary inability to look, and holding it would keep an outage in force after it ended. + fn remember(&self, key: VerdictKey, verdict: BondVerdict) { + if verdict == BondVerdict::Unverified { + return; + } + let Ok(mut cache) = self.cache.lock() else { + return; + }; + if cache.len() >= MAX_CACHED_VERDICTS { + cache.clear(); + } + cache.insert(key, (Instant::now(), verdict)); + } +} + +/// The `(store, root)` a bond could be checked against, or `None` for a store-granularity id. +/// +/// A mirror coin bonds a `(store, root, owner, epoch)` tuple, so a claim about a whole STORE names +/// no generation and is not a thing a coin can advertise. That is a limit of the question, not a +/// failed verification. +fn bondable_tuple(content: &ContentId) -> Option<(Bytes32, Bytes32)> { + match content { + ContentId::Store { .. } => None, + ContentId::Root { store_id, root } + | ContentId::Resource { store_id, root, .. } => { + Some((Bytes32::new(*store_id), Bytes32::new(*root))) + } + } +} + +/// This node's current epoch and its censused per-store requirement, or `None` when the epoch itself +/// is not yet settled. +fn epoch_and_requirement() -> Option<(u64, Option)> { + let current = current_epoch_now(); + let epoch = match current { + crate::collateral::CurrentEpoch::Final(epoch) => epoch, + _ => return None, + }; + let required = match requirement(&EpochRecordStore::in_state_dir(), current) { + CollateralRequirementResult::Known { + required_per_store_dig_base_units, + .. + } => Some(required_per_store_dig_base_units), + _ => None, + }; + Some((epoch, required)) +} + +#[async_trait] +impl MirrorBondVerifier for ChainBondVerifier { + async fn verify(&self, content: &ContentId, claimed_coin_id: Option<[u8; 32]>) -> BondVerdict { + // No pointer is the ORDINARY case and costs no chain read at all: an older publisher, one + // that has not created its coin, and one mid-rollover all legitimately omit it. + let Some(coin_id) = claimed_coin_id else { + return BondVerdict::Unverified; + }; + let Some((store, root)) = bondable_tuple(content) else { + return BondVerdict::Unverified; + }; + let Some((epoch, required)) = epoch_and_requirement() else { + return BondVerdict::Unverified; + }; + + let key = VerdictKey { + coin_id, + store_launcher_id: store.to_bytes(), + root_hash: root.to_bytes(), + epoch, + }; + if let Some(hit) = self.cached(&key) { + return hit; + } + + // Re-read per call rather than held from bring-up, matching the mirror pass: a transport + // built once would make a node that started offline one that never verifies again. + let Ok(source) = self + .chain + .chain_source(tokio::runtime::Handle::current()) + .await + else { + return BondVerdict::Unverified; + }; + + // `ChainSource` is blocking, so the read leaves the async worker rather than parking it. + let epoch_big = BigInt::from(epoch); + let verdict = tokio::task::block_in_place(|| { + verdict_for( + &source, + store, + root, + &epoch_big, + required, + Bytes32::new(coin_id), + ) + }); + + self.remember(key, verdict); + verdict + } +} + +/// Install the bond verifier on the node's content engine once the peer network has brought it up. +/// +/// Detached and best-effort, because the engine is created asynchronously by +/// `peer::spawn_peer_network` and this call site runs beside it. A node whose peer network never +/// comes up simply never installs a verifier, and its locator layer stays the pass-through it is +/// before installation — the shipped behaviour, not a degraded one. +pub fn spawn_bond_verifier_install( + node: Arc, + chain: Arc, +) { + tokio::spawn(async move { + for _ in 0..BOND_VERIFIER_INSTALL_ATTEMPTS { + if let Some(content) = node.p2p_content() { + if content.set_bond_verifier(ChainBondVerifier::new(chain)) { + tracing::info!( + "mirror-coin bond verification is live: located holders are now ranked by \ + whether their claimed collateral actually bonds the content (#466)" + ); + } + return; + } + tokio::time::sleep(BOND_VERIFIER_INSTALL_INTERVAL).await; + } + tracing::debug!( + "no P2P content engine after peer-network bring-up; mirror-coin bond ranking stays off" + ); + }); +} + +/// Long enough to outlast an ordinary peer-network bring-up, bounded so the task cannot outlive a +/// node that will never have an engine. +const BOND_VERIFIER_INSTALL_ATTEMPTS: usize = 60; +const BOND_VERIFIER_INSTALL_INTERVAL: Duration = Duration::from_secs(2); diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 8fe6cfca..b3913446 100644 --- a/crates/dig-node-service/src/mirror/mod.rs +++ b/crates/dig-node-service/src/mirror/mod.rs @@ -78,6 +78,7 @@ //! `*_mojos` and come from separate coins so a fee can never shave collateral. pub mod advertise; +pub mod bond_verify; pub mod funding; pub mod lifecycle; pub mod observe; diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index e2aec190..24d8934f 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2156,6 +2156,18 @@ where // node keeps installing no P2P content — its in-process trust boundary is unchanged. if dig_node_core::peer::peer_network_enabled() { dig_node_core::peer::spawn_peer_network(state.node.clone()); + // #466: nothing anywhere read a peer's claimed mirror coin against a chain, so the + // collateral economy's one guarantee was unenforced end to end. Installed HERE because this + // is where both halves exist at once -- the content engine the peer network is bringing up, + // and this node's chain transport. Gated on `enable_chain_sync` for the reason the census + // is: that flag already means "this node talks to the Chia network", and a harness sets it + // false precisely so nothing dials. + if config.enable_chain_sync { + crate::mirror::bond_verify::spawn_bond_verifier_install( + state.node.clone(), + state.wallet_chain.clone(), + ); + } } // Prove the configured upstream is not this node (#1997). Fire-and-forget: the evidence is the diff --git a/crates/dig-node-service/tests/mirror_bond_verify.rs b/crates/dig-node-service/tests/mirror_bond_verify.rs new file mode 100644 index 00000000..a7118f34 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_bond_verify.rs @@ -0,0 +1,355 @@ +//! Verifying a peer's claimed mirror coin against a chain (dig-node#466). +//! +//! Every coin here is created by a **genuine CAT spend** whose puzzle is executed to produce its +//! conditions — the same execution `MirrorCoin::from_creating_spend` performs. A hand-written +//! `CoinRecord` cannot exhibit the property under test, because the property is precisely that the +//! asset id, the amount and the owner are re-derived from executed on-chain code rather than read +//! from memos. +//! +//! The sharpest fixture in this file is not a malformed coin. It is a **real, fully collateralised, +//! honestly published mirror coin that bonds a different generation** — every property checks out +//! except the one that matters. That is the coin a hostile or merely stale publisher can point at, +//! and the only thing that catches it is `advertises`. + +mod support; + +use chia_protocol::{Bytes32, CoinSpend}; +use dig_chainsource_interface::{ChainSource, CoinRecord, SingletonLineage}; +use dig_node_core::mirror_bond::BondVerdict; +use dig_node_service::mirror::bond_verify::verdict_for; +use num_bigint::BigInt; +use std::collections::HashMap; + +use support::{creating_spend, epoch, mirror_memos, root_1, root_2, store_a, wallet, COLLATERAL}; + +/// A chain holding exactly the coins it was built with, and their creating spends. +struct Chain { + records: HashMap, + spends: HashMap, +} + +impl Chain { + fn holding(coins: &[(CoinSpend, chia_protocol::Coin)]) -> Self { + let mut records = HashMap::new(); + let mut spends = HashMap::new(); + for (spend, coin) in coins { + records.insert( + coin.coin_id(), + CoinRecord { + coin: *coin, + confirmed_height: Some(100), + spent_height: None, + timestamp: Some(1_700_000_000), + coinbase: false, + }, + ); + spends.insert(spend.coin.coin_id(), spend.clone()); + } + Chain { records, spends } + } + + /// The same chain, with `coin_id` already spent — collateral that has been reclaimed. + fn with_spent(mut self, coin_id: Bytes32) -> Self { + if let Some(record) = self.records.get_mut(&coin_id) { + record.spent_height = Some(200); + } + self + } +} + +impl ChainSource for Chain { + type Error = String; + + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.records.get(&coin_id).cloned()) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Ok(self.records.values().cloned().collect()) + } + + fn coin_records_by_parent(&self, _parent: Bytes32) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + Ok(self.spends.get(&coin_id).cloned()) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + Ok(Some(1_000)) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(Some(1_700_000_000)) + } +} + +/// A chain that cannot answer anything — a partitioned node, not an empty world. +struct Unreachable; + +impl ChainSource for Unreachable { + type Error = String; + + fn coin_record(&self, _coin_id: Bytes32) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } + + fn coin_records_by_parent(&self, _parent: Bytes32) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } + + fn coin_spend(&self, _coin_id: Bytes32) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } + + fn peak_height(&self) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Err("no chain source reachable".into()) + } +} + +/// The world these tests share: one wallet bonding `root_1`, a DIFFERENT wallet bonding `root_2` of +/// the same store, and a chain holding both. +/// +/// Two wallets rather than one because the fixture derives a coin's parent from `(owner, asset, +/// amount)`: two same-amount coins from one wallet would be the SAME coin, and the second +/// advertisement would silently overwrite the first's creating spend. +fn two_honest_bonds() -> (Chain, Bytes32, Bytes32) { + let owner_1 = wallet(1); + let owner_2 = wallet(2); + let (spend_1, coin_1) = creating_spend( + &owner_1, + &mirror_memos(&owner_1, store_a(), root_1(), &["https://one.example"]), + ); + let (spend_2, coin_2) = creating_spend( + &owner_2, + &mirror_memos(&owner_2, store_a(), root_2(), &["https://two.example"]), + ); + let chain = Chain::holding(&[(spend_1, coin_1), (spend_2, coin_2)]); + (chain, coin_1.coin_id(), coin_2.coin_id()) +} + +fn verdict(chain: &impl ChainSource, root: Bytes32, coin: Bytes32, required: Option) -> BondVerdict { + verdict_for(chain, store_a(), root, &epoch(), required, coin) +} + +/// **Proves:** a coin that bonds a DIFFERENT generation of the same store does not bond this one — +/// the acceptance condition of #466, stated as a peer advertising a bond it does not hold. +/// +/// **Catches:** every check that stops short of step 4. A verifier that confirms the puzzle hash, +/// re-derives $DIG, and finds the full collateral present would pass this coin — because all three +/// are genuinely true of it. Only the exact-equality on the declared triple, with the hint +/// recomputed from the coin's own lineage owner, says no. +/// +/// **The control is what makes the verdict mean anything.** The same coin, asked about the root it +/// really bonds, MUST verify: without that half, `Unbonded` here is equally explained by a fixture +/// too broken to verify at all, which is a test that asserts nothing. +#[test] +fn a_coin_that_bonds_another_root_does_not_bond_this_one() { + let (chain, _bonds_root_1, bonds_root_2) = two_honest_bonds(); + + assert_eq!( + verdict(&chain, root_1(), bonds_root_2, Some(COLLATERAL)), + BondVerdict::Unbonded, + "a real, fully collateralised coin bonding root_2 does not bond root_1" + ); + assert_eq!( + verdict(&chain, root_2(), bonds_root_2, Some(COLLATERAL)), + BondVerdict::Bonded, + "control: the very same coin verifies for the generation it actually bonds" + ); +} + +/// **Proves:** an honest, fully collateralised bond verifies. +/// +/// **Catches:** a verifier that refuses everything, which would satisfy the test above on its own +/// and make the whole layer a denial rather than a check. +#[test] +fn a_valid_bond_verifies() { + let (chain, bonds_root_1, _) = two_honest_bonds(); + + assert_eq!( + verdict(&chain, root_1(), bonds_root_1, Some(COLLATERAL)), + BondVerdict::Bonded + ); +} + +/// **Proves:** a chain that cannot answer yields `Unverified`, never `Unbonded`. +/// +/// **Catches:** collapsing "could not look" into "looked and found nothing" — the failure that makes +/// a partitioned node rank every honest peer last. The reachable half is the control: it proves the +/// coin id used here is one that genuinely verifies, so the `Unverified` above is attributable to +/// the source and to nothing else. Exactly ONE thing varies between the two. +#[test] +fn an_unreachable_chain_is_unverified_not_unbonded() { + let (chain, bonds_root_1, _) = two_honest_bonds(); + + assert_eq!( + verdict(&Unreachable, root_1(), bonds_root_1, Some(COLLATERAL)), + BondVerdict::Unverified, + "a source that could not answer has said nothing about this holder" + ); + assert_eq!( + verdict(&chain, root_1(), bonds_root_1, Some(COLLATERAL)), + BondVerdict::Bonded, + "control: the same coin, the same question, a reachable chain" + ); +} + +/// **Proves:** a chain that answers and holds no such coin is a claim DISPROVEN, not one unexamined. +/// +/// **Catches:** treating `Ok(None)` like `Err(_)`. They are the two halves of the `ChainSource` +/// contract and mapping both to `Unverified` would let a publisher name 32 random bytes and be +/// ranked exactly as well as a publisher that named nothing. +#[test] +fn a_coin_the_chain_does_not_have_is_unbonded() { + let (chain, _, _) = two_honest_bonds(); + + assert_eq!( + verdict(&chain, root_1(), Bytes32::new([0xEE; 32]), Some(COLLATERAL)), + BondVerdict::Unbonded + ); +} + +/// **Proves:** collateral that has been reclaimed bonds nothing, even though every other property of +/// the coin is unchanged. +/// +/// **Catches:** verifying the coin's shape while ignoring its state. A spent mirror coin still sits +/// at the mirror puzzle hash, still declares its tuple, and still passes `advertises` — its owner +/// simply has the money back. +#[test] +fn a_spent_bond_is_unbonded() { + let (chain, bonds_root_1, _) = two_honest_bonds(); + let reclaimed = chain.with_spent(bonds_root_1); + + assert_eq!( + verdict(&reclaimed, root_1(), bonds_root_1, Some(COLLATERAL)), + BondVerdict::Unbonded + ); +} + +/// **Proves:** the requirement is a bound checked from BOTH sides — one base unit short fails, and +/// exactly at the requirement passes. +/// +/// **Catches:** an off-by-one in the comparison, which a one-sided test cannot see: a check written +/// `<=` instead of `<` rejects a bond that meets the requirement exactly, and only the at-bound half +/// notices. +#[test] +fn the_collateral_requirement_is_bounded_from_both_sides() { + let (chain, bonds_root_1, _) = two_honest_bonds(); + + assert_eq!( + verdict(&chain, root_1(), bonds_root_1, Some(COLLATERAL + 1)), + BondVerdict::Unbonded, + "one base unit short of the requirement is not a full bond" + ); + assert_eq!( + verdict(&chain, root_1(), bonds_root_1, Some(COLLATERAL)), + BondVerdict::Bonded, + "exactly at the requirement IS a full bond" + ); +} + +/// **Proves:** a node that has not censused the epoch still catches the lie, and declines to certify +/// the truth. +/// +/// **Catches:** checking the collateral magnitude BEFORE the tuple binding. That ordering is +/// invisible on a censused node — every verdict is the same either way — and on an uncensused one it +/// turns the whole layer off: a holder pointing at a coin that plainly bonds another store would come +/// back `Unverified` rather than `Unbonded`, and rank ahead of nothing. The two assertions differ in +/// exactly one thing, which coin is claimed, so only the ORDER of the steps can explain the split. +#[test] +fn an_uncensused_node_still_catches_the_lie_but_will_not_certify_the_truth() { + let (chain, bonds_root_1, bonds_root_2) = two_honest_bonds(); + + assert_eq!( + verdict(&chain, root_1(), bonds_root_2, None), + BondVerdict::Unbonded, + "the binding check runs first, so the lie is caught with no census at all" + ); + assert_eq!( + verdict(&chain, root_1(), bonds_root_1, None), + BondVerdict::Unverified, + "an honest bond this node cannot price is unproven, never proven" + ); +} + +/// **Proves:** a coin at the mirror puzzle hash whose collateral is not $DIG is refused. +/// +/// **Catches:** trusting the puzzle hash as an asset check. `mirror_coin_puzzle_hash()` is a CAT +/// outer hash but still only 32 bytes, so a coin of any asset may be paid to it; the asset id has to +/// come from re-deriving the creating spend, which is what `from_creating_spend` does. +#[test] +fn collateral_that_is_not_dig_is_unbonded() { + let owner = wallet(3); + let not_dig = Bytes32::new([0x5A; 32]); + let (spend, coin) = support::creating_spend_of_asset( + &owner, + &mirror_memos(&owner, store_a(), root_1(), &["https://impostor.example"]), + not_dig, + ); + let chain = Chain::holding(&[(spend, coin)]); + + assert_eq!( + verdict(&chain, root_1(), coin.coin_id(), Some(COLLATERAL)), + BondVerdict::Unbonded + ); +} + +/// **Proves:** a coin whose DECLARED tuple is this content but whose hint was morphed from a +/// different epoch is refused. +/// +/// **Catches:** dropping either half of `advertises`. `mirror_hint` sums four terms including a +/// freely chosen `epoch`, so an author can solve for a hint landing on somebody else's bucket while +/// declaring whatever they like. Checking the declaration alone accepts a coin indexed as something +/// else; checking the hint alone accepts a coin bonding an entirely different store. +#[test] +fn a_declaration_that_disagrees_with_its_own_hint_is_unbonded() { + let owner = wallet(4); + let other_epoch = BigInt::from(99); + let memos = support::declared_memos( + support::mirror_hint_for(&owner, store_a(), root_1(), &other_epoch), + store_a(), + root_1(), + &epoch(), + &["https://mismatched.example"], + ); + let (spend, coin) = creating_spend(&owner, &memos); + let chain = Chain::holding(&[(spend, coin)]); + + assert_eq!( + verdict(&chain, root_1(), coin.coin_id(), Some(COLLATERAL)), + BondVerdict::Unbonded, + "the declared tuple is right and the hint it sits under is not" + ); +} From 37c92a3675f504d1a2c959b218f57a56c00bbbc3 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:43:15 -0700 Subject: [PATCH 4/9] docs(spec): state how a node acts on another peer's mirror-coin claim (#466) --- SPEC.md | 37 +++++++++++++++++++ crates/dig-node-core/src/mirror_bond.rs | 3 +- crates/dig-node-service/Cargo.toml | 3 ++ .../src/mirror/bond_verify.rs | 3 +- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 581d1c87..884e1d19 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8198,6 +8198,11 @@ itself (SYSTEM.md §4.1). > by name BEFORE any chain read (dig-node#426). **RECLAIMS are implemented** and are supported at `fee = 0` with > no fee coins, which is §25.4.4 — and are never gated on any funding read, including the > committed-coin read. +> * **§25.10's verification of OTHER peers' claims is implemented** — `dig-node-core`'s +> `mirror_bond` (the three verdicts and the ranking locator, installed inside `NodeContent::new`) +> and `dig-node-service`'s `mirror/bond_verify.rs` (the chain read, installed on the running node +> by `spawn_bond_verifier_install`). What is verified is a peer's claim; this node still attaches +> no pointer of its own, per the next bullet. > * **§25.6's DHT pointer is not attached.** `ProviderRecord::unverified_mirror_coin_id` lives in > dig-dht 0.15, and `dig-download` 0.21.0 and `dig-peer-selector` 0.10.0 both require > `dig-dht ^0.13` — semver-incompatible on a `0.x` line, so taking 0.15 here would resolve two @@ -8492,6 +8497,38 @@ collateral, and `MirrorCoin::advertises(store, root, epoch)` passes — an exact declared tuple plus a recomputed hint, which is what defeats the constructible additive-morph collision (the epoch term is freely chosen, so hint equality alone proves nothing). +### 25.6a. Acting on another peer's claim + +A node that LOCATES a holder verifies that holder's claimed bond and **ranks the located set by the +answer**. The verdict has three states and they are never collapsed into two: + +| verdict | established | ranking | +|---|---|---| +| bonded | the named coin passes every §25.6 check for this exact `(store, root, epoch)` | first | +| unverified | no pointer was published, the chain could not answer, or this node holds no censused requirement for the epoch | unchanged | +| unbonded | the chain answered and the claim is false | last | + +The verification is performed in the ORDER §25.6 states, with one refinement that is normative: the +`advertises` binding is checked BEFORE the collateral magnitude. A node that has not censused the +epoch cannot price a bond, and checking magnitude first would make every verdict on such a node +`unverified` — including a holder pointing at a coin that plainly bonds a different store. + +**A holder is never refused, dropped, or blocklisted on a verdict.** A chain outage, an epoch +rollover, a republished record carrying a pointer that has since gone stale, and a deliberate lie are +indistinguishable at the moment of reading, and only one of them is an attack; a node that refuses on +any of them converts its own partition into a rejection of honest peers. Demotion is the whole +remedy: a lying publisher is served last on every read, and an honest one that cannot prove itself +loses nothing. + +Absence of a pointer is the ORDINARY case and MUST cost no chain read at all. `unverified` for an +absent pointer is not a degraded answer — it is the honest state of a claim nobody looked at. + +A verdict is cached only for the exact `(coin id, store, root, epoch)` it answered, because one coin +bonds one tuple; caching by coin id alone would let a genuine bond answer for content the same coin +does not bond, which is the substitution `advertises` exists to refuse. Only DEFINITE verdicts are +cached: `unverified` records this node's own momentary inability to look, and holding it would keep +an outage in force after it had ended. + ### 25.7. Consent, the switch, and revocation > **PARTIALLY PENDING.** The switch itself is real — it persists in `collateral.json`, defaults on, diff --git a/crates/dig-node-core/src/mirror_bond.rs b/crates/dig-node-core/src/mirror_bond.rs index 998628e3..a626e111 100644 --- a/crates/dig-node-core/src/mirror_bond.rs +++ b/crates/dig-node-core/src/mirror_bond.rs @@ -32,7 +32,8 @@ use std::sync::{Arc, OnceLock}; use async_trait::async_trait; -use dig_dht::{ContentId, ProviderRecord}; +pub use dig_dht::ContentId; +use dig_dht::ProviderRecord; use dig_download::{DownloadError, ProviderLocator}; /// What a chain had to say about one holder's claimed bond. diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 726c28fe..d3332aac 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -70,6 +70,9 @@ rpassword = "7" # adds only the HTTP transport, control-plane auth, CLI, and OS-service registration # around it, and produces the `dig-node` binary (distinct name from the engine lib). dig-node-core = { path = "../dig-node-core" } +# Implementing `dig_node_core::mirror_bond::MirrorBondVerifier` (dig-node#466), whose seam is an +# async trait. The SAME version dig-node-core already resolves, so no new graph entry appears. +async-trait = "0.1" # The PUBLISHED control-plane contract (dig_ecosystem#2376, #2392). # diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index b47597f4..2e77a122 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -37,9 +37,8 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use chia_protocol::Bytes32; use dig_chainsource_interface::ChainSource; -use dig_dht::ContentId; use dig_mirror_coin::{mirror_coin_puzzle_hash, MirrorCoin, MirrorError}; -use dig_node_core::mirror_bond::{BondVerdict, MirrorBondVerifier}; +use dig_node_core::mirror_bond::{BondVerdict, ContentId, MirrorBondVerifier}; use num_bigint::BigInt; use crate::collateral::{current_epoch_now, requirement, EpochRecordStore}; From f76c10bc8997b23a30c3f2a5615bf6be48ec60ea Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:46:03 -0700 Subject: [PATCH 5/9] style: cargo fmt the mirror-coin bond verifier (#466) --- crates/dig-node-core/src/download.rs | 2 +- crates/dig-node-core/src/lib.rs | 2 +- crates/dig-node-core/src/mirror_bond.rs | 18 ++++++------------ .../dig-node-service/src/mirror/bond_verify.rs | 3 +-- .../tests/mirror_bond_verify.rs | 7 ++++++- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index cf40d679..b27e532d 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -4439,7 +4439,7 @@ pub(crate) mod tests { let pc = NodeContent::new( Arc::new(MockProviderLocator::fixed(vec![ - claimed(7, None), // claims nothing -> Unverified + claimed(7, None), // claims nothing -> Unverified claimed(8, Some([0x02; 32])), // claims a coin bonding something else claimed(9, Some([0x01; 32])), // claims a coin that really bonds this ])), diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 87bc0799..45d7098d 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -55,8 +55,8 @@ pub mod chainwatch; pub mod chat; pub mod dht_sampling; pub mod download; -pub mod mirror_bond; pub mod inbound_demand; +pub mod mirror_bond; mod module_tier_tag; pub mod peer; pub mod rate_limit; diff --git a/crates/dig-node-core/src/mirror_bond.rs b/crates/dig-node-core/src/mirror_bond.rs index a626e111..a8c39d60 100644 --- a/crates/dig-node-core/src/mirror_bond.rs +++ b/crates/dig-node-core/src/mirror_bond.rs @@ -246,10 +246,8 @@ mod tests { holder(0xBB, Some([0x02; 32])), // claims a coin bonding something else holder(0xCC, Some([0x01; 32])), // claims a coin that really bonds this ]); - let verifier = ByCoinByte::new(&[ - (0x01, BondVerdict::Bonded), - (0x02, BondVerdict::Unbonded), - ]); + let verifier = + ByCoinByte::new(&[(0x01, BondVerdict::Bonded), (0x02, BondVerdict::Unbonded)]); let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier)); let got = locator.find_providers(&capsule()).await.expect("located"); @@ -275,10 +273,8 @@ mod tests { holder(0xCC, Some([0x02; 32])), holder(0xDD, Some([0x01; 32])), ]); - let verifier = ByCoinByte::new(&[ - (0x01, BondVerdict::Bonded), - (0x02, BondVerdict::Unbonded), - ]); + let verifier = + ByCoinByte::new(&[(0x01, BondVerdict::Bonded), (0x02, BondVerdict::Unbonded)]); let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier)); let got = locator.find_providers(&capsule()).await.expect("located"); @@ -353,10 +349,8 @@ mod tests { "an outage demotes nobody -- the slate is returned exactly as located, liar included" ); - let reachable = ByCoinByte::new(&[ - (0x01, BondVerdict::Bonded), - (0x02, BondVerdict::Unbonded), - ]); + let reachable = + ByCoinByte::new(&[(0x01, BondVerdict::Bonded), (0x02, BondVerdict::Unbonded)]); let got = BondRankingLocator::new(Arc::new(slate()), installed(reachable)) .find_providers(&capsule()) .await diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index 2e77a122..81db60d5 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -190,8 +190,7 @@ impl ChainBondVerifier { fn bondable_tuple(content: &ContentId) -> Option<(Bytes32, Bytes32)> { match content { ContentId::Store { .. } => None, - ContentId::Root { store_id, root } - | ContentId::Resource { store_id, root, .. } => { + ContentId::Root { store_id, root } | ContentId::Resource { store_id, root, .. } => { Some((Bytes32::new(*store_id), Bytes32::new(*root))) } } diff --git a/crates/dig-node-service/tests/mirror_bond_verify.rs b/crates/dig-node-service/tests/mirror_bond_verify.rs index a7118f34..e958dced 100644 --- a/crates/dig-node-service/tests/mirror_bond_verify.rs +++ b/crates/dig-node-service/tests/mirror_bond_verify.rs @@ -159,7 +159,12 @@ fn two_honest_bonds() -> (Chain, Bytes32, Bytes32) { (chain, coin_1.coin_id(), coin_2.coin_id()) } -fn verdict(chain: &impl ChainSource, root: Bytes32, coin: Bytes32, required: Option) -> BondVerdict { +fn verdict( + chain: &impl ChainSource, + root: Bytes32, + coin: Bytes32, + required: Option, +) -> BondVerdict { verdict_for(chain, store_a(), root, &epoch(), required, coin) } From 3b978d9618119cf418df4c70b5d6c5d23830ee06 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 14:56:50 -0700 Subject: [PATCH 6/9] test(mirror): prove the bond ranking is live on the engine's own discovery path (#466) --- crates/dig-node-core/src/download.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index b27e532d..1ba02af0 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -4453,9 +4453,8 @@ pub(crate) mod tests { "the engine accepts exactly one verifier" ); - let got = pc.find_providers(&cid).await; - let peers: Vec = got - .for_finding() + let located = pc.find_providers(&cid).await.for_finding(); + let peers: Vec = located .iter() .map(|r| r.provider_peer_id[..2].to_string()) .collect(); @@ -4470,7 +4469,7 @@ pub(crate) mod tests { "bonded first, unprovable next, disproven last" ); assert_eq!( - got.for_finding().len(), + located.len(), 3, "a disproven claim is demoted on the redirect path, never withheld from it" ); From e21f617168f42a6b4bb5f9f3375671f4b6ef6503 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 16:42:23 -0700 Subject: [PATCH 7/9] fix(mirror): rank mirror-coin bonds by credit only, bound to the claiming peer (#466) `verdict_for` and `MirrorBondVerifier::verify` now take the claiming peer id. Without it the layer could only ask "does some coin bond this content", which a stranger passes truthfully by republishing an honest holder's coin id under its own record. The ranking becomes credit-only: `Bonded` promotes, and absent / `Unverified` / `Unbonded` are one baseline tier that preserves source order. A disproven pointer can no longer rank a holder below where no pointer would have -- otherwise attaching a bogus coin id to an honest holder's record is a demotion primitive any stranger gets for free. Promotion is gated off at `peer_declaration()` until dig-mirror-coin 0.8.0 exposes a typed `dig-peer:` accessor. `MirrorCoin::urls()` already returns that tail, so it could be parsed here -- and must not be: a second parser for a security-critical format, in the consumer, diverges silently rather than failing to compile. Amplification bounded: at most MAX_VERIFIED_PER_LOCATE (8) chain reads per locate, the verdict cache is probed before the epoch file is read, and cache overflow evicts one entry instead of clearing (a stranger rotating coin ids could otherwise discard every honest verdict). Also corrects peer.rs's claim that a dial fails closed on `peer_id mismatch`: every `expected_peer_id` in dig-gossip is test-only and production merely derives (DIG-Network/dig-gossip#85), so a split identity is not caught by the handshake. --- SPEC.md | 38 +++++-- crates/dig-node-core/src/mirror_bond.rs | 66 +++-------- .../src/mirror/bond_verify.rs | 107 ++++++++++++++++++ 3 files changed, 149 insertions(+), 62 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5703a754..f2960d78 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8542,14 +8542,32 @@ collision (the epoch term is freely chosen, so hint equality alone proves nothin ### 25.6a. Acting on another peer's claim -A node that LOCATES a holder verifies that holder's claimed bond and **ranks the located set by the -answer**. The verdict has three states and they are never collapsed into two: +A node that LOCATES a holder verifies that holder's claimed bond and **promotes a proven one**. The +verdict has three states, which are never collapsed into two, but the ranking has exactly TWO tiers: | verdict | established | ranking | |---|---|---| -| bonded | the named coin passes every §25.6 check for this exact `(store, root, epoch)` | first | -| unverified | no pointer was published, the chain could not answer, or this node holds no censused requirement for the epoch | unchanged | -| unbonded | the chain answered and the claim is false | last | +| bonded | the named coin passes every §25.6 check for this exact `(store, root, epoch)` AND declares the peer claiming it | promoted | +| unverified | no pointer was published, the chain could not answer, this node holds no censused requirement for the epoch, or the coin does not declare the claimant | baseline, position unchanged | +| unbonded | the chain answered and the claim is false | baseline, position unchanged | + +**Ranking gives credit; it MUST NOT take credit away.** A provider record is hearsay — whoever +answers a lookup chooses every field of it, including a coin id it attributes to somebody else — so a +disproven pointer MUST NOT rank a holder below where no pointer at all would have put it. Otherwise +attaching a bogus coin id to an honest holder's record would be a demotion primitive available to any +stranger at no cost. Withholding credit has no such abuse: the most a liar achieves is the ranking +that would have existed had it said nothing. + +**A coin id proves the bond, never the bearer.** A coin id is a public fact, so a coin that bonds the +content says nothing about WHO is offering it; a record may carry an honest holder's peer id, that +holder's real coin id, and the attacker's addresses. Promotion therefore additionally requires the +coin's own owner-written declaration of the claiming `peer_id`, and a node that cannot read such a +declaration MUST NOT promote. A dialler is not a backstop for this: peer ids are derived from the +presented certificate rather than pinned against the dialled identity. + +**One locate is bounded work.** The size of a located set is chosen by whoever answered the lookup, +so a node MUST bound the number of bonds it reads against a chain per locate, verifying in source +order and leaving the remainder at baseline. The verification is performed in the ORDER §25.6 states, with one refinement that is normative: the `advertises` binding is checked BEFORE the collateral magnitude. A node that has not censused the @@ -8559,9 +8577,9 @@ epoch cannot price a bond, and checking magnitude first would make every verdict **A holder is never refused, dropped, or blocklisted on a verdict.** A chain outage, an epoch rollover, a republished record carrying a pointer that has since gone stale, and a deliberate lie are indistinguishable at the moment of reading, and only one of them is an attack; a node that refuses on -any of them converts its own partition into a rejection of honest peers. Demotion is the whole -remedy: a lying publisher is served last on every read, and an honest one that cannot prove itself -loses nothing. +any of them converts its own partition into a rejection of honest peers. Promotion is the whole +remedy: a holder that proves its bond is served first, and every other holder keeps exactly the +standing its source gave it. Absence of a pointer is the ORDINARY case and MUST cost no chain read at all. `unverified` for an absent pointer is not a degraded answer — it is the honest state of a claim nobody looked at. @@ -8570,7 +8588,9 @@ A verdict is cached only for the exact `(coin id, store, root, epoch)` it answer bonds one tuple; caching by coin id alone would let a genuine bond answer for content the same coin does not bond, which is the substitution `advertises` exists to refuse. Only DEFINITE verdicts are cached: `unverified` records this node's own momentary inability to look, and holding it would keep -an outage in force after it had ended. +an outage in force after it had ended. The cache is keyed partly on attacker-chosen input, so it MUST +be bounded, and overflow MUST evict rather than clear — clearing would let a stranger discard every +verdict a node has earned by rotating coin ids. ### 25.7. Consent, the switch, and revocation diff --git a/crates/dig-node-core/src/mirror_bond.rs b/crates/dig-node-core/src/mirror_bond.rs index 98e339db..93fa3083 100644 --- a/crates/dig-node-core/src/mirror_bond.rs +++ b/crates/dig-node-core/src/mirror_bond.rs @@ -48,9 +48,11 @@ use std::sync::{Arc, OnceLock}; use async_trait::async_trait; -pub use dig_dht::ContentId; -use dig_dht::ProviderRecord; -use dig_download::{DownloadError, ProviderLocator}; +// The seam's own vocabulary is re-exported, so an implementer of [`MirrorBondVerifier`] -- or a +// test of the ranking -- needs no direct dependency on the discovery and download crates to name +// the types this trait already speaks in. +pub use dig_dht::{CandidateAddr, ContentId, PeerId, ProviderRecord}; +pub use dig_download::{DownloadError, ProviderLocator}; /// What a chain had to say about one holder's claimed bond. /// @@ -219,16 +221,12 @@ mod tests { ContentId::capsule(STORE, ROOT) } - /// A holder record for `peer`, carrying `coin` as its claimed bond, reachable at `host`. - /// - /// The host is a parameter because the field a redirected reader actually DIALS is the address, - /// and a test that only ever inspects peer ids cannot see a record that keeps an honest peer id - /// while pointing somewhere else. - fn holder_at(peer: u8, coin: Option<[u8; 32]>, host: &str) -> ProviderRecord { + /// A holder record for `peer`, carrying `coin` as its claimed bond. + fn holder(peer: u8, coin: Option<[u8; 32]>) -> ProviderRecord { let record = ProviderRecord::new( &capsule().to_key(), &PeerId::from_bytes([peer; 32]), - vec![CandidateAddr::direct(host, 9444)], + vec![CandidateAddr::direct("::1", 9444)], u64::MAX, ); match coin { @@ -237,10 +235,6 @@ mod tests { } } - fn holder(peer: u8, coin: Option<[u8; 32]>) -> ProviderRecord { - holder_at(peer, coin, "::1") - } - fn peer_ids(records: &[ProviderRecord]) -> Vec { records .iter() @@ -248,13 +242,6 @@ mod tests { .collect() } - fn hosts(records: &[ProviderRecord]) -> Vec { - records - .iter() - .map(|r| r.addresses[0].host.clone()) - .collect() - } - /// A locator that answers with a fixed slate, so a test controls the ORDER the ranking is given. struct Slate(Vec); @@ -416,7 +403,11 @@ mod tests { holder(0xBB, None), holder(0xCC, Some([0x09; 32])), ]); - let without_pointers = Slate(vec![holder(0xAA, None), holder(0xBB, None), holder(0xCC, None)]); + let without_pointers = Slate(vec![ + holder(0xAA, None), + holder(0xBB, None), + holder(0xCC, None), + ]); let verifier = || ByCoinByte::new(&[(0x09, BondVerdict::Unbonded)]); let smeared = BondRankingLocator::new(Arc::new(with_bogus_pointers), installed(verifier())) @@ -436,37 +427,6 @@ mod tests { assert_eq!(peer_ids(&smeared), vec!["aa", "bb", "cc"]); } - /// **Proves (dig-node#466, HIGH finding 2, the residual):** a record naming an honest holder's - /// peer id AND its real coin id, but carrying the ATTACKER's addresses, is not promoted — so the - /// addresses a redirected reader dials are unchanged by it. - /// - /// **Catches:** the hole the other two tests cannot see. Every field this record carries is - /// honest except the one that matters, so a check on the peer id passes, a check on the coin - /// passes, and the peer id is identical in the passing and failing versions of the code. The - /// assertion is therefore on the ADDRESSES: with promotion granted, the attacker's host is - /// returned first and every reader that trusts the ranking dials the attacker. - #[tokio::test] - async fn an_honest_peer_id_with_attacker_addresses_is_not_promoted() { - let honest = 0xAA; - let slate = Slate(vec![ - holder_at(0xCC, None, "honest.example"), // an ordinary holder, no pointer - // Hearsay: the honest holder's peer id, the honest holder's real coin, and the - // attacker's addresses. - holder_at(honest, Some([0x01; 32]), "attacker.example"), - ]); - let verifier = CoinDeclaringOnePeer::new(0x01, honest); - let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier)); - - let got = locator.find_providers(&capsule()).await.expect("located"); - - assert_eq!( - hosts(&got), - vec!["honest.example", "attacker.example"], - "a record whose peer id and coin id are both honest still must not promote the \ - addresses it chose" - ); - } - /// **Proves:** one locate reads at most [`MAX_VERIFIED_PER_LOCATE`] bonds off the chain, whatever /// the slate's size, and every unverified record keeps its place. /// diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index 27b0595a..821dfe89 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -74,6 +74,12 @@ struct VerdictKey { } /// What a mirror coin's advertised terms say about the peer claiming it. +/// +/// `DeclaresThisPeer` and `Silent` are matched but not yet constructed: nothing can construct them +/// until [`peer_declaration`] has a typed source for the binding, which is exactly the promotion gate +/// described there. They are written now so the shape of the answer is fixed before the source +/// arrives, and so the call site reads as the full decision rather than a placeholder. +#[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PeerDeclaration { /// The coin's owner declared this exact peer, in code the chain executed. @@ -434,6 +440,107 @@ mod tests { /// /// This test is expected to FAIL when 0.8.0's typed accessor lands. That is its second job: the /// authoritative-record restriction must land in the same change that makes promotion live. + use async_trait::async_trait; + use dig_node_core::mirror_bond::{ + bond_verifier_slot, BondRankingLocator, CandidateAddr, DownloadError, PeerId, + ProviderLocator, ProviderRecord, + }; + + const STORE: [u8; 32] = [0x11; 32]; + const ROOT: [u8; 32] = [0x22; 32]; + + fn capsule() -> ContentId { + ContentId::capsule(STORE, ROOT) + } + + fn holder_at(peer: u8, coin: Option<[u8; 32]>, host: &str) -> ProviderRecord { + let record = ProviderRecord::new( + &capsule().to_key(), + &PeerId::from_bytes([peer; 32]), + vec![CandidateAddr::direct(host, 9444)], + u64::MAX, + ); + match coin { + Some(id) => record.with_unverified_mirror_coin_id(id), + None => record, + } + } + + /// A slate exactly as a single lookup answer would deliver it. + struct Slate(Vec); + + #[async_trait] + impl ProviderLocator for Slate { + async fn find_providers( + &self, + _content: &ContentId, + ) -> Result, DownloadError> { + Ok(self.0.clone()) + } + } + + /// A chain that answers YES to everything `verdict_for` can check without this node's own + /// judgement: the coin exists, is unspent, is a mirror coin, is fully collateralised, and + /// advertises exactly this `(store, root, epoch)`. The ONLY step left is the real production + /// gate — [`peer_declaration`] — so this double cannot make the layer look safer than it is. + struct EveryChainCheckPasses; + + #[async_trait] + impl dig_node_core::mirror_bond::MirrorBondVerifier for EveryChainCheckPasses { + async fn verify( + &self, + _content: &ContentId, + claiming_peer_id: &str, + claimed: Option<[u8; 32]>, + ) -> BondVerdict { + if claimed.is_none() { + return BondVerdict::Unverified; + } + // The coin's memo tail as a coin owned by this claimant would carry it. + let terms = vec![format!("dig-peer:{claiming_peer_id}")]; + match peer_declaration(&terms, claiming_peer_id) { + PeerDeclaration::DeclaresThisPeer => BondVerdict::Bonded, + PeerDeclaration::Silent | PeerDeclaration::NotReadable => BondVerdict::Unverified, + } + } + } + + /// **Proves (dig-node#466, HIGH finding 2 — the residual the credit-only lattice does NOT + /// close):** a hearsay record naming an honest holder's peer id AND its real coin id, but + /// carrying the ATTACKER's addresses, is not promoted — so the address a redirected reader + /// dials is unchanged by it. + /// + /// **Catches:** the hole neither other test can see. Every field of this record is honest + /// except the one that matters, so a coin-id check passes, a peer-id check passes, and the peer + /// id is IDENTICAL in the passing and failing versions of the code — which is why the assertion + /// is on the addresses. `stop_on_providers` means one answer can be the whole slate, so a + /// promotion here puts the attacker's host first for every reader that trusts the ranking, and + /// the dial does not pin the peer id (DIG-Network/dig-gossip#85) to catch it afterwards. + /// + /// The verifier is driven through the REAL [`peer_declaration`] gate rather than a hand-written + /// verdict, so this test measures production's answer: with no sound coin -> peer binding + /// available, nothing is promoted at all. + #[tokio::test] + async fn an_honest_peer_id_with_attacker_addresses_is_not_promoted() { + let slate = Slate(vec![ + holder_at(0xCC, None, "honest.example"), // an ordinary holder, no pointer + holder_at(0xAA, Some([0x01; 32]), "attacker.example"), + ]); + let slot = bond_verifier_slot(); + let _ = slot.set(Arc::new(EveryChainCheckPasses)); + let locator = BondRankingLocator::new(Arc::new(slate), slot); + + let got = locator.find_providers(&capsule()).await.expect("located"); + let hosts: Vec = got.iter().map(|r| r.addresses[0].host.clone()).collect(); + + assert_eq!( + hosts, + vec!["honest.example", "attacker.example"], + "a record whose peer id and coin id are both honest must still not promote the \ + addresses it chose for itself" + ); + } + #[test] fn no_visible_term_promotes_a_claim_before_the_typed_accessor_exists() { let peer = "aa".repeat(32); From e59548a8c700e36c2e45286de7222ce7b9938a6b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 17:11:59 -0700 Subject: [PATCH 8/9] fix(mirror): split the chain half from the peer gate so the conformance control survives `chain_bond_verdict` answers "does this coin bond this content"; `verdict_for` adds "and does it name the peer claiming it". The split keeps `tests/mirror_bond_verify.rs`'s `Bonded` control meaningful -- routed through `verdict_for` the honest coin and a coin nobody could look up would both answer `Unverified`, so every negative case would be equally explained by a fixture too broken to verify at all. Adds a test on the SAME on-chain fixture asserting both halves: the chain establishes the bond, and the claim is still not promoted to a peer the coin does not name. --- .../src/mirror/bond_verify.rs | 74 ++++++++++++++++--- .../tests/mirror_bond_verify.rs | 57 +++++++++++++- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index 821dfe89..aa697fe3 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -115,8 +115,11 @@ pub(crate) fn peer_declaration( PeerDeclaration::NotReadable } -/// Whether `claimed_coin_id` genuinely bonds `store_launcher_id` at `root_hash` for `epoch` -/// **on behalf of `claiming_peer_id`**. +/// Whether `claimed_coin_id` genuinely bonds `store_launcher_id` at `root_hash` for `epoch`. +/// +/// This is the CHAIN half only. `Bonded` here means a real coin bonds this content — it does NOT +/// mean the peer offering the record is that coin's holder. [`verdict_for`] adds that question, and +/// it is the one that decides promotion. /// /// `required_collateral` is this node's censused per-store requirement, or `None` when it has no /// record for the epoch. Pure over the source, so a test can drive every branch with real coins @@ -128,13 +131,12 @@ pub(crate) fn peer_declaration( /// `claiming_peer_id` is the peer id off the same untrusted record as `claimed_coin_id`. A coin id /// is a public fact, so a coin that bonds the content proves nothing about WHO is offering it; the /// last step asks the coin whether it declares this claimant, and only that answer promotes. -pub fn verdict_for( +pub fn chain_bond_verdict( source: &S, store_launcher_id: Bytes32, root_hash: Bytes32, epoch: &BigInt, required_collateral: Option, - claiming_peer_id: &str, claimed_coin_id: Bytes32, ) -> BondVerdict { let record = match source.coin_record(claimed_coin_id) { @@ -190,19 +192,67 @@ pub fn verdict_for( None => return BondVerdict::Unverified, } - // Step 5 — WHOSE bond is it? A valid, fully-collateralised coin bonding exactly this content - // still says nothing about the peer offering the record; every field of that record, including - // the coin id, was chosen by whoever answered the lookup. Only the coin's own declaration of a - // peer can close that, and this node cannot read one yet (see `peer_declaration`), so no claim - // is promoted today. - match peer_declaration(mirror.urls(), claiming_peer_id) { + // The chain half is satisfied. WHOSE bond it is is a separate question -- see `verdict_for`. + BondVerdict::Bonded +} + +/// The full verdict: the chain half, then **whose bond it is**. +/// +/// A valid, fully-collateralised coin bonding exactly this content still says nothing about the +/// peer offering the record — every field of that record, the coin id included, was chosen by +/// whoever answered the lookup. Only the coin's own owner-written declaration of a peer closes that, +/// and this node cannot read one yet (see [`peer_declaration`]), so nothing is promoted today. +/// +/// Credit is withheld, never subtracted: a record naming this coin may be a stranger's lie ABOUT the +/// coin's real holder, and demoting on it is what would make that lie pay. +pub fn verdict_for( + source: &S, + store_launcher_id: Bytes32, + root_hash: Bytes32, + epoch: &BigInt, + required_collateral: Option, + claiming_peer_id: &str, + claimed_coin_id: Bytes32, +) -> BondVerdict { + let chain = chain_bond_verdict( + source, + store_launcher_id, + root_hash, + epoch, + required_collateral, + claimed_coin_id, + ); + if chain != BondVerdict::Bonded { + return chain; + } + match declared_peer(source, claimed_coin_id, claiming_peer_id) { PeerDeclaration::DeclaresThisPeer => BondVerdict::Bonded, - // Credit withheld, never subtracted: this record may be a stranger's lie about an honest - // holder's coin, and demoting on it is what would make that lie pay. PeerDeclaration::Silent | PeerDeclaration::NotReadable => BondVerdict::Unverified, } } +/// Re-reads the coin whose chain checks already passed and asks whether it declares the claimant. +/// +/// A second read rather than a returned `MirrorCoin` so [`chain_bond_verdict`] keeps the exact +/// signature its conformance tests drive, and because the read is memoised one layer up: in +/// production this path is unreachable until the declaration has a typed source. +fn declared_peer( + source: &S, + claimed_coin_id: Bytes32, + claiming_peer_id: &str, +) -> PeerDeclaration { + let Ok(Some(record)) = source.coin_record(claimed_coin_id) else { + return PeerDeclaration::NotReadable; + }; + let Ok(Some(spend)) = source.coin_spend(record.coin.parent_coin_info) else { + return PeerDeclaration::NotReadable; + }; + let Ok(Some(mirror)) = MirrorCoin::from_creating_spend(&spend, claimed_coin_id) else { + return PeerDeclaration::NotReadable; + }; + peer_declaration(mirror.urls(), claiming_peer_id) +} + /// The production [`MirrorBondVerifier`]: one bounded chain read per distinct claim, memoised. pub struct ChainBondVerifier { chain: Arc, diff --git a/crates/dig-node-service/tests/mirror_bond_verify.rs b/crates/dig-node-service/tests/mirror_bond_verify.rs index e958dced..8e422ab5 100644 --- a/crates/dig-node-service/tests/mirror_bond_verify.rs +++ b/crates/dig-node-service/tests/mirror_bond_verify.rs @@ -16,7 +16,7 @@ mod support; use chia_protocol::{Bytes32, CoinSpend}; use dig_chainsource_interface::{ChainSource, CoinRecord, SingletonLineage}; use dig_node_core::mirror_bond::BondVerdict; -use dig_node_service::mirror::bond_verify::verdict_for; +use dig_node_service::mirror::bond_verify::{chain_bond_verdict, verdict_for}; use num_bigint::BigInt; use std::collections::HashMap; @@ -159,13 +159,21 @@ fn two_honest_bonds() -> (Chain, Bytes32, Bytes32) { (chain, coin_1.coin_id(), coin_2.coin_id()) } +/// The CHAIN half of the verdict: does this coin bond this content, for this epoch? +/// +/// Deliberately not the whole answer. `verdict_for` asks a second question — does the coin declare +/// the peer claiming it — which nothing can answer affirmatively until `dig-mirror-coin` exposes a +/// typed accessor for the declaration. Driving these conformance tests through the chain half keeps +/// their `Bonded` control MEANINGFUL: if they went through `verdict_for`, the honest coin and a coin +/// nobody could look up would both answer `Unverified`, and every negative case here would be +/// equally explained by a fixture too broken to verify at all. fn verdict( chain: &impl ChainSource, root: Bytes32, coin: Bytes32, required: Option, ) -> BondVerdict { - verdict_for(chain, store_a(), root, &epoch(), required, coin) + chain_bond_verdict(chain, store_a(), root, &epoch(), required, coin) } /// **Proves:** a coin that bonds a DIFFERENT generation of the same store does not bond this one — @@ -358,3 +366,48 @@ fn a_declaration_that_disagrees_with_its_own_hint_is_unbonded() { "the declared tuple is right and the hint it sits under is not" ); } + +/// **Proves (dig-node#466, HIGH finding 2):** a coin that passes EVERY chain check is still not +/// promoted, because nothing yet binds the coin to the peer claiming it. +/// +/// **Catches:** the attack the chain half cannot see. This fixture is a real, fully collateralised +/// mirror coin bonding exactly this `(store, root, epoch)` — the control above proves the chain half +/// says `Bonded` for it. A coin id is a public fact, so ANY peer can publish a record carrying this +/// coin id, this coin's honest holder's peer id, and its own addresses; every check that looks only +/// at the coin passes, and the record is promoted to first while pointing at the publisher. Until +/// the coin itself names its holder, the only sound answer is to promote nobody. +/// +/// The two halves are asserted together on ONE fixture so the second cannot be satisfied by a +/// broken chain: the same coin, same store, same root, same epoch, differing only in which question +/// is asked. +#[test] +fn a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant() { + let (chain, bonds_root_1, _bonds_root_2) = two_honest_bonds(); + let claimant = "aa".repeat(32); + + assert_eq!( + chain_bond_verdict( + &chain, + store_a(), + root_1(), + &epoch(), + Some(COLLATERAL), + bonds_root_1 + ), + BondVerdict::Bonded, + "control: the chain half genuinely establishes this bond" + ); + assert_eq!( + verdict_for( + &chain, + store_a(), + root_1(), + &epoch(), + Some(COLLATERAL), + &claimant, + bonds_root_1 + ), + BondVerdict::Unverified, + "a bond proven on chain must not promote a peer the coin does not name" + ); +} From 54e31f2cc23869ec1c69fb518aac8fa7255860b4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:11:48 -0700 Subject: [PATCH 9/9] fix(mirror): key the bond verdict on its claimant and stop paying for a discarded one Two gate findings on #467, plus the two doc claims that codified them. The verdict cache was keyed on `(coin id, store, root, epoch)` while `verdict_for` answers a peer-DEPENDENT question. A `Bonded` earned by a coin's real holder would have been served, for the whole 600s TTL, to any stranger republishing that public coin id -- reinstating through the memo layer the substitution the ownership half exists to refuse. The claiming peer id is now part of the key, hashed so the key stays fixed-size and `Copy` against an attacker-chosen string. Caching `Bonded` is retained deliberately: refusing to cache it would trade this for unbounded chain reads. While `peer_declaration` has no typed source, `Bonded` is unreachable for every input, so the chain reads were paid at a third party for a verdict the credit-only stable sort provably discards -- up to 32 uncacheable `api.coinset.org` reads per locate, admitted by one token of a bucket sized for a cheap lookup. `verdict_for` and the production verifier now short-circuit to `Unverified` before any read. The gate is a probe of `peer_declaration` itself rather than a separate flag, so it lifts when 0.8.0's accessor arrives with no second switch to remember, and an accessor needing more than the term list leaves it closed. The ownership half no longer re-fetches the coin the chain half just read, so a bonded holder costs two reads rather than four once promotion is live. Also: `cached_epoch()`'s hint is gone. Its stated invariant was false across a rollover -- the key was built FROM the stale epoch, so it hit the entry stored under the previous one. `current_epoch_now()` is clock arithmetic, so the cache is now probed under the true epoch and only the epoch-record parse is deferred to a miss. `declared_peer`'s two false claims (unreachable in production; memoised one layer up) are gone with the function. SPEC.md 25.6a specified the peer-agnostic key normatively and now specifies the claimant, the true-epoch probe, and the short-circuit. Refs: DIG-Network/dig-node#466 Co-Authored-By: Claude --- SPEC.md | 21 +- crates/dig-node-service/Cargo.toml | 4 + .../src/mirror/bond_verify.rs | 462 +++++++++++++----- 3 files changed, 358 insertions(+), 129 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5f24df82..1f60c254 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8604,9 +8604,24 @@ standing its source gave it. Absence of a pointer is the ORDINARY case and MUST cost no chain read at all. `unverified` for an absent pointer is not a degraded answer — it is the honest state of a claim nobody looked at. -A verdict is cached only for the exact `(coin id, store, root, epoch)` it answered, because one coin -bonds one tuple; caching by coin id alone would let a genuine bond answer for content the same coin -does not bond, which is the substitution `advertises` exists to refuse. Only DEFINITE verdicts are +A verdict is cached only for the exact `(coin id, store, root, epoch, claiming peer id)` it answered. +Every component is load-bearing. One coin bonds one `(store, root, epoch)` tuple, so caching by coin +id alone would let a genuine bond answer for content the same coin does not bond — the substitution +`advertises` exists to refuse. And the verdict is peer-DEPENDENT: it is `bonded` only when the coin +declares the peer offering the record, so a key omitting the claiming peer id would serve one +holder's earned `bonded`, for the whole cache lifetime, to any stranger republishing the same +publicly-visible coin id — reinstating through the memo the substitution the ownership question +exists to refuse. The cache MUST also be probed under the node's TRUE current epoch rather than a +remembered one, or a probe taken after a rollover hits the entry stored under the previous epoch and +returns a verdict taken under the wrong one. + +While the node has no sound source for the coin-to-peer binding, `bonded` is unreachable for every +input, and the verifier MUST then read no chain at all: the reads would be paid, at a third party, for +a verdict the credit-only ranking provably discards. That short-circuit MUST be conditioned on the +binding source itself, so that it lifts when the source arrives rather than needing a second switch +to be remembered. + +Only DEFINITE verdicts are cached: `unverified` records this node's own momentary inability to look, and holding it would keep an outage in force after it had ended. The cache is keyed partly on attacker-chosen input, so it MUST be bounded, and overflow MUST evict rather than clear — clearing would let a stranger discard every diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index d3332aac..69b86d25 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -126,6 +126,10 @@ dig-chainsource-interface = "0.3" # builder wants is a different type from the one the caller holds. chia-bls = "0.36.1" chia-protocol = "0.36.1" +# The mirror-coin verdict cache keys on a hash of the claiming peer id, so the key stays fixed-size +# and `Copy` against an attacker-chosen string. Same 0.36.1 line as every other chia primitive here +# -- a second line would be a second `Sha256`. +chia-sha2 = "0.36.1" chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] } # `MAINNET_CONSTANTS` -- the Chia L1 `AGG_SIG_ME` domain every mirror-coin spend is signed under # (`mirror::lifecycle::mirror_agg_sig_data`, dig-node#447). This is the crate `chia_wallet_sdk::types` diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index aa697fe3..552adb98 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -31,7 +31,6 @@ //! then downgrades an otherwise-good answer to `Unverified` rather than promoting it to `Bonded`. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -65,12 +64,43 @@ const MAX_CACHED_VERDICTS: usize = 1024; /// The coin id alone is not the key: one coin bonds one `(store, root, epoch)`, so caching by coin /// would let a genuine `Bonded` for one capsule answer for a different capsule the same coin does /// not bond — the precise substitution `advertises` exists to refuse. +/// +/// **The claiming peer is part of the key for the same reason.** [`verdict_for`] answers a +/// peer-DEPENDENT question: the chain half establishes that a coin bonds this content, and the +/// ownership half asks whether that coin declares the peer offering the record. A key without the +/// claimant would let a `Bonded` earned by the coin's real holder be served, for the whole +/// [`VERDICT_TTL`], to any stranger republishing the same public coin id — reinstating through the +/// memo layer the exact substitution the ownership half exists to refuse. It is inert only while +/// [`peer_declaration`] cannot return `DeclaresThisPeer`, and it must not depend on that. #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct VerdictKey { coin_id: [u8; 32], store_launcher_id: [u8; 32], root_hash: [u8; 32], epoch: u64, + /// `SHA-256` of the claiming peer id. Hashed rather than held so the key stays `Copy` and + /// fixed-size against an attacker-chosen string; only equality is ever needed. + claiming_peer: [u8; 32], +} + +impl VerdictKey { + fn new( + coin_id: [u8; 32], + store: Bytes32, + root: Bytes32, + epoch: u64, + claiming_peer_id: &str, + ) -> Self { + let mut hasher = chia_sha2::Sha256::new(); + hasher.update(claiming_peer_id.as_bytes()); + VerdictKey { + coin_id, + store_launcher_id: store.to_bytes(), + root_hash: root.to_bytes(), + epoch, + claiming_peer: hasher.finalize(), + } + } } /// What a mirror coin's advertised terms say about the peer claiming it. @@ -106,8 +136,12 @@ pub(crate) enum PeerDeclaration { /// (mirror_bond's lattice is credit-only), so the interim behaviour is exactly the behaviour of a /// node with no verifier at all. /// -/// Replacing the body with the 0.8.0 accessor is the whole of the change that makes promotion live — -/// and the authoritative-record restriction on dig-node#466 MUST land with it, never after. +/// Replacing the body with the 0.8.0 accessor turns promotion on, and three things MUST land in +/// that same change, never after: the authoritative-record restriction on dig-node#466, the +/// claiming peer id staying part of [`VerdictKey`] (a peer-agnostic key would serve one peer's +/// earned `Bonded` to a stranger republishing the same public coin id), and the cost analysis on +/// [`declaration_source_is_readable`], whose short-circuit lifts itself the moment this function +/// can answer. pub(crate) fn peer_declaration( _advertised_terms: &[String], _claiming_peer_id: &str, @@ -115,6 +149,27 @@ pub(crate) fn peer_declaration( PeerDeclaration::NotReadable } +/// Whether [`peer_declaration`] can bind a coin to a peer AT ALL — probed through the real +/// function, on the most favourable input that exists. +/// +/// While the answer is `false`, [`verdict_for`] cannot return `Bonded` for any input, so every +/// chain read it would perform buys a verdict that is discarded: `Unverified` and `Unbonded` share +/// a rank in `credit_rank`, and the sort is stable, so the located slate is returned unchanged. +/// Paying four third-party HTTPS reads per holder, up to the locate budget, for a provably +/// discarded answer converts one cheap-lookup token into attacker-directed egress at +/// `api.coinset.org` — which degrades the same transport this node's wallet reads through. +/// +/// **The gate is the condition itself, not a flag beside it.** The probe asks the production +/// function for the one term a coin owned by `probe_peer` would carry; a typed accessor that can +/// answer returns `DeclaresThisPeer` for it, the probe flips, and the short-circuit removes itself +/// with no second switch to remember. An accessor that needs more than the term list stays +/// unreadable here, which withholds credit rather than granting it — the fail-closed direction. +fn declaration_source_is_readable() -> bool { + let probe_peer = "00".repeat(32); + let terms = [format!("dig-peer:{probe_peer}")]; + peer_declaration(&terms, &probe_peer) == PeerDeclaration::DeclaresThisPeer +} + /// Whether `claimed_coin_id` genuinely bonds `store_launcher_id` at `root_hash` for `epoch`. /// /// This is the CHAIN half only. `Bonded` here means a real coin bonds this content — it does NOT @@ -139,24 +194,52 @@ pub fn chain_bond_verdict( required_collateral: Option, claimed_coin_id: Bytes32, ) -> BondVerdict { + chain_bond_verdict_and_coin( + source, + store_launcher_id, + root_hash, + epoch, + required_collateral, + claimed_coin_id, + ) + .0 +} + +/// [`chain_bond_verdict`], additionally handing back the coin it read. +/// +/// The ownership half needs the SAME `MirrorCoin` the chain half just re-derived. Returning it is +/// what keeps a bonded holder at two chain reads rather than four: the reads are outbound HTTPS to +/// a shared third party, so re-fetching to ask a second question about one coin doubles this +/// node's egress for no new information. +/// +/// The coin is returned only alongside [`BondVerdict::Bonded`] — every other verdict is reached +/// before, or instead of, a coin that binds this claim. +fn chain_bond_verdict_and_coin( + source: &S, + store_launcher_id: Bytes32, + root_hash: Bytes32, + epoch: &BigInt, + required_collateral: Option, + claimed_coin_id: Bytes32, +) -> (BondVerdict, Option) { let record = match source.coin_record(claimed_coin_id) { Ok(Some(record)) => record, // The chain answered and there is no such coin. The publisher named something that does not // exist, which is a claim disproven rather than a claim unexamined. - Ok(None) => return BondVerdict::Unbonded, - Err(_) => return BondVerdict::Unverified, + Ok(None) => return (BondVerdict::Unbonded, None), + Err(_) => return (BondVerdict::Unverified, None), }; // A spent coin locks nothing. Collateral is the coin remaining unspent; a reclaimed one is a // bond that has already been taken back. if record.is_spent() { - return BondVerdict::Unbonded; + return (BondVerdict::Unbonded, None); } // Step 1. Every mirror coin in existence shares this puzzle hash, so failing here says the coin // is not collateral of any kind. if record.coin.puzzle_hash != mirror_coin_puzzle_hash() { - return BondVerdict::Unbonded; + return (BondVerdict::Unbonded, None); } // Steps 2 and 3's asset id, and the owner step 4 needs, all come from EXECUTED on-chain code: @@ -167,33 +250,33 @@ pub fn chain_bond_verdict( Ok(Some(spend)) => spend, // The coin exists, so its parent was spent; a source that cannot produce that spend has a // gap rather than an answer. - Ok(None) | Err(_) => return BondVerdict::Unverified, + Ok(None) | Err(_) => return (BondVerdict::Unverified, None), }; let mirror = match MirrorCoin::from_creating_spend(&creating_spend, claimed_coin_id) { Ok(Some(mirror)) => mirror, // Established, and the answer is no: not a $DIG-collateral coin, or one advertising nothing. - Ok(None) => return BondVerdict::Unbonded, - Err(MirrorError::ChainUnavailable(_)) => return BondVerdict::Unverified, + Ok(None) => return (BondVerdict::Unbonded, None), + Err(MirrorError::ChainUnavailable(_)) => return (BondVerdict::Unverified, None), // Memos that will not decode. The publisher chose this coin id and chose those memos, so // this is its claim failing, not this node failing to look. - Err(_) => return BondVerdict::Unbonded, + Err(_) => return (BondVerdict::Unbonded, None), }; // Step 4 — the one that binds the coin to THIS claim. if !mirror.advertises(store_launcher_id, root_hash, epoch) { - return BondVerdict::Unbonded; + return (BondVerdict::Unbonded, None); } // Step 3's magnitude (see the module docs for why it is not first). match required_collateral { - Some(required) if mirror.collateral() < required => return BondVerdict::Unbonded, + Some(required) if mirror.collateral() < required => return (BondVerdict::Unbonded, None), Some(_) => {} - None => return BondVerdict::Unverified, + None => return (BondVerdict::Unverified, None), } // The chain half is satisfied. WHOSE bond it is is a separate question -- see `verdict_for`. - BondVerdict::Bonded + (BondVerdict::Bonded, Some(mirror)) } /// The full verdict: the chain half, then **whose bond it is**. @@ -205,6 +288,10 @@ pub fn chain_bond_verdict( /// /// Credit is withheld, never subtracted: a record naming this coin may be a stranger's lie ABOUT the /// coin's real holder, and demoting on it is what would make that lie pay. +/// +/// **No chain is read at all while the ownership half has no source** (see +/// [`declaration_source_is_readable`]): with `Bonded` unreachable, the reads would be paid for an +/// answer this function is about to discard. pub fn verdict_for( source: &S, store_launcher_id: Bytes32, @@ -214,7 +301,12 @@ pub fn verdict_for( claiming_peer_id: &str, claimed_coin_id: Bytes32, ) -> BondVerdict { - let chain = chain_bond_verdict( + // Before any chain read: while nothing can bind a coin to a peer, `Bonded` is unreachable and + // the reads below would be paid for a verdict this function is about to discard. + if !declaration_source_is_readable() { + return BondVerdict::Unverified; + } + let (chain, coin) = chain_bond_verdict_and_coin( source, store_launcher_id, root_hash, @@ -222,44 +314,61 @@ pub fn verdict_for( required_collateral, claimed_coin_id, ); - if chain != BondVerdict::Bonded { + let Some(mirror) = coin else { + // Every non-`Bonded` verdict arrives without a coin, and `Bonded` never arrives without one. return chain; - } - match declared_peer(source, claimed_coin_id, claiming_peer_id) { + }; + match peer_declaration(mirror.urls(), claiming_peer_id) { PeerDeclaration::DeclaresThisPeer => BondVerdict::Bonded, PeerDeclaration::Silent | PeerDeclaration::NotReadable => BondVerdict::Unverified, } } -/// Re-reads the coin whose chain checks already passed and asks whether it declares the claimant. +/// The memo of definite verdicts, keyed on the exact question each one answered. /// -/// A second read rather than a returned `MirrorCoin` so [`chain_bond_verdict`] keeps the exact -/// signature its conformance tests drive, and because the read is memoised one layer up: in -/// production this path is unreachable until the declaration has a typed source. -fn declared_peer( - source: &S, - claimed_coin_id: Bytes32, - claiming_peer_id: &str, -) -> PeerDeclaration { - let Ok(Some(record)) = source.coin_record(claimed_coin_id) else { - return PeerDeclaration::NotReadable; - }; - let Ok(Some(spend)) = source.coin_spend(record.coin.parent_coin_info) else { - return PeerDeclaration::NotReadable; - }; - let Ok(Some(mirror)) = MirrorCoin::from_creating_spend(&spend, claimed_coin_id) else { - return PeerDeclaration::NotReadable; - }; - peer_declaration(mirror.urls(), claiming_peer_id) +/// Its own type, rather than two fields on the verifier, so the key/lookup/eviction rules can be +/// exercised directly — including the one that matters most and is invisible from the outside: +/// that a verdict earned by one claiming peer is never served to another. +#[derive(Default)] +struct VerdictCache { + entries: Mutex>, +} + +impl VerdictCache { + /// The verdict recorded for exactly this question, if one is recorded and still fresh. + fn get(&self, key: &VerdictKey) -> Option { + let entries = self.entries.lock().ok()?; + entries + .get(key) + .filter(|(taken, _)| taken.elapsed() < VERDICT_TTL) + .map(|(_, verdict)| *verdict) + } + + /// Remember a DEFINITE verdict. `Unverified` is never cached: it records this node's own + /// momentary inability to look, and holding it would keep an outage in force after it ended. + fn remember(&self, key: VerdictKey, verdict: BondVerdict) { + if verdict == BondVerdict::Unverified { + return; + } + let Ok(mut entries) = self.entries.lock() else { + return; + }; + if entries.len() >= MAX_CACHED_VERDICTS { + // Evict one arbitrary entry, not the map. `HashMap` iteration order is unspecified, so + // the victim is not attacker-selectable either; the cost of a wrong guess is one chain + // read, never a wrong answer. + if let Some(victim) = entries.keys().next().copied() { + entries.remove(&victim); + } + } + entries.insert(key, (Instant::now(), verdict)); + } } /// The production [`MirrorBondVerifier`]: one bounded chain read per distinct claim, memoised. pub struct ChainBondVerifier { chain: Arc, - cache: Mutex>, - /// The epoch of the most recent definite verdict, plus one; `0` means none yet. Read to probe - /// the cache before paying for the epoch file. - last_epoch: AtomicU64, + cache: VerdictCache, } impl ChainBondVerifier { @@ -267,32 +376,15 @@ impl ChainBondVerifier { pub fn new(chain: Arc) -> Arc { Arc::new(ChainBondVerifier { chain, - cache: Mutex::new(HashMap::new()), - last_epoch: AtomicU64::new(0), + cache: VerdictCache::default(), }) } - fn cached(&self, key: &VerdictKey) -> Option { - let cache = self.cache.lock().ok()?; - cache - .get(key) - .filter(|(taken, _)| taken.elapsed() < VERDICT_TTL) - .map(|(_, verdict)| *verdict) - } - - /// The epoch the last definite verdict was taken under, or `None` when nothing is cached. - /// - /// Lets the cache be probed without re-reading the epoch file. It is only a HINT: a miss falls - /// through to the real read, and a stale value can only produce a cache miss, never a verdict - /// taken under the wrong epoch, because the epoch remains part of the key. - fn cached_epoch(&self) -> Option { - self.last_epoch.load(Ordering::Relaxed).checked_sub(1) - } - /// The chain half: one bounded read, memoised. #[allow(clippy::too_many_arguments)] async fn verify_against_chain( &self, + key: VerdictKey, store: Bytes32, root: Bytes32, epoch: u64, @@ -300,16 +392,6 @@ impl ChainBondVerifier { claiming_peer_id: &str, coin_id: [u8; 32], ) -> BondVerdict { - let key = VerdictKey { - coin_id, - store_launcher_id: store.to_bytes(), - root_hash: root.to_bytes(), - epoch, - }; - if let Some(hit) = self.cached(&key) { - return hit; - } - // Re-read per call rather than held from bring-up, matching the mirror pass: a transport // built once would make a node that started offline one that never verifies again. let Ok(source) = self @@ -334,31 +416,9 @@ impl ChainBondVerifier { ) }); - self.remember(key, verdict); + self.cache.remember(key, verdict); verdict } - - /// Remember a DEFINITE verdict. `Unverified` is never cached: it records this node's own - /// momentary inability to look, and holding it would keep an outage in force after it ended. - fn remember(&self, key: VerdictKey, verdict: BondVerdict) { - if verdict == BondVerdict::Unverified { - return; - } - // +1 so that "never set" and "epoch 0" stay distinguishable in one atomic. - self.last_epoch.store(key.epoch + 1, Ordering::Relaxed); - let Ok(mut cache) = self.cache.lock() else { - return; - }; - if cache.len() >= MAX_CACHED_VERDICTS { - // Evict one arbitrary entry, not the map. `HashMap` iteration order is unspecified, so - // the victim is not attacker-selectable either; the cost of a wrong guess is one chain - // read, never a wrong answer. - if let Some(victim) = cache.keys().next().copied() { - cache.remove(&victim); - } - } - cache.insert(key, (Instant::now(), verdict)); - } } /// The `(store, root)` a bond could be checked against, or `None` for a store-granularity id. @@ -375,14 +435,25 @@ fn bondable_tuple(content: &ContentId) -> Option<(Bytes32, Bytes32)> { } } -/// This node's current epoch and its censused per-store requirement, or `None` when the epoch itself -/// is not yet settled. -fn epoch_and_requirement() -> Option<(u64, Option)> { +/// This node's current epoch, or `None` when it is not yet settled. +/// +/// Clock arithmetic only — no file is touched, which is why the cache can be probed under the TRUE +/// current epoch rather than under a remembered hint. A hint would hit an entry stored under the +/// previous epoch for up to [`VERDICT_TTL`] after a rollover, which is a verdict taken under the +/// wrong epoch and not merely a miss. +fn settled_epoch() -> Option { + match current_epoch_now() { + crate::collateral::CurrentEpoch::Final(epoch) => Some(epoch), + _ => None, + } +} + +/// This node's censused per-store requirement for the current epoch, or `None` when it has no +/// record for it. +/// +/// A file read plus a line-by-line JSON parse, so it is paid only on a cache miss. +fn current_requirement() -> Option { let current = current_epoch_now(); - let epoch = match current { - crate::collateral::CurrentEpoch::Final(epoch) => epoch, - _ => return None, - }; let required = match requirement(&EpochRecordStore::in_state_dir(), current) { CollateralRequirementResult::Known { required_per_store_dig_base_units, @@ -390,7 +461,7 @@ fn epoch_and_requirement() -> Option<(u64, Option)> { } => Some(required_per_store_dig_base_units), _ => None, }; - Some((epoch, required)) + required } #[async_trait] @@ -409,34 +480,31 @@ impl MirrorBondVerifier for ChainBondVerifier { let Some((store, root)) = bondable_tuple(content) else { return BondVerdict::Unverified; }; - // The epoch read is a file read plus a line-by-line JSON parse, so it happens AFTER the - // cheap in-memory probe rather than before it: a slate of records for one capsule otherwise - // pays that parse per record even when every verdict is already known. The cache is keyed - // on the epoch, so the probe uses the epoch the last read established and re-reads only on a - // miss. - let Some(epoch) = self.cached_epoch() else { - let Some((epoch, required)) = epoch_and_requirement() else { - return BondVerdict::Unverified; - }; - return self - .verify_against_chain(store, root, epoch, required, claiming_peer_id, coin_id) - .await; - }; - let key = VerdictKey { - coin_id, - store_launcher_id: store.to_bytes(), - root_hash: root.to_bytes(), - epoch, - }; - if let Some(hit) = self.cached(&key) { - return hit; + // Nothing below can produce `Bonded` while the ownership half has no source, so the whole + // leg — cache, epoch, chain — is skipped rather than paid for a discarded answer. + if !declaration_source_is_readable() { + return BondVerdict::Unverified; } - let Some((epoch, required)) = epoch_and_requirement() else { + let Some(epoch) = settled_epoch() else { return BondVerdict::Unverified; }; + // The cheap in-memory probe first: a slate of records for one capsule otherwise pays the + // epoch-record parse per record even when every verdict is already known. + let key = VerdictKey::new(coin_id, store, root, epoch, claiming_peer_id); + if let Some(hit) = self.cache.get(&key) { + return hit; + } - self.verify_against_chain(store, root, epoch, required, claiming_peer_id, coin_id) - .await + self.verify_against_chain( + key, + store, + root, + epoch, + current_requirement(), + claiming_peer_id, + coin_id, + ) + .await } } @@ -491,10 +559,70 @@ mod tests { /// This test is expected to FAIL when 0.8.0's typed accessor lands. That is its second job: the /// authoritative-record restriction must land in the same change that makes promotion live. use async_trait::async_trait; + use dig_chainsource_interface::{CoinRecord, SingletonLineage}; use dig_node_core::mirror_bond::{ bond_verifier_slot, BondRankingLocator, CandidateAddr, DownloadError, PeerId, ProviderLocator, ProviderRecord, }; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + + /// A chain that counts every read reaching it and answers nothing. + /// + /// Answering nothing is deliberate: the property under test is that the source is not consulted + /// AT ALL, so a double that could satisfy a read would let a short-circuit that merely fails + /// fast look identical to one that never asks. + struct CountingChain { + reads: Arc, + } + + impl CountingChain { + fn counted(&self, answer: T) -> Result { + self.reads.fetch_add(1, AtomicOrdering::Relaxed); + Ok(answer) + } + } + + impl ChainSource for CountingChain { + type Error = String; + + fn coin_record(&self, _coin_id: Bytes32) -> Result, Self::Error> { + self.counted(None) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + self.counted(Vec::new()) + } + + fn coin_records_by_parent(&self, _parent: Bytes32) -> Result, Self::Error> { + self.counted(Vec::new()) + } + + fn coin_spend( + &self, + _coin_id: Bytes32, + ) -> Result, Self::Error> { + self.counted(None) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + self.counted(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + self.counted(Some(1_000)) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + self.counted(Some(1_700_000_000)) + } + } const STORE: [u8; 32] = [0x11; 32]; const ROOT: [u8; 32] = [0x22; 32]; @@ -591,6 +719,88 @@ mod tests { ); } + /// **Proves (dig-node#466, review round 2):** a verdict earned by one claiming peer is never + /// served from the memo to a DIFFERENT peer naming the same coin for the same content. + /// + /// **Catches:** the ownership check being reinstated-then-bypassed through the cache. A coin id + /// is a public fact published in provider records by design, so a stranger can republish + /// another peer's coin id verbatim; if the key omitted the claimant, that stranger would be + /// served the real holder's `Bonded` for the whole TTL and `verdict_for`'s second question + /// would never be asked of it. + /// + /// The two lookups differ in EXACTLY one field — the claiming peer — and the same-peer read is + /// asserted as a control, so a key that simply never hits would not pass. + #[test] + fn a_verdict_earned_by_one_peer_is_not_served_to_another() { + let store = Bytes32::new(STORE); + let root = Bytes32::new(ROOT); + let coin = [0x33; 32]; + let holder = "aa".repeat(32); + let stranger = "bb".repeat(32); + + let cache = VerdictCache::default(); + cache.remember( + VerdictKey::new(coin, store, root, 7, &holder), + BondVerdict::Bonded, + ); + + assert_eq!( + cache.get(&VerdictKey::new(coin, store, root, 7, &holder)), + Some(BondVerdict::Bonded), + "control: the peer that earned the verdict is still served it" + ); + assert_eq!( + cache.get(&VerdictKey::new(coin, store, root, 7, &stranger)), + None, + "a stranger republishing the same coin id must re-ask, not inherit the holder's verdict" + ); + } + + /// **Proves (dig-node#466, security F1):** no chain is read at all while nothing can bind a + /// coin to a peer. + /// + /// **Catches:** the amplifier. The production `ChainSource` reaches `api.coinset.org`, and a + /// `Bonded` that degrades to `Unverified` is the one verdict the cache refuses to hold — so + /// each read is re-paid on every locate, up to the locate budget, for an answer the stable + /// credit-only sort provably discards. The counting source makes the absence of that egress an + /// assertion rather than a claim. + /// + /// The count is asserted at zero AND the verdict at `Unverified`, so a short-circuit that + /// changed the answer would fail here rather than pass quietly. + #[test] + fn nothing_is_read_from_the_chain_while_the_declaration_has_no_source() { + let reads = Arc::new(AtomicUsize::new(0)); + let source = CountingChain { + reads: Arc::clone(&reads), + }; + + let verdict = verdict_for( + &source, + Bytes32::new(STORE), + Bytes32::new(ROOT), + &BigInt::from(7u64), + Some(1), + &"aa".repeat(32), + Bytes32::new([0x33; 32]), + ); + + assert_eq!( + verdict, + BondVerdict::Unverified, + "withholding credit is the answer the short-circuit must preserve" + ); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "a verdict that cannot be `Bonded` must cost no chain read" + ); + assert!( + !declaration_source_is_readable(), + "control: the short-circuit is active precisely because the source is unreadable — \ + when 0.8.0's accessor lands this flips and the reads resume" + ); + } + #[test] fn no_visible_term_promotes_a_claim_before_the_typed_accessor_exists() { let peer = "aa".repeat(32);