diff --git a/Cargo.lock b/Cargo.lock index 0d26ba4c..621e09c7 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.217.0" +version = "0.221.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2a394e81..cd3b4cba 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.217.0" +version = "0.221.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/SPEC.md b/SPEC.md index 08cde5b2..6d54c29e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8252,6 +8252,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 @@ -8603,6 +8608,73 @@ 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 **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)` 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 +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. 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. + +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 +verdict a node has earned by rotating coin ids. + ### 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/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/download.rs b/crates/dig-node-core/src/download.rs index 6c5c4e84..b629e613 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 @@ -2522,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() } @@ -4373,6 +4398,89 @@ 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, + _claiming_peer_id: &str, + 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 located = pc.find_providers(&cid).await.for_finding(); + let peers: Vec = located + .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(), + ], + "the provable holder is promoted; the other two keep their located order, because a \ + disproven pointer withholds credit rather than demoting (dig-node#466)" + ); + assert_eq!( + located.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-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 12bb7058..8d360325 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod chat; pub mod dht_sampling; pub mod download; 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 new file mode 100644 index 00000000..93fa3083 --- /dev/null +++ b/crates/dig-node-core/src/mirror_bond.rs @@ -0,0 +1,599 @@ +//! Acting on a peer's mirror-coin claim (dig-node#466). +//! +//! 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 gives CREDIT; it never takes it away +//! +//! [`BondRankingLocator`] promotes a proven bond and leaves everything else exactly where its source +//! put it. There are two tiers, not three: `Bonded` and *baseline*, where baseline holds an absent +//! pointer, an unprovable one and a disproven one together. +//! +//! That collapse is deliberate and it is a security property, not a simplification. A provider record +//! is hearsay -- the peer that answers a lookup chooses every field of it, including the coin id it +//! attributes to somebody else. If a disproven pointer ranked a holder BELOW where no pointer would +//! have put it, attaching a bogus coin id to an honest holder's record would be a way to demote that +//! holder, and a lookup answer would become a demotion primitive available to any stranger for free. +//! Withholding credit cannot be abused that way: the worst a liar achieves is the ranking that would +//! have existed had it said nothing at all. +//! +//! Nothing is ever dropped, for the reason it was never dropped before: absence of a pointer is the +//! ordinary case today, and a chain outage, an epoch rollover and a deliberate lie are +//! indistinguishable at the moment of reading. +//! +//! # A locate is bounded work +//! +//! Verification is chain I/O, and a stranger answering one lookup chooses how many records the slate +//! carries. At most [`MAX_VERIFIED_PER_LOCATE`] records are verified per locate, in source order; +//! the rest keep their place at baseline. Promotion is a bonus, so declining to compute it for the +//! tail costs a holder nothing it was owed. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +// 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. +/// +/// The variants are NOT a ranking. Only [`BondVerdict::Bonded`] earns promotion; the other two form +/// one baseline tier (see the module docs for why a disproven claim must not sink a holder). The +/// three states stay distinct because they mean different things to an operator reading a log, not +/// because they sort. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BondVerdict { + /// A chain answered, 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 — **and that coin declares the peer claiming it**. + /// + /// The second half is not optional. A coin id is a public fact: whoever answers a lookup can + /// attribute an honest holder's real coin to any record it likes, including one carrying its own + /// addresses. Without the coin naming the claimant, `Bonded` would mean only "some coin bonds + /// this content", which a stranger can say truthfully while pointing the record at itself. + 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` AND the claiming `peer_id` are both attacker-supplied: the coin id may be +/// absent, wrong, stale, or a real coin bonding something else entirely, and the peer id may name a +/// holder that never published this record. An implementation owes the pair 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 **on behalf of + /// `claiming_peer_id`**. + /// + /// Both arguments come off the same untrusted record and neither implies the other. An + /// implementation that ignores `claiming_peer_id` answers a strictly weaker question — "does + /// some coin bond this content" — which a stranger republishing an honest holder's coin id + /// passes. + async fn verify( + &self, + content: &ContentId, + claiming_peer_id: &str, + 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>>; + +/// The most records whose bond is read against a chain during one locate. +/// +/// A slate's size is chosen by whoever answered the lookup, and each verification is a blocking +/// chain read drawn on the budget the discovery path is sized for. Small on purpose: promotion only +/// has to reach the holders a download would try first. +pub const MAX_VERIFIED_PER_LOCATE: usize = 8; + +/// Promotion tier for a verdict — `Bonded` first, everything else in one baseline tier. +fn credit_rank(verdict: BondVerdict) -> u8 { + match verdict { + BondVerdict::Bonded => 0, + BondVerdict::Unverified | BondVerdict::Unbonded => 1, + } +} + +/// 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 at most 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<(u8, ProviderRecord)> = Vec::with_capacity(found.len()); + let mut verified = 0usize; + for record in found { + let claimed = record.unverified_mirror_coin_id_bytes(); + // A holder that claims nothing, and every record past the budget, keeps its place with + // no chain read at all. Both belong at baseline, which is where they already are. + if claimed.is_none() || verified == MAX_VERIFIED_PER_LOCATE { + ranked.push((credit_rank(BondVerdict::Unverified), record)); + continue; + } + verified += 1; + let verdict = verifier + .verify(content, &record.provider_peer_id, claimed) + .await; + if verdict == BondVerdict::Unbonded { + // Worth an operator's attention and nobody's ban list: this record's own pointer + // disproves its own claim. Logged and NOT demoted — the record may be a stranger's + // lie ABOUT an honest holder, and demoting on it is what would make that lie pay. + tracing::debug!( + peer = %record.provider_peer_id, + "located holder's claimed mirror coin does not bond this content; no promotion" + ); + } + ranked.push((credit_rank(verdict), record)); + } + + // STABLE, so holders sharing a tier keep the order their source gave them. The download + // union deliberately puts connection-verified pool addresses first (#836); a ranking that + // reshuffled within a tier would quietly undo that. + ranked.sort_by_key(|(rank, _)| *rank); + + 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}; + use std::sync::Mutex; + + 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, + _claiming_peer_id: &str, + 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) + } + } + + /// A chain in which exactly one coin exists, it bonds this content, and its memo declares + /// exactly one peer — the binding `dig-mirror-coin` 0.8.0's typed accessor will expose. + /// + /// This is the shape of the REAL guarantee, so a test built on it cannot pass by giving the + /// layer a weaker question than production asks. It also records every `(peer, coin)` pair it + /// was asked about, which is how a test proves the claiming peer id actually reached the chain + /// side rather than being dropped on the way. + struct CoinDeclaringOnePeer { + coin_first_byte: u8, + declared_peer_prefix: String, + asked: Mutex>, + } + + impl CoinDeclaringOnePeer { + fn new(coin_first_byte: u8, declared_peer: u8) -> Arc { + Arc::new(CoinDeclaringOnePeer { + coin_first_byte, + declared_peer_prefix: PeerId::from_bytes([declared_peer; 32]).to_hex(), + asked: Mutex::new(Vec::new()), + }) + } + } + + #[async_trait] + impl MirrorBondVerifier for CoinDeclaringOnePeer { + async fn verify( + &self, + _content: &ContentId, + claiming_peer_id: &str, + claimed: Option<[u8; 32]>, + ) -> BondVerdict { + let Some(coin) = claimed else { + return BondVerdict::Unverified; + }; + self.asked + .lock() + .expect("asked") + .push((claiming_peer_id.to_string(), coin[0])); + if coin[0] != self.coin_first_byte { + // The chain answered and no such bond exists. + return BondVerdict::Unbonded; + } + if claiming_peer_id != self.declared_peer_prefix { + // The coin is real and bonds this content, but it names somebody else. Credit is + // withheld, never subtracted. + return BondVerdict::Unverified; + } + BondVerdict::Bonded + } + } + + fn installed(verifier: Arc) -> BondVerifierSlot { + let slot = bond_verifier_slot(); + let _ = slot.set(verifier); + slot + } + + /// **Proves (dig-node#466, HIGH finding 2):** a stranger that republishes an honest holder's + /// real coin id under its OWN peer id is not promoted, and the claiming peer id genuinely + /// reaches the verifier. + /// + /// **Catches:** the missing peer parameter. Without it the verifier can only ask "does some coin + /// bond this content", which the liar's record passes truthfully — the coin is real, it is fully + /// collateralised, and it bonds exactly this `(store, root, epoch)`. The liar is then ranked + /// FIRST, ahead of the honest holder whose coin it copied. Asserting the ORDER alone would not + /// prove the fix: the second assertion pins that the record's own peer id was what was asked + /// about, so an implementation passing a constant or the honest peer's id fails here. + #[tokio::test] + async fn a_liar_republishing_an_honest_holders_coin_id_is_not_promoted() { + let honest = 0xAA; + let liar = 0xBB; + let slate = Slate(vec![ + holder(honest, Some([0x01; 32])), // the coin is really its own + holder(liar, Some([0x01; 32])), // the same coin id, under a different peer + ]); + let verifier = CoinDeclaringOnePeer::new(0x01, honest); + 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!["aa", "bb"], + "the coin's declared holder is promoted; the peer that merely copied its id is baseline" + ); + let asked = verifier.asked.lock().expect("asked").clone(); + let liar_hex = PeerId::from_bytes([liar; 32]).to_hex(); + assert!( + asked.iter().any(|(peer, _)| *peer == liar_hex), + "the CLAIMING peer id must reach the chain side; asked {asked:?}" + ); + } + + /// **Proves (dig-node#466, HIGH finding 1):** a disproven pointer attached to an honest holder + /// leaves that holder EXACTLY where a slate with no pointers at all would have left it. + /// + /// **Catches:** the demotion primitive. A provider record is hearsay, so a stranger can answer a + /// lookup with an honest holder's peer id and addresses plus a bogus coin id; under a three-tier + /// ranking that sinks the honest holder to last. The control is the SAME slate with the pointers + /// removed — not a reversal — because asserting "it moved down less" would be satisfied by a fix + /// that still sinks it. + #[tokio::test] + async fn a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would() { + let with_bogus_pointers = Slate(vec![ + holder(0xAA, Some([0x09; 32])), // a stranger's lie ABOUT this holder + holder(0xBB, None), + holder(0xCC, Some([0x09; 32])), + ]); + 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())) + .find_providers(&capsule()) + .await + .expect("located"); + let baseline = BondRankingLocator::new(Arc::new(without_pointers), installed(verifier())) + .find_providers(&capsule()) + .await + .expect("located"); + + assert_eq!( + peer_ids(&smeared), + peer_ids(&baseline), + "a disproven pointer must not rank a holder below where NO pointer would have put it" + ); + assert_eq!(peer_ids(&smeared), vec!["aa", "bb", "cc"]); + } + + /// **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. + /// + /// **Catches:** the amplification. A stranger answering a single lookup chooses the slate, so an + /// unbounded loop turns one cheap DHT lookup into as many blocking chain reads as the answer + /// carries records. Asserted on the chain-read COUNT rather than on elapsed time: a timeout test + /// passes on a fast machine with the bound removed. + #[tokio::test] + async fn a_locate_reads_at_most_the_budget_off_the_chain() { + let slate = Slate( + (0u8..40) + .map(|i| holder(i, Some([0x07; 32]))) + .collect::>(), + ); + let verifier = ByCoinByte::new(&[(0x07, BondVerdict::Unverified)]); + let locator = BondRankingLocator::new(Arc::new(slate), installed(verifier.clone())); + + let got = locator.find_providers(&capsule()).await.expect("located"); + + assert_eq!(got.len(), 40, "every located holder is still offered"); + assert_eq!( + verifier.calls.load(Ordering::SeqCst), + MAX_VERIFIED_PER_LOCATE, + "a stranger cannot choose how many chain reads one locate costs" + ); + } + + /// **Proves:** holders sharing a tier 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 tier. + #[tokio::test] + async fn holders_sharing_a_tier_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`, and while that no longer demotes anyone it would spend a + /// chain read per pointerless holder on every locate. The call COUNT is what makes the + /// distinction observable; the order alone cannot. + #[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 proven holder is promoted; the two pointerless ones keep 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 demotes nobody, while the SAME slate with a + /// reachable chain still promotes the holder that can prove itself. + /// + /// **Catches:** collapsing "could not look" into "looked and found 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 proving the machinery is 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 promotes nobody -- the slate is returned exactly as located" + ); + + 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", "cc", "bb"], + "control: the SAME slate, reached by a live chain, promotes the provable holder -- and \ + the liar keeps its place rather than sinking below the holder that claimed nothing" + ); + } + + /// **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()); + } +} diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index b80d0c88..ecf74a96 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -858,8 +858,13 @@ pub use crate::shared::identity::{install_crypto_provider, load_or_generate_node /// Sharing these files is what makes the pool's inbound listener present the node's ADVERTISED /// identity, so `peer_id = SHA-256(SPKI DER)` is identical across every listener the node runs (the /// peer-RPC server, the DHT dials, and the gossip pool). Without this the pool would present a cert -/// hashing to a different peer_id and every dial to this node fails closed with `peer_id mismatch` -/// (#1532). dig-gossip only READS these files (`dig_peer_protocol::load_ssl_cert`), so pointing at the +/// hashing to a different peer_id than the one this node advertises (#1532). +/// +/// **A dialler is not guaranteed to notice.** `dig-gossip` DERIVES a peer id from the presented SPKI +/// and does not compare it against the one it dialled — every `expected_peer_id` occurrence in that +/// crate is inside `#[cfg(test)]` (DIG-Network/dig-gossip#85). So a split identity is not caught by a +/// fail-closed handshake in the general case; where pinning does happen it is the dialler's own +/// (`dig-tls::pin_and_bind`, used by the ping path). Do not rely on a mismatch being refused. dig-gossip only READS these files (`dig_peer_protocol::load_ssl_cert`), so pointing at the /// canonical identity files can never clobber them. /// /// [`node_cert_dir`]: crate::seams::key_mgmt::key_manager::KeyManager::node_cert_dir @@ -2459,7 +2464,9 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // that the node registers with the relay, advertises, and the auto-dialer PINS. So we point the // pool's cert/key at the persisted `NodeCert` files themselves (dig-gossip only READS them); // letting the GossipService mint its OWN throwaway cert would hash to a DIFFERENT peer_id and - // every dial to this node fails closed with `peer_id mismatch` (#1532 — the identity split). + // a dial to this node can reach a DIFFERENT identity than the one advertised (#1532 — the + // identity split). Not every dialler refuses that: dig-gossip derives the peer id and does not + // pin it (DIG-Network/dig-gossip#85), so this must be right at the source rather than caught. // `cfg.peer_id` is set to that same identity so the pool's self-dial guard + handshake agree. // The address book (`peers.json`) stays under `peer-net/`; only the identity is shared. let gossip_dir = node.peer_cert_dir(); diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 726c28fe..69b86d25 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). # @@ -123,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 new file mode 100644 index 00000000..552adb98 --- /dev/null +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -0,0 +1,820 @@ +//! 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_mirror_coin::{mirror_coin_puzzle_hash, MirrorCoin, MirrorError}; +use dig_node_core::mirror_bond::{BondVerdict, ContentId, 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 evicts ONE entry rather than +/// clearing: clearing hands a stranger a cheap way to discard every honest verdict this node has +/// earned simply by rotating coin ids, which converts a memoisation into an amplifier. +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. +/// +/// **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. +/// +/// `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. + DeclaresThisPeer, + /// The coin declares some other peer, or none. Credit is withheld — never subtracted, because + /// the record naming this coin may be a stranger's lie ABOUT the coin's real holder. + Silent, + /// This node cannot read the declaration at all, so it knows nothing either way. + NotReadable, +} + +/// Whether `advertised_terms` — the free tail of a mirror coin's memo — declares `claiming_peer_id`. +/// +/// **This is deliberately unreadable today, and that is the promotion gate.** The tail is arbitrary +/// UTF-8 and `MirrorCoin::urls()` already hands it over, so a `dig-peer:<64-hex>` term COULD be +/// parsed here — and must not be. `dig-mirror-coin` 0.8.0 is about to own that format with a typed +/// accessor; parsing it here would create a second parser for a security-critical format, in the +/// consumer, where a divergence between the two would be a silent authorization difference rather +/// than a compile error (CLAUDE.md 2.0, centralize rival implementations). +/// +/// So promotion is switched OFF until that accessor exists: with no sound source for the +/// coin -> peer binding, `Bonded` cannot be established, and the layer withholds credit from +/// everyone rather than granting it on a check it cannot make. Nothing is demoted by this +/// (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 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, +) -> PeerDeclaration { + 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 +/// 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 +/// 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. +/// +/// `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 chain_bond_verdict( + source: &S, + store_launcher_id: Bytes32, + root_hash: Bytes32, + epoch: &BigInt, + 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, 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, 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, None); + } + + // 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, 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, 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, None), + }; + + // Step 4 — the one that binds the coin to THIS claim. + if !mirror.advertises(store_launcher_id, root_hash, epoch) { + 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, None), + Some(_) => {} + None => return (BondVerdict::Unverified, None), + } + + // The chain half is satisfied. WHOSE bond it is is a separate question -- see `verdict_for`. + (BondVerdict::Bonded, Some(mirror)) +} + +/// 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. +/// +/// **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, + root_hash: Bytes32, + epoch: &BigInt, + required_collateral: Option, + claiming_peer_id: &str, + claimed_coin_id: Bytes32, +) -> BondVerdict { + // 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, + epoch, + required_collateral, + claimed_coin_id, + ); + let Some(mirror) = coin else { + // Every non-`Bonded` verdict arrives without a coin, and `Bonded` never arrives without one. + return chain; + }; + match peer_declaration(mirror.urls(), claiming_peer_id) { + PeerDeclaration::DeclaresThisPeer => BondVerdict::Bonded, + PeerDeclaration::Silent | PeerDeclaration::NotReadable => BondVerdict::Unverified, + } +} + +/// The memo of definite verdicts, keyed on the exact question each one answered. +/// +/// 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: VerdictCache, +} + +impl ChainBondVerifier { + /// Verify against the node's own chain transport. + pub fn new(chain: Arc) -> Arc { + Arc::new(ChainBondVerifier { + chain, + cache: VerdictCache::default(), + }) + } + + /// 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, + required: Option, + claiming_peer_id: &str, + coin_id: [u8; 32], + ) -> BondVerdict { + // 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, + claiming_peer_id, + Bytes32::new(coin_id), + ) + }); + + self.cache.remember(key, verdict); + 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, 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 required = match requirement(&EpochRecordStore::in_state_dir(), current) { + CollateralRequirementResult::Known { + required_per_store_dig_base_units, + .. + } => Some(required_per_store_dig_base_units), + _ => None, + }; + required +} + +#[async_trait] +impl MirrorBondVerifier for ChainBondVerifier { + async fn verify( + &self, + content: &ContentId, + claiming_peer_id: &str, + 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; + }; + // 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) = 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( + key, + store, + root, + epoch, + current_requirement(), + claiming_peer_id, + coin_id, + ) + .await + } +} + +/// 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); + +#[cfg(test)] +mod tests { + use super::*; + + /// **Proves (dig-node#466):** no advertised term this node can see promotes a claim today. + /// + /// **Catches:** the rival parser. The tail of a mirror coin's memo is arbitrary UTF-8 and + /// `MirrorCoin::urls()` hands it straight over, so a well-meaning change could make promotion + /// reachable immediately by parsing `dig-peer:` here — a second implementation of a + /// security-critical format that `dig-mirror-coin` 0.8.0 is about to own, where a divergence + /// between the two parsers is a silent authorization difference rather than a compile error. + /// A well-formed term is included in the fixture precisely because that is the input a rival + /// parser would accept; a fixture of only junk terms would pass against one. + /// + /// 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]; + + 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" + ); + } + + /// **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); + for terms in [ + vec![], + vec![format!("dig-peer:{peer}")], + vec![format!("dig-peer:{}", "bb".repeat(32))], + vec!["https://mirror.example/store".to_string()], + ] { + assert_ne!( + peer_declaration(&terms, &peer), + PeerDeclaration::DeclaresThisPeer, + "promotion must stay unreachable until the binding has a typed source; terms {terms:?}" + ); + } + } +} diff --git a/crates/dig-node-service/src/mirror/mod.rs b/crates/dig-node-service/src/mirror/mod.rs index 529b7025..c852dc13 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; #[cfg(test)] mod converge_tests; pub mod funding; diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 499b76b2..bab3e3bc 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2169,6 +2169,18 @@ where crate::mirror::pointers::SnapshotMirrorPointers::new(state.mirror_bonds.clone()), )); 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..8e422ab5 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_bond_verify.rs @@ -0,0 +1,413 @@ +//! 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::{chain_bond_verdict, 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()) +} + +/// 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 { + 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 — +/// 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" + ); +} + +/// **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" + ); +}