diff --git a/Cargo.lock b/Cargo.lock index a6a4adc6..61857d31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.252.2" +version = "0.252.3" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index a4177251..1bf0afdc 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.252.2" +version = "0.252.3" # 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 a3c5291f..379e848f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8974,6 +8974,17 @@ a stolen bond. 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. +**A `bonded` verdict MUST rest on AGREEMENT across independently drawn, concurrently-held untrusted +peers -- never on one source.** The §25.6 checks establish that a coin and its creating spend are +internally consistent; none of them establishes that the coin was ever on chain. A coin currying the +real, public $DIG CAT puzzle around an invented parent satisfies every one of them, so a verdict +taken from a single provider promotes a bond that does not exist, at no collateral cost to whoever +published it. The two reads that decide the verdict -- the coin record, and the spend that created +it -- MUST each be corroborated: below the corroboration floor, or on disagreement, the verdict is +`unverified` and MUST NOT be `bonded`. A node MUST NOT fall back to a single source when +corroboration is unavailable, because falling through to one endpoint exactly when the peers failed +to agree lets that endpoint overrule them. + 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 diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index 821ee1f7..1b891ee2 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -392,12 +392,23 @@ impl ChainBondVerifier { claiming_peer_id: &str, coin_id: [u8; 32], ) -> BondVerdict { + // Corroborated, never the router (dig-node#503). `chain_source` asks `api.coinset.org` + // first and consults this node's peers only when that read fails -- its own `ProviderInfo` + // says `trustless: false` -- so a `Bonded` verdict taken from it rests on ONE source's + // word. Every check below is internal consistency of a coin and its creating spend, and + // all of them pass on a coin curried around an invented parent that was never on mainnet. + // Only chain MEMBERSHIP disproves that, and membership is what a single endpoint cannot + // settle. + // + // No fallback here, deliberately: `corroborated_chain_source` errs rather than handing + // back the router, and this reads that as `Unverified`. Falling back would let one + // endpoint overrule the peers exactly when they failed to agree. + // // 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 + .corroborated_chain_source(tokio::runtime::Handle::current()) else { return BondVerdict::Unverified; }; diff --git a/crates/dig-node-service/tests/mirror_bond_corroboration.rs b/crates/dig-node-service/tests/mirror_bond_corroboration.rs new file mode 100644 index 00000000..521d60b3 --- /dev/null +++ b/crates/dig-node-service/tests/mirror_bond_corroboration.rs @@ -0,0 +1,472 @@ +//! A `Bonded` verdict must rest on AGREEMENT across the node's own peers, never on one source +//! (dig-node#503). +//! +//! # What these tests are about, and why the fixture has to be shaped this way +//! +//! `mirror_bond_verify.rs` proves the four chain checks are internally sound. Every one of them is +//! *internal consistency* of a coin and the spend that created it, and that is exactly the hole: +//! an attacker who curries the real, public $DIG CAT puzzle around an INVENTED parent gets a coin +//! that passes all four, because nothing there asks whether the coin was ever on mainnet. So the +//! coin in the attack fixture below is not malformed in any way. It is a genuine CAT spend, at the +//! mirror puzzle hash, in $DIG, at full collateral, advertising exactly the `(store, root, epoch)` +//! being asked about. The ONLY thing wrong with it is that no other peer has ever seen it. +//! +//! Two consequences for how these fixtures are built: +//! +//! * **One peer varies; the rest stay honest.** A round where every peer lies cannot see a missing +//! corroboration step, because there is no truthful answer left for the round to prefer. +//! * **The `Bonded` control is not optional.** Without a case where agreeing peers DO produce +//! `Bonded`, every negative here is equally explained by a harness that can never produce a +//! verdict at all — a suite that passes by asserting nothing. +//! +//! # Why the CHAIN half, and not `verdict_for` +//! +//! `verdict_for` returns `Unverified` before any chain read while `declaration_source_is_readable()` +//! is false, so `Bonded` is unreachable through it and the control above could not exist. #503's +//! concern lives entirely in the chain half, which is reachable today and composes unchanged when +//! the ownership half lands. + +mod support; + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::{Bytes32, Coin, CoinSpend}; +use dig_node_core::mirror_bond::BondVerdict; +use dig_node_service::mirror::bond_verify::chain_bond_verdict; +use dig_wallet::sage::chain::ChainTransport; +use dig_wallet::sage::corroborated_source::CorroboratedChainSource; +use dig_wallet::sage::db::WalletDb; +use dig_wallet::sage::fallback::{FallbackCoin, FallbackCoinSpend}; +use dig_wallet::sage::peer_reads::{CoinPeer, PeerCorroboratedReads, PeerSample}; +use dig_wallet::sage::quorum::PeakClaim; + +use support::{ + creating_spend, creating_spend_of_amount, epoch, mirror_memos, root_1, store_a, wallet, + COLLATERAL, +}; + +// --------------------------------------------------------------------------- +// The doubles +// --------------------------------------------------------------------------- + +/// ONE peer's whole view of the chain: the coins it will admit exist, and the spends it will +/// produce. +/// +/// A map rather than a single scripted answer, because the property under test needs one peer to +/// answer a coin question AND a spend question consistently within its own story. A double that can +/// only voice one of the two cannot express a peer presenting a fabricated coin together with the +/// fabricated spend that created it — which is precisely the lie a single-source verifier believes. +#[derive(Clone, Default)] +struct ChainView { + records: HashMap, + spends: HashMap, +} + +impl ChainView { + /// A view holding each `(creating spend, created coin)` pair: the coin as an unspent record, + /// and the spend keyed by the coin it consumed — which is the parent read the bond path makes. + fn holding(pairs: &[(CoinSpend, Coin)]) -> Self { + let mut view = Self::default(); + for (spend, coin) in pairs { + view.records.insert(coin.coin_id(), fallback_coin(coin)); + view.spends + .insert(spend.coin.coin_id(), fallback_spend(spend)); + } + view + } +} + +/// A coin as the record a peer reports for it. Unspent and confirmed, because a spent bond is +/// refused a step earlier and would mask everything below it. +fn fallback_coin(coin: &Coin) -> FallbackCoin { + FallbackCoin { + coin_id: hex::encode(coin.coin_id()), + parent_coin_info: hex::encode(coin.parent_coin_info), + puzzle_hash: hex::encode(coin.puzzle_hash), + amount: coin.amount, + created_height: Some(6_000_000), + spent_height: None, + created_timestamp: Some(1_700_000_000), + spent_timestamp: None, + } +} + +/// A real `CoinSpend` as the spend a peer reports. The reveal and solution are the fixture's own +/// executed CLVM, so the puzzle hash IS the reveal's tree hash and the corroborated read's own +/// binding checks pass on the honest fixtures rather than being dodged. +fn fallback_spend(spend: &CoinSpend) -> FallbackCoinSpend { + FallbackCoinSpend { + coin_id: hex::encode(spend.coin.coin_id()), + parent_coin_info: hex::encode(spend.coin.parent_coin_info), + puzzle_hash: hex::encode(spend.coin.puzzle_hash), + amount: spend.coin.amount, + puzzle_reveal: hex::encode(&spend.puzzle_reveal), + solution: hex::encode(&spend.solution), + } +} + +/// A peer that answers from one [`ChainView`], or refuses to answer at all. +struct ScriptedPeer { + id: String, + view: Option, +} + +#[async_trait] +impl CoinPeer for ScriptedPeer { + fn id(&self) -> String { + self.id.clone() + } + + async fn coin_record( + &self, + coin_id: Bytes32, + ) -> dig_wallet::sage::Result> { + match &self.view { + Some(view) => Ok(view.records.get(&coin_id).cloned()), + None => Err(dig_wallet::sage::Error::internal("peer did not answer")), + } + } + + async fn coin_spend( + &self, + coin_id: Bytes32, + ) -> dig_wallet::sage::Result> { + match &self.view { + Some(view) => Ok(view.spends.get(&coin_id).cloned()), + None => Err(dig_wallet::sage::Error::internal("peer did not answer")), + } + } + + async fn peak_claim(&self) -> Option { + None + } +} + +/// A draw of scripted peers with DISTINCT ids — one view each, never one view counted twice. +struct ScriptedSample { + views: Vec>, +} + +#[async_trait] +impl PeerSample for ScriptedSample { + async fn draw(&self) -> Vec> { + self.views + .iter() + .enumerate() + .map(|(i, view)| { + Arc::new(ScriptedPeer { + id: format!("10.0.0.{i}:8444"), + view: view.clone(), + }) as Arc + }) + .collect() + } +} + +/// A corroborated source over exactly these peer views, on a fresh in-memory wallet DB. +async fn source_over(views: Vec>) -> CorroboratedChainSource { + let db = WalletDb::open_in_memory() + .await + .expect("in-memory wallet db"); + let reads = Arc::new(PeerCorroboratedReads::new( + Arc::new(ScriptedSample { views }), + db, + )); + CorroboratedChainSource::new(reads, tokio::runtime::Handle::current()) +} + +// --------------------------------------------------------------------------- +// The fixtures +// --------------------------------------------------------------------------- + +/// The honest bond every case is measured against: a real, fully collateralised mirror coin +/// advertising `store_a()` at `root_1()` in the current epoch. +fn honest_bond() -> (CoinSpend, Coin) { + let owner = wallet(1); + creating_spend( + &owner, + &mirror_memos(&owner, store_a(), root_1(), &["https://honest.example"]), + ) +} + +/// A coin that is wrong in NO way except that it was never on chain. +/// +/// A different wallet and a different amount so its id genuinely differs from the honest bond's +/// (the fixture derives a parent from owner+asset+amount, so reusing either would produce the same +/// coin). It advertises the SAME `(store, root, epoch)` and carries the SAME full collateral, so +/// every check in `chain_bond_verdict` passes on it when a single source vouches for it. Only +/// corroboration can tell it apart from the honest one. +fn fabricated_bond() -> (CoinSpend, Coin) { + let attacker = wallet(9); + creating_spend_of_amount( + &attacker, + &mirror_memos( + &attacker, + store_a(), + root_1(), + &["https://attacker.example"], + ), + COLLATERAL, + ) +} + +/// The chain half's verdict about `coin`, asked at full collateral. +fn verdict(source: &CorroboratedChainSource, coin: Bytes32) -> BondVerdict { + chain_bond_verdict( + source, + store_a(), + root_1(), + &epoch(), + Some(COLLATERAL), + coin, + ) +} + +// --------------------------------------------------------------------------- +// The cases +// --------------------------------------------------------------------------- + +/// **Proves:** THE BUG. A coin that only ONE peer has ever heard of is not `Bonded`, however +/// perfect the coin is. +/// +/// **Catches:** a verdict sourced from a single provider — which is what +/// `ChainTransport::chain_source` hands out, since its router asks `api.coinset.org` first and its +/// own `ProviderInfo` records `trustless: false`. Against that source this exact fixture returns +/// `Bonded` and ranks the attacker's peer at zero collateral cost. +/// +/// **Why the fixture is shaped this way:** three honest peers are kept, and only one varies. A +/// four-liar round could not see a missing corroboration step, because it would leave no truthful +/// answer for the round to prefer. +#[tokio::test(flavor = "multi_thread")] +async fn a_coin_only_one_peer_has_ever_seen_is_not_bonded() { + let honest = honest_bond(); + let forged = fabricated_bond(); + + let attacker_view = ChainView::holding(&[honest.clone(), forged.clone()]); + let honest_view = ChainView::holding(std::slice::from_ref(&honest)); + + let source = source_over(vec![ + Some(attacker_view), + Some(honest_view.clone()), + Some(honest_view.clone()), + Some(honest_view), + ]) + .await; + + assert_eq!( + verdict(&source, forged.1.coin_id()), + BondVerdict::Unbonded, + "one peer vouching alone must not produce Bonded; three honest peers agreeing the coin \ + does not exist is a CORROBORATED absence, which disproves the claim rather than leaving \ + it unexamined" + ); +} + +/// **Proves:** below the corroboration floor, no verdict is `Bonded` — not even about a genuine +/// coin. +/// +/// **Catches:** a floor of one. `quorum::CORROBORATION_FLOOR` is 2, and a single answering peer is +/// `Insufficient` rather than a source. The coin here is the HONEST one on purpose: the refusal has +/// to come from the count, not from anything wrong with the coin. +#[tokio::test(flavor = "multi_thread")] +async fn one_answering_peer_cannot_bond_even_a_genuine_coin() { + let honest = honest_bond(); + let view = ChainView::holding(std::slice::from_ref(&honest)); + + let source = source_over(vec![Some(view)]).await; + + assert_eq!( + verdict(&source, honest.1.coin_id()), + BondVerdict::Unverified, + "a lone source is UNKNOWN, never a bond -- and never an absence either" + ); +} + +/// **Proves:** peers that split evenly settle nothing, and the verdict is `Unverified` rather than +/// either side's story. +/// +/// **Catches:** believing a plurality. `required_agreement(4)` is 3, so a 2-vs-2 round has no +/// winner; a verifier that took the first answer, or the largest bucket regardless of the ratio, +/// would promote whichever half the attacker controls. +#[tokio::test(flavor = "multi_thread")] +async fn evenly_split_peers_do_not_bond() { + let honest = honest_bond(); + let forged = fabricated_bond(); + + let vouching = ChainView::holding(&[honest.clone(), forged.clone()]); + let denying = ChainView::holding(std::slice::from_ref(&honest)); + + let source = source_over(vec![ + Some(vouching.clone()), + Some(vouching), + Some(denying.clone()), + Some(denying), + ]) + .await; + + assert_eq!( + verdict(&source, forged.1.coin_id()), + BondVerdict::Unverified, + "half the round vouching is disagreement, and disagreement is UNKNOWN" + ); +} + +/// **Proves:** the control, and without it the three cases above prove nothing. +/// +/// Agreeing peers holding a genuine, fully collateralised coin that advertises exactly the +/// requested `(store, root, epoch)` DO produce `Bonded`. If they did not, every `Unbonded` and +/// `Unverified` above would be equally explained by a harness that cannot reach a positive verdict +/// at all. +#[tokio::test(flavor = "multi_thread")] +async fn agreeing_peers_do_bond_a_genuine_coin() { + let honest = honest_bond(); + let view = ChainView::holding(std::slice::from_ref(&honest)); + + let source = source_over(vec![ + Some(view.clone()), + Some(view.clone()), + Some(view.clone()), + Some(view), + ]) + .await; + + assert_eq!( + verdict(&source, honest.1.coin_id()), + BondVerdict::Bonded, + "corroboration must not make a real bond unverifiable -- the fix is a floor, not a wall" + ); +} + +/// **Proves:** a transport with no corroborated-read surface REFUSES, and does not quietly hand +/// back the single-source router instead. +/// +/// **Catches:** the fallback that would undo the whole fix. Falling through to one endpoint exactly +/// when the peers are unavailable is what lets that endpoint overrule them, and it is invisible +/// from the outside — the caller gets an `Ok` source and a `Bonded` verdict either way. The only +/// observable difference is that this call must be an `Err`. +#[tokio::test(flavor = "multi_thread")] +async fn a_transport_without_peer_reads_refuses_rather_than_using_the_router() { + let transport = ChainTransport::new(); + + let refused = transport.corroborated_chain_source(tokio::runtime::Handle::current()); + + assert!( + refused.is_err(), + "with no peer reads there is nothing to corroborate against, and the router is NOT a \ + substitute" + ); +} + +/// **Proves:** a source drawing ZERO peers errs rather than reporting an absence. +/// +/// **Catches:** the collapse of UNKNOWN into "no such coin" at the read layer. That direction is +/// the expensive one on this path: `chain_bond_verdict` reads `Ok(None)` as *the publisher named a +/// coin that does not exist* and answers `Unbonded`, which would demote an honest holder every time +/// this node's peer tier was momentarily empty. +#[tokio::test(flavor = "multi_thread")] +async fn a_source_with_no_peers_errs_rather_than_reporting_an_absence() { + use dig_chainsource_interface::ChainSource; + + let honest = honest_bond(); + let source = source_over(vec![]).await; + + assert!( + source.coin_record(honest.1.coin_id()).is_err(), + "no peers means UNKNOWN, never Ok(None)" + ); + assert_eq!( + verdict(&source, honest.1.coin_id()), + BondVerdict::Unverified, + "and the verdict that reads it must fail closed, not answer Unbonded" + ); +} + +/// **Proves the DEFECT, so the fix above is demonstrably load-bearing.** +/// +/// The identical fabricated coin, read through a source that answers from ONE story -- the shape +/// `ChainTransport::chain_source` hands out, whose router asks `api.coinset.org` first and whose own +/// `ProviderInfo` records `trustless: false` -- verifies as `Bonded`. Nothing about the coin is +/// wrong; it was simply never on mainnet, and a single source has no way to say so. +/// +/// This is the pre-change behaviour of the whole bond path, kept as a permanent witness: if the +/// corroboration seam is ever removed and a single-source read reinstated, +/// `a_coin_only_one_peer_has_ever_seen_is_not_bonded` flips to `Bonded` and this test explains why. +#[test] +fn a_single_source_bonds_the_fabricated_coin() { + use dig_chainsource_interface::{ChainSource, CoinRecord, SingletonLineage}; + + /// A chain that is whatever one provider says it is. + struct SingleSource { + records: HashMap, + spends: HashMap, + } + + impl ChainSource for SingleSource { + type Error = std::convert::Infallible; + + 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(Vec::new()) + } + + fn coin_records_by_parent(&self, _p: 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(6_000_000)) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Ok(None) + } + } + + let (spend, coin) = fabricated_bond(); + let source = SingleSource { + records: HashMap::from([( + coin.coin_id(), + CoinRecord { + coin, + confirmed_height: Some(6_000_000), + spent_height: None, + timestamp: Some(1_700_000_000), + coinbase: false, + }, + )]), + spends: HashMap::from([(spend.coin.coin_id(), spend)]), + }; + + assert_eq!( + chain_bond_verdict( + &source, + store_a(), + root_1(), + &epoch(), + Some(COLLATERAL), + coin.coin_id(), + ), + BondVerdict::Bonded, + "the fabricated coin passes every internal-consistency check; only corroboration catches it" + ); +} diff --git a/crates/dig-wallet/src/sage/chain.rs b/crates/dig-wallet/src/sage/chain.rs index 96ef1422..0a6bfe1e 100644 --- a/crates/dig-wallet/src/sage/chain.rs +++ b/crates/dig-wallet/src/sage/chain.rs @@ -457,6 +457,43 @@ impl ChainTransport { )) } + /// This transport's chain reads served by the node's OWN peers and believed only on agreement + /// (dig-node#503). + /// + /// The counterpart to [`Self::chain_source`], and the difference is the whole point. That one + /// hands out `chia-query`'s router, which asks `api.coinset.org` first and consults this node's + /// peers only when that read fails; its own `ProviderInfo` says `trustless: false` for exactly + /// that reason. This one asks the peers, and returns nothing they did not agree on. + /// + /// Use it for any verdict a forged answer would PAY for — ranking a holder, crediting a bond — + /// where a single endpoint's word is the attack rather than a latency choice. The router + /// remains the right source for the reads whose worst case is a stale number. + /// + /// `handle` MUST belong to a **multi-thread** tokio runtime, as for [`Self::chain_source`]. + /// + /// # Errors + /// + /// This transport holds no corroborated-read surface — a node built without one. **It does NOT + /// fall back to [`Self::chain_source`], deliberately.** Falling through to one endpoint exactly + /// when the peers are unavailable or failed to agree would let that endpoint overrule them, + /// which is the same rule this file already applies to the lineage-walk reads. A caller that + /// gets this error must fail closed. + pub fn corroborated_chain_source( + &self, + handle: tokio::runtime::Handle, + ) -> Result { + let Some(reads) = &self.peer_reads else { + return Err(Error::internal( + "this node holds no corroborated peer reads, so no chain answer can be \ + corroborated; the single-source router is deliberately NOT used instead", + )); + }; + Ok(super::corroborated_source::CorroboratedChainSource::new( + reads.clone(), + handle, + )) + } + /// A transport that already HAS its client, so nothing in the test dials. /// /// Seeding the client is what makes pointer identity assertable: a consumer that quietly built diff --git a/crates/dig-wallet/src/sage/corroborated_source.rs b/crates/dig-wallet/src/sage/corroborated_source.rs new file mode 100644 index 00000000..8e0dd02a --- /dev/null +++ b/crates/dig-wallet/src/sage/corroborated_source.rs @@ -0,0 +1,270 @@ +//! [`CorroboratedChainSource`] — the canonical [`ChainSource`] served by the node's OWN Chia peers, +//! believed only on agreement (dig-node#503). +//! +//! # The single-source hole this closes +//! +//! [`super::chain::ChainTransport::chain_source`] hands out `chia-query`'s router, and its own +//! `ProviderInfo` says what that means: `trustless: false`, because with `coinset_fallback_enabled` +//! — the default every production fabric is built from — it asks `api.coinset.org` FIRST and +//! consults this node's dialled peers only when that read fails. The peers do not corroborate the +//! answer. +//! +//! For a balance read that is a latency choice. For a verdict that RANKS a peer — dig-node's mirror +//! bond verdict is the case this was built for — it is a forgery surface: the checks that promote a +//! holder to `Bonded` are all *internal consistency* of a coin and its creating spend, and every one +//! of them passes on a coin that was never on mainnet. An attacker can curry the real, public $DIG +//! CAT puzzle around an invented parent, compute the child id, and publish that id. Only chain +//! MEMBERSHIP disproves it, and membership is exactly what one endpoint's word cannot settle. +//! +//! # Why corroboration rather than a proof +//! +//! [`ChainSource`] exposes no block header, no merkle path, no inclusion proof; its one +//! proof-shaped primitive is `resolve_singleton_lineage`, and a mirror coin is a CAT with no +//! launcher. So verification-by-proof is not available here and agreement across independently +//! drawn peers is the answer — which is cheap in this case, because the whole verdict reduces to +//! two primitive reads: the coin record, and the spend that created it. +//! +//! [`super::peer_reads::PeerCorroboratedReads`] already corroborates exactly those two reads over +//! the peers this node dialled itself, inheriting `super::quorum::CORROBORATION_FLOOR` (never one +//! source) and `super::quorum::required_agreement`. This module is a thin [`ChainSource`] face over +//! it and invents no second agreement mechanism. +//! +//! # The failure direction, stated +//! +//! Every method here is allowed to be wrong in ONE direction only: it may refuse an answer the +//! chain would have given, and it may NEVER manufacture an absence. `Err` is UNKNOWN; `Ok(None)` is +//! reserved for the peers having AGREED the thing does not exist. That is why the reads this +//! surface cannot serve return `Err` rather than an empty `Vec` or `Ok(None)`: a caller reads an +//! empty answer as *the chain has no such thing* and acts on it, and on the bond path acting on a +//! fabricated absence is how a real holder gets demoted. +//! +//! There is deliberately NO fallback to the router. Falling through to one endpoint exactly when +//! the peers failed to agree would let that endpoint overrule them, which is the dependency this +//! whole module exists to remove. + +use std::sync::Arc; + +use chia_protocol::{Bytes32, Coin, CoinSpend, Program}; +use chia_query::provider_registry::interface::{ + ChainSource, ChainSourceError, CoinRecord, SingletonLineage, +}; + +use super::fallback::{FallbackCoin, FallbackCoinSpend}; +use super::peer_reads::PeerCorroboratedReads; + +/// A [`ChainSource`] whose every answer came from several of the node's own peers agreeing. +/// +/// Synchronous, because [`ChainSource`] is: each read bridges to the async corroborated round +/// through `handle`. `handle` MUST belong to a **multi-thread** tokio runtime — the bridge fails +/// closed with a clear error on a current-thread one rather than deadlocking. +pub struct CorroboratedChainSource { + reads: Arc, + handle: tokio::runtime::Handle, +} + +impl CorroboratedChainSource { + /// A source over `reads`, bridging each blocking call onto `handle`. + #[must_use] + pub fn new(reads: Arc, handle: tokio::runtime::Handle) -> Self { + Self { reads, handle } + } + + /// Drives one corroborated read to completion from a synchronous caller. + /// + /// The same three-way shape `chia-query`'s own facade uses (its `run_blocking` is + /// crate-private, so it cannot be called from here): inside a multi-thread runtime the read + /// leaves the async worker via `block_in_place`; outside any runtime it blocks directly; on a + /// CURRENT-THREAD runtime it refuses with a clear error instead of raising tokio's opaque + /// panic. + fn block_on(&self, fut: F) -> Result { + match tokio::runtime::Handle::try_current() { + Ok(current) + if current.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread => + { + Err(ChainSourceError::Transport( + "corroborated chain source cannot block on a current-thread runtime" + .to_string(), + )) + } + Ok(_) => Ok(tokio::task::block_in_place(|| self.handle.block_on(fut))), + Err(_) => Ok(self.handle.block_on(fut)), + } + } +} + +/// The one spelling of a coin id [`PeerCorroboratedReads`] keys its cache on: lowercase hex, no +/// `0x`. `hex::encode` of the 32 bytes produces exactly that, so the key can never drift from the +/// bytes the caller asked about. +fn key_for(coin_id: Bytes32) -> String { + hex::encode(coin_id) +} + +/// 64 lowercase hex characters as the 32 bytes a coin field is. +fn bytes32(hex_str: &str, what: &str) -> Result { + let bytes = hex::decode(hex_str) + .map_err(|_| ChainSourceError::Malformed(format!("{what} is not hex")))?; + let array: [u8; 32] = bytes + .try_into() + .map_err(|_| ChainSourceError::Malformed(format!("{what} is not 32 bytes")))?; + Ok(Bytes32::from(array)) +} + +/// The coin an answer describes, refused unless it hashes to the id that was ASKED for. +/// +/// A coin id IS `SHA256(parent | puzzle_hash | amount)`, so this is arithmetic no vote can outrank. +/// [`PeerCorroboratedReads`] already binds its own answers this way; repeating it here is what +/// makes THIS adapter's contract local and checkable rather than inherited — a caller holding a +/// [`CorroboratedChainSource`] can rely on it without reading the layer below. It also closes a gap +/// the bond path leaves open, since that path never asserts the record it got back IS the claimed +/// coin. +fn coin_bound_to( + parent: &str, + puzzle_hash: &str, + amount: u64, + requested: Bytes32, +) -> Result { + let coin = Coin { + parent_coin_info: bytes32(parent, "parent coin id")?, + puzzle_hash: bytes32(puzzle_hash, "puzzle hash")?, + amount, + }; + if coin.coin_id() != requested { + return Err(ChainSourceError::Malformed(format!( + "corroborated answer is about coin {} but {} was asked for", + hex::encode(coin.coin_id()), + hex::encode(requested) + ))); + } + Ok(coin) +} + +/// A corroborated coin as the canonical record shape. +fn record_from(coin: &FallbackCoin, requested: Bytes32) -> Result { + Ok(CoinRecord { + coin: coin_bound_to( + &coin.parent_coin_info, + &coin.puzzle_hash, + coin.amount, + requested, + )?, + confirmed_height: coin.created_height, + spent_height: coin.spent_height, + timestamp: coin.created_timestamp, + // A peer's coin state carries no coinbase flag. `false` is the shape + // `CoinRecord::from_coin_state` already uses for the same absence, and nothing on the bond + // path reads it. + coinbase: false, + }) +} + +/// A corroborated spend as the canonical spend shape. +fn spend_from( + spend: &FallbackCoinSpend, + requested: Bytes32, +) -> Result { + let coin = coin_bound_to( + &spend.parent_coin_info, + &spend.puzzle_hash, + spend.amount, + requested, + )?; + let reveal = hex::decode(&spend.puzzle_reveal) + .map_err(|_| ChainSourceError::Malformed("puzzle reveal is not hex".to_string()))?; + let solution = hex::decode(&spend.solution) + .map_err(|_| ChainSourceError::Malformed("solution is not hex".to_string()))?; + Ok(CoinSpend::new( + coin, + Program::from(reveal), + Program::from(solution), + )) +} + +impl ChainSource for CorroboratedChainSource { + type Error = ChainSourceError; + + /// `Ok(Some(..))` — the peers agreed this coin exists and agreed on its fields. + /// `Ok(None)` — they agreed it does NOT exist, which is a corroborated absence and safe to act + /// on. `Err` — too few answered, or they disagreed: UNKNOWN, never absence. + fn coin_record(&self, coin_id: Bytes32) -> Result, Self::Error> { + let key = key_for(coin_id); + let answer = self + .block_on(async { self.reads.coin_record_by_id(&key).await })? + .map_err(|e| ChainSourceError::Transport(e.to_string()))?; + answer + .as_ref() + .map(|coin| record_from(coin, coin_id)) + .transpose() + } + + /// The spend that spent `coin_id`, with the same three-way meaning as + /// [`coin_record`](Self::coin_record). + fn coin_spend(&self, coin_id: Bytes32) -> Result, Self::Error> { + let key = key_for(coin_id); + let answer = self + .block_on(async { self.reads.coin_spend(&key).await })? + .map_err(|e| ChainSourceError::Transport(e.to_string()))?; + answer + .as_ref() + .map(|spend| spend_from(spend, coin_id)) + .transpose() + } + + /// Not served. **`Err`, never an empty `Vec`** — the corroborated surface answers by coin id + /// only, and an empty list here would read as *no coin pays this puzzle hash*, which is a + /// fabricated absence rather than an unanswered question. + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported( + "corroborated peer reads answer by coin id, not by puzzle hash", + )) + } + + /// Not served, for the same reason as + /// [`coin_records_by_puzzle_hash`](Self::coin_records_by_puzzle_hash). + fn coin_records_by_parent( + &self, + _parent_coin_id: Bytes32, + ) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported( + "corroborated peer reads answer by coin id, not by parent", + )) + } + + /// Not served. `Ok(None)` would claim the launcher never existed or the singleton was melted; + /// this source simply cannot walk one. + fn resolve_singleton_lineage( + &self, + _launcher_id: Bytes32, + ) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported( + "corroborated peer reads do not walk singleton lineages", + )) + } + + /// The peak the node's peers SETTLED on. + /// + /// The two `None`s here mean different things, and collapsing them would be a lie in the + /// permissive direction. `PeerCorroboratedReads::peak_height` returns `None` for *the peers did + /// not agree, or too few of them spoke* — an unknown. `ChainSource::peak_height`'s `Ok(None)` + /// means *this source does not expose a peak at all* — a settled fact a caller may act on. So + /// the unknown maps to `Err`. + fn peak_height(&self) -> Result, Self::Error> { + match self.block_on(async { self.reads.peak_height().await })? { + Some(height) => Ok(Some(height)), + None => Err(ChainSourceError::Transport( + "the node's peers did not settle on a peak height".to_string(), + )), + } + } + + /// Not served. A peer round here answers coin questions; `Ok(None)` would assert there is no + /// such block. + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + Err(ChainSourceError::Unsupported( + "corroborated peer reads do not resolve block timestamps", + )) + } +} diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index 095a25f2..8abc9ec1 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -62,6 +62,7 @@ pub mod actions; pub mod arrivals; pub mod cat_discovery; pub mod chain; +pub mod corroborated_source; pub mod coverage; pub mod custody; pub mod db;