diff --git a/Cargo.lock b/Cargo.lock index 5cf1e0eb..b44d546c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.254.40" +version = "0.254.41" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index e6a3b941..c7276050 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.254.40" +version = "0.254.41" # 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 d8e784ed..99966d0f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -9293,10 +9293,16 @@ Two limits are required, because they answer two different attacks: process, so any narrower scope would be several budgets against one resource; - a **per-claimant** limit on DISTINCT coin ids the claimant has caused reads for without ever proving a bond, which bounds the fabricated-coin-id case. A coin id the claimant has already been - read about MUST NOT count again: that is a cache entry expiring and being refreshed, not a new - question. A claimant that proves a bond MUST have this ledger forgiven; the process-wide budget - MUST NOT be refunded, since a proven bond says the claimant is honest and says nothing about this - node's chain access. + read about MUST NOT count AGAIN TOWARD THE DISTINCT-ID CAP, but a REPEAT of that same coin id + MUST itself be rate-limited, independently of the process-wide budget: a coin whose owner never + declares this claimant produces `unverified` forever, which by design is never cached, so without + a repeat-specific bound one such pair could re-enter admission at the process-wide refill rate + indefinitely and, sustained, hold the ENTIRE process-wide budget at zero for every other + claimant — a single fabricated coin silencing the whole node's bond promotion. A repeated pair + MUST be re-admitted no more often than the cadence an honest re-ask (a cache entry expiring) would + cost for free — never on every attempt. A claimant that proves a bond MUST have this ledger + forgiven; the process-wide budget MUST NOT be refunded, since a proven bond says the claimant is + honest and says nothing about this node's chain access. Exhausting either limit yields `unverified` having read nothing. **This is a degradation and never a refusal of service**: `unverified` and `unbonded` share a rank, the sort is stable, and the located @@ -9304,6 +9310,14 @@ slate is returned unchanged — precisely the behaviour of a node with no verifi path is never blocked and no holder is ever ranked below where it started, so an adversary who exhausts the budget denies promotion, not content. +**A node MUST also skip the read when its own currently-held peer sample cannot possibly meet the +corroboration floor a bond verdict requires**, using only a cheap, non-dialling peek at whatever the +last draw already held — never forcing a fresh dial round purely to answer this question, since a +network that cannot supply enough peers would then pay a full dial attempt on every claim it can +never resolve, which is worse than the wasted read it replaces. An unknown sample (nothing drawn +yet) MUST NOT be read as a refusal — it fails open into the real read, which is what discovers peers +in the first place. + Both limits are keyed on the claiming peer id LOWERCASED before use. A peer id is fixed-length hex, so its two spellings denote one identity, and every check that GRANTS promotion already treats them as one — the coin's declaration compares decoded bytes and the TLS pin compares certificate-hash diff --git a/crates/dig-node-service/src/mirror/bond_verify.rs b/crates/dig-node-service/src/mirror/bond_verify.rs index 76f74650..dec810c5 100644 --- a/crates/dig-node-service/src/mirror/bond_verify.rs +++ b/crates/dig-node-service/src/mirror/bond_verify.rs @@ -61,10 +61,11 @@ const MAX_CACHED_VERDICTS: usize = 1024; /// The most locate-triggered verifications this PROCESS will pay for in one burst. /// -/// Each one costs up to two blocking HTTPS reads through the node's ONE shared `ChiaQuery` client — -/// the same client the wallet, the collateral census and the mirror spends read through. So this +/// Each one reads through `corroborated_chain_source` (dig-node#503, dig-node#527 item 3) — +/// `api.coinset.org` first, falling back to a `BOND_CORROBORATION_FLOOR`-peer corroborated round +/// only when that read fails, never a bare uncorroborated read from a single shared client. So this /// ceiling is not about fairness between requestors; it is about this node keeping its own chain -/// access when a stranger directs traffic at it. +/// access — and its peers' attention on a fallback round — when a stranger directs traffic at it. /// /// The inbound admission gate (`allow_miss_lookup`, burst 16 at 4/sec) is PER REQUESTOR over up to /// 4,096 self-minted identities and has no aggregate cap, so it cannot bound this. Sixteen is @@ -106,6 +107,23 @@ const MAX_UNPROVEN_COINS_PER_CLAIMANT: usize = 4; /// that genuinely rotates coins is forgiven on the same clock a cached verdict expires on. const CLAIMANT_LEDGER_WINDOW: Duration = VERDICT_TTL; +/// How often the SAME unproven `(claimant, coin_id)` pair may re-enter admission (dig-node#527, +/// item 1). +/// +/// A coin that does not declare its claimant produces [`BondVerdict::Unverified`], and +/// [`VerdictCache::remember`] deliberately never caches `Unverified` — so without this bound, a +/// repeat of that exact pair paid only the process-wide token bucket, refilling at +/// [`VERIFICATION_REFILL_PER_SEC`]. That let one `(peer_id, coin_id)` pair a stranger controls hold +/// [`ReadAdmission::shared`]'s ENTIRE budget at zero forever, sustained at roughly one request per +/// second — silencing every other claimant's verification, not just this one's. +/// +/// Matched to [`VERDICT_TTL`] rather than given its own tuning knob: an honest re-ask of a coin +/// whose cached verdict genuinely expired is indistinguishable, from this ledger's point of view, +/// from an attacker repeating a coin that will never declare them. Both are bounded to the exact +/// cadence a cache hit would have given the honest case for free, which costs the honest claimant +/// nothing it wasn't already going to wait for. +const UNPROVEN_COIN_RETRY_COOLDOWN: Duration = VERDICT_TTL; + /// The most claimants tracked in that ledger at once. /// /// A claiming peer id is attacker-chosen and unbounded in supply, so the ledger MUST be bounded. @@ -403,6 +421,12 @@ struct ReadAdmission { state: Mutex, } +/// Per claimant: when its ledger window opened, and the distinct coin ids it has spent reads on +/// without proving a bond, each mapped to WHEN it was last admitted — so a repeat of the same coin +/// id can be throttled to [`UNPROVEN_COIN_RETRY_COOLDOWN`] independently of the process-wide +/// bucket. +type ClaimantLedger = HashMap<[u8; 32], (Instant, HashMap<[u8; 32], Instant>)>; + /// [`ReadAdmission`]'s interior, held under one lock so the two limits are decided atomically — /// a token spent on a claim the ledger was about to refuse would be a leak. struct AdmissionState { @@ -412,11 +436,10 @@ struct AdmissionState { refilled_at: Instant, burst: f64, refill_per_sec: f64, - /// Per claimant: when its window opened, and the distinct coin ids it has spent reads on - /// without proving a bond. Keyed on the SHA-256 of the lowercased peer id, for the reason - /// [`VerdictKey`] hashes it — the key must be fixed-size against an attacker-chosen string, and - /// two hex spellings must be one identity. - unproven: HashMap<[u8; 32], (Instant, std::collections::HashSet<[u8; 32]>)>, + /// Keyed on the SHA-256 of the lowercased peer id, for the reason [`VerdictKey`] hashes it — + /// the key must be fixed-size against an attacker-chosen string, and two hex spellings must be + /// one identity. + unproven: ClaimantLedger, } impl ReadAdmission { @@ -470,11 +493,20 @@ impl ReadAdmission { return false; } if let Some((_, coins)) = state.unproven.get(&claimant) { - // A coin id this claimant has already been read about is not a new question: the cache - // entry for it has simply expired, and re-asking is what keeps a verdict fresh. Only - // DISTINCT unproven ids count against the ledger. - if !coins.contains(&claimed_coin_id) && coins.len() >= MAX_UNPROVEN_COINS_PER_CLAIMANT { - return false; + match coins.get(&claimed_coin_id) { + // A repeat of a coin this claimant already spent a read on. Bounded to the SAME + // cadence a cache hit would have given an honest re-ask — never a fresh admission + // every attempt, which is what let one unprovable pair hold the whole process-wide + // bucket at zero (dig-node#527, item 1). + Some(last_admitted) if last_admitted.elapsed() < UNPROVEN_COIN_RETRY_COOLDOWN => { + return false; + } + // Genuinely new to this claimant: only DISTINCT unproven ids count against the + // ledger cap. + None if coins.len() >= MAX_UNPROVEN_COINS_PER_CLAIMANT => { + return false; + } + _ => {} } } @@ -485,9 +517,9 @@ impl ReadAdmission { state .unproven .entry(claimant) - .or_insert_with(|| (now, std::collections::HashSet::new())) + .or_insert_with(|| (now, HashMap::new())) .1 - .insert(claimed_coin_id); + .insert(claimed_coin_id, now); true } @@ -520,11 +552,18 @@ fn claimant_key(claiming_peer_id: &str) -> [u8; 32] { /// ordering rather than re-deriving it: the budget is consulted BEFORE the source is touched, so a /// refused claim reads nothing, and a proven bond forgives its claimant's ledger. /// -/// The parameter list is `verdict_for`'s, in `verdict_for`'s order, with the budget in front. That -/// is deliberate and is why the lint is allowed here rather than satisfied by grouping: four of the -/// arguments are opaque 32-byte values, so the one mistake this wrapper could make is transposing -/// two of them, and a signature that mirrors the wrapped function exactly makes such a transposition -/// visible at the call site below instead of hiding it inside a re-packing struct. +/// **`required_collateral` is a THUNK, not a value, and that is load-bearing (dig-node#527, item +/// 2).** `current_requirement()` — the production caller — is a synchronous file read plus a JSON +/// parse; if it were an already-evaluated `Option` argument, Rust would run it while building +/// this call's argument list, which happens BEFORE `admit()` below ever runs. A claim `admit()` was +/// always going to refuse would still have paid the read. Taking a closure defers that cost to +/// exactly the branch that needs it: only a claim that survives admission ever calls it. +/// +/// The parameter list is otherwise `verdict_for`'s, in `verdict_for`'s order, with the budget in +/// front. That is deliberate and is why the lint is allowed here rather than satisfied by grouping: +/// four of the arguments are opaque 32-byte values, so the one mistake this wrapper could make is +/// transposing two of them, and a signature that mirrors the wrapped function exactly makes such a +/// transposition visible at the call site below instead of hiding it inside a re-packing struct. #[allow(clippy::too_many_arguments)] fn admitted_verdict_for( admission: &ReadAdmission, @@ -532,7 +571,7 @@ fn admitted_verdict_for( store_launcher_id: Bytes32, root_hash: Bytes32, epoch: &BigInt, - required_collateral: Option, + required_collateral: impl FnOnce() -> Option, claiming_peer_id: &str, claimed_coin_id: Bytes32, ) -> BondVerdict { @@ -544,7 +583,7 @@ fn admitted_verdict_for( store_launcher_id, root_hash, epoch, - required_collateral, + required_collateral(), claiming_peer_id, claimed_coin_id, ); @@ -628,6 +667,11 @@ impl ChainBondVerifier { } /// The chain half: one bounded read, memoised. + /// + /// Takes no `required_collateral` parameter, deliberately (dig-node#527, item 2): the epoch + /// requirement is a synchronous file read, and computing it here — before `admit()` — would + /// pay that read for a claim the budget was always going to refuse. `admitted_verdict_for` + /// receives [`current_requirement`] itself as a thunk and calls it only past that gate. #[allow(clippy::too_many_arguments)] async fn verify_against_chain( &self, @@ -635,7 +679,6 @@ impl ChainBondVerifier { store: Bytes32, root: Bytes32, epoch: u64, - required: Option, claiming_peer_id: &str, coin_id: [u8; 32], ) -> BondVerdict { @@ -669,6 +712,21 @@ impl ChainBondVerifier { return BondVerdict::Unverified; }; + // Peer-availability pre-check (dig-node#527, item 4): a sample too small to ever meet + // `source.required_floor()` makes `Bonded` unreachable exactly as surely as + // `declaration_source_is_readable()` does above, so the read below is skipped rather than + // paid for a verdict `tally_with_floor` cannot produce. + // + // `live_peer_hint` is a non-dialling PEEK at whatever the last draw already held, never a + // reason to dial ourselves: forcing a fresh redraw for every claim a thin network cannot + // satisfy would trade one honest `Unverified` for a redial storm against that same thin + // network, which is the worse defect (§2.6 -- report the truth, do not manufacture load). + // `None` (nothing drawn yet) fails OPEN into the real read, which is what discovers peers + // in the first place. + if sample_cannot_meet_floor(source.live_peer_hint(), source.required_floor()) { + return BondVerdict::Unverified; + } + // The concurrency ceiling, and `try_acquire` rather than `acquire`: a queue of tasks // waiting for a permit is an unbounded backlog of attacker-directed work held on this // node's heap, and the honest answer when the budget is saturated is available immediately @@ -690,7 +748,7 @@ impl ChainBondVerifier { store, root, &epoch_big, - required, + current_requirement, claiming_peer_id, Bytes32::new(coin_id), ) @@ -701,6 +759,20 @@ impl ChainBondVerifier { } } +/// Whether a chain read would be spent on a sample too small to ever produce `Bonded` +/// (dig-node#527, item 4). +/// +/// A pure predicate over the two inputs [`ChainBondVerifier::verify_against_chain`] gathers, kept +/// separate from the caller so the decision is unit-testable without a real chain transport or any +/// network dial. +/// +/// `live_hint` is `None` whenever nothing is known yet (no draw has happened) — that fails OPEN +/// into the real read, which is what discovers peers in the first place, never into a refusal a +/// caller could mistake for the network having none. +fn sample_cannot_meet_floor(live_hint: Option, required_floor: usize) -> bool { + live_hint.is_some_and(|live| live < required_floor) +} + /// 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 @@ -774,16 +846,8 @@ impl MirrorBondVerifier for ChainBondVerifier { return hit; } - self.verify_against_chain( - key, - store, - root, - epoch, - current_requirement(), - claiming_peer_id, - coin_id, - ) - .await + self.verify_against_chain(key, store, root, epoch, claiming_peer_id, coin_id) + .await } } @@ -1165,7 +1229,7 @@ mod tests { Bytes32::new(STORE), Bytes32::new(ROOT), &BigInt::from(7u64), - Some(1), + || Some(1), // A fresh claimant per row, each a well-formed 64-hex id. &format!("{claim:064x}"), Bytes32::new([0x33; 32]), @@ -1223,7 +1287,7 @@ mod tests { Bytes32::new(STORE), Bytes32::new(ROOT), &BigInt::from(7u64), - Some(1), + || Some(1), &claimant, Bytes32::new([invented; 32]), ); @@ -1243,7 +1307,7 @@ mod tests { Bytes32::new(STORE), Bytes32::new(ROOT), &BigInt::from(7u64), - Some(1), + || Some(1), &"bb".repeat(32), Bytes32::new([0x01; 32]), ); @@ -1254,6 +1318,73 @@ mod tests { ); } + /// **Proves (dig-node#527, item 4, HIGH):** a sample too small for the bond floor is detected + /// WITHOUT dialling, and a not-yet-known sample never falsely refuses. + #[test] + fn a_sample_below_the_required_floor_is_recognised_without_a_read() { + assert!( + sample_cannot_meet_floor(Some(2), 3), + "two live peers can never satisfy a floor of three -- `tally_with_floor` would return \ + `Insufficient` every time, so the read is skippable" + ); + assert!( + !sample_cannot_meet_floor(Some(3), 3), + "a sample that exactly meets the floor must still be tried" + ); + assert!( + !sample_cannot_meet_floor(Some(4), 3), + "a sample above the floor must still be tried" + ); + assert!( + !sample_cannot_meet_floor(None, 3), + "an unknown sample (nothing drawn yet) fails OPEN into the real read -- that is what \ + discovers peers in the first place, and a hint must never be read as a refusal" + ); + } + + /// **Proves (dig-node#527, item 1, HIGH):** repeating the SAME unproven coin id does not buy a + /// fresh chain read every attempt. + /// + /// **Catches:** the ledger bypass the distinct-coin cap left open. A coin that does not declare + /// its claimant returns [`BondVerdict::Unverified`], which [`VerdictCache::remember`] + /// deliberately never caches — so before this fix, re-asking about the identical + /// `(claimant, coin_id)` pair spent only the process-wide token bucket, never the ledger. At + /// [`VERIFICATION_REFILL_PER_SEC`] that is a sustainable ~1 request/sec that holds + /// [`ReadAdmission::shared`]'s entire budget at zero, forever, off ONE fabricated coin. + /// + /// The budget here is deliberately enormous (`1_000` burst, `1_000.0`/sec refill) so the token + /// bucket can never be what stops the 50th attempt — only the per-pair cooldown this fix adds. + #[test] + fn a_repeated_unproven_coin_id_stops_costing_a_fresh_read_every_attempt() { + let reads = Arc::new(AtomicUsize::new(0)); + let source = CountingChain { + reads: Arc::clone(&reads), + }; + let admission = ReadAdmission::new(1_000, 1_000.0); + let claimant = "cc".repeat(32); + let same_coin = Bytes32::new([0x42; 32]); + + for _ in 0..50 { + admitted_verdict_for( + &admission, + &source, + Bytes32::new(STORE), + Bytes32::new(ROOT), + &BigInt::from(7u64), + || Some(1), + &claimant, + same_coin, + ); + } + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 1, + "a claimant repeating ONE unproven coin id must be bounded to the SAME cadence a cache \ + hit would give an honest re-ask, never a fresh chain read every attempt" + ); + } + /// **Proves (dig-node#501, security round 1, HIGH, second half):** a flood of cheap negative /// verdicts cannot displace a verdict this node earned. /// diff --git a/crates/dig-node-service/src/mirror/funding.rs b/crates/dig-node-service/src/mirror/funding.rs index 78c6ccf4..37672ca6 100644 --- a/crates/dig-node-service/src/mirror/funding.rs +++ b/crates/dig-node-service/src/mirror/funding.rs @@ -42,7 +42,8 @@ //! non-terminal record's funding coins may still be consumed, so they are withheld. See //! [`committed_funding_coin_ids`]. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; use chia_protocol::Bytes32; use chia_puzzle_types::cat::CatArgs; @@ -511,12 +512,32 @@ pub fn select_operator_dig_cats_detailed( let mut attempts: usize = 0; let mut walked_whole_pool = true; + // Every coin id genuinely still a candidate for THIS selection. Pruning the shared memo to + // this set, once, is what bounds it without a time-based expiry: a coin absent here is spent + // and can never be authenticated again (dig-node#481, item 1). + let memo = AuthMemo::shared(); + memo.retain_only(&pool.iter().map(|r| r.coin.coin_id().to_bytes()).collect()); + for record in &pool { // Enough of this operator's own money is proven spendable. Every further authentication is // a chain read that cannot change the answer. if authenticated_total >= need_dig_base_units { break; } + + let coin_id_bytes = record.coin.coin_id().to_bytes(); + + // A coin this OR an earlier call already proved can never authenticate costs no chain read + // and no budget: the ledger's own creating spend does not change, so re-asking would only + // reproduce the same refusal at the price of another read (dig-node#481, item 1). + if let Some(reason) = memo.recall(coin_id_bytes) { + skipped.push(SkippedCandidate { + coin_id: hex::encode(coin_id_bytes), + reason, + }); + continue; + } + if attempts >= MAX_AUTHENTICATION_ATTEMPTS { walked_whole_pool = false; break; @@ -531,11 +552,14 @@ pub fn select_operator_dig_cats_detailed( // `MAX_AUTHENTICATION_ATTEMPTS` lines per selection per create per pass, and the // count is chosen by whoever planted the coins -- an attacker-driven log volume in a // module with no rate limit anywhere. The whole walk is reported ONCE below. - Err(FundingError::Unauthenticated { coin_id, reason }) => { + Err((FundingError::Unauthenticated { coin_id, reason }, verdict)) => { + if verdict == AuthVerdict::Final { + memo.remember(coin_id_bytes, reason.clone()); + } skipped.push(SkippedCandidate { coin_id, reason }); } // A source that cannot answer is not a verdict about the coin. - Err(fatal) => return Err(fatal), + Err((fatal, _)) => return Err(fatal), } } @@ -1052,15 +1076,104 @@ fn whole_dig(base_units: u64) -> String { /// resolved CAT AGREES about both. They can only ever fire if the CAT construction and this module's /// derivation have drifted apart, and that is precisely the condition under which a selection would /// otherwise hand the builder coins it cannot spend. +/// Whether an [`authenticate`] refusal is safe to remember FOREVER for this exact coin id, or +/// might resolve differently on a later read and MUST NOT be cached (dig-node#481, item 1). +/// +/// A coin id commits to `(parent_coin_info, puzzle_hash, amount)`, and a parent's creating spend, +/// once on chain, never changes — so a refusal that is a pure function of that immutable data plus +/// the parent's ALREADY-EXECUTED spend can never legitimately flip for the same coin id. The one +/// exception is *"its creating spend is not on chain"*: that reads a possibly lagging or reorging +/// source, which can answer `Ok(None)` for a spend that will exist a moment later. Caching THAT +/// would permanently blacklist a coin the node genuinely owns — a money defect strictly worse than +/// the unbounded-read drain this type exists to fix. +/// +/// Deliberately has no `#[non_exhaustive]` escape and no `Default`: every call site that produces +/// one names a variant explicitly, so a future refusal reason `authenticate` grows cannot silently +/// inherit `Final` (permanent blacklist) by omission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuthVerdict { + /// Provably permanent for this coin id. Safe to remember. + Final, + /// May resolve differently on a later read. MUST NEVER be cached. + Transient, +} + +/// Coin ids [`authenticate`] has FINALLY refused, so a repeat walk of the same operator address +/// never re-pays a chain read for a coin that can never authenticate (dig-node#481, item 1). +/// +/// Process-wide, matching [`select_operator_dig_cats_detailed`]'s own scope: every `create()` in +/// one pass, and every pass, scans the SAME operator address, so a coin's authentication history is +/// a property of that address, not of any one call. Bounded independently of elapsed time — +/// [`Self::retain_only`] prunes to the caller's own current pool once per selection, giving +/// `len() <= pool.len()` for free, with [`Self::MAX_ENTRIES`] as a hard backstop against a pool +/// this module has not yet seen shrink. +struct AuthMemo { + refused: Mutex>, +} + +impl AuthMemo { + /// Costs an attacker 50,000 on-chain spends to fill even before [`Self::retain_only`] ever + /// runs — chosen the same way `bond_verify`'s own verdict cache is (dig-node#527): a real + /// ceiling on attacker-controlled growth, not a number expected to be reached in practice. + const MAX_ENTRIES: usize = 50_000; + + fn shared() -> &'static AuthMemo { + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED.get_or_init(Self::empty) + } + + /// A fresh, empty memo — the constructor [`Self::shared`] wraps, and the one a test uses + /// directly so its assertions can never depend on, or be polluted by, another test's use of + /// the process-wide singleton. + fn empty() -> AuthMemo { + AuthMemo { + refused: Mutex::new(HashMap::new()), + } + } + + /// The remembered FINAL refusal for `coin_id`, if this exact coin was ever refused for a + /// reason that cannot change. + fn recall(&self, coin_id: [u8; 32]) -> Option { + self.refused.lock().ok()?.get(&coin_id).cloned() + } + + /// Remember a FINAL refusal. A poisoned lock or a full memo simply forgets nothing new rather + /// than panicking or evicting — the cost of a missed memoisation is one re-paid chain read, + /// never a correctness defect, so failing open into "ask again" is the safe direction here. + fn remember(&self, coin_id: [u8; 32], reason: String) { + let Ok(mut refused) = self.refused.lock() else { + return; + }; + if refused.len() >= Self::MAX_ENTRIES && !refused.contains_key(&coin_id) { + return; + } + refused.insert(coin_id, reason); + } + + /// Forget every entry not in `live`. A coin absent from the operator's current unspent pool is + /// spent and can never be a fresh candidate again, so dropping it costs nothing and keeps the + /// memo's real-world size tied to the pool this selection just scanned. + fn retain_only(&self, live: &HashSet<[u8; 32]>) { + if let Ok(mut refused) = self.refused.lock() { + refused.retain(|coin_id, _| live.contains(coin_id)); + } + } +} + fn authenticate( source: &S, record: &dig_chainsource_interface::CoinRecord, owner_puzzle_hash: Bytes32, -) -> Result { +) -> Result { let coin_id = hex::encode(record.coin.coin_id()); - let refuse = |reason: &str| FundingError::Unauthenticated { - coin_id: coin_id.clone(), - reason: reason.to_string(), + let refuse = |reason: &str, verdict: AuthVerdict| { + ( + FundingError::Unauthenticated { + coin_id: coin_id.clone(), + reason: reason.to_string(), + }, + verdict, + ) }; let creating = source @@ -1068,8 +1181,8 @@ fn authenticate( // An `Err` is the source failing to answer, which is a CHAIN failure and not a verdict about // the coin. Kept distinct so an operator is not told their coin is forged when the truth is // that the read timed out. - .map_err(|e| FundingError::Chain(e.to_string()))? - .ok_or_else(|| refuse("its creating spend is not on chain"))?; + .map_err(|e| (FundingError::Chain(e.to_string()), AuthVerdict::Transient))? + .ok_or_else(|| refuse("its creating spend is not on chain", AuthVerdict::Transient))?; let parent = ParentSpend { coin: creating.coin, @@ -1077,15 +1190,26 @@ fn authenticate( solution: creating.solution.into(), }; let cat = resolve_cat(&parent, record.coin) - .map_err(|e| refuse(&format!("its lineage could not be executed: {e}")))? - .ok_or_else(|| refuse("its creating spend produced no matching CAT child"))?; + .map_err(|e| { + refuse( + &format!("its lineage could not be executed: {e}"), + AuthVerdict::Final, + ) + })? + .ok_or_else(|| { + refuse( + "its creating spend produced no matching CAT child", + AuthVerdict::Final, + ) + })?; if cat.info.asset_id != DIG_ASSET_ID { - return Err(refuse("the resolved CAT is not $DIG")); + return Err(refuse("the resolved CAT is not $DIG", AuthVerdict::Final)); } if cat.info.p2_puzzle_hash != owner_puzzle_hash { return Err(refuse( "the resolved CAT is owned by a different puzzle hash", + AuthVerdict::Final, )); } Ok(cat) @@ -1095,6 +1219,85 @@ fn authenticate( mod tests { use super::*; + /// **Proves (dig-node#481, item 1, HIGH):** a coin remembered as FINALLY refused is recalled + /// without re-deciding, and pruning to the live pool forgets exactly what left it — never more, + /// never less. + /// + /// Uses [`AuthMemo::empty`] rather than [`AuthMemo::shared`] so this assertion can never + /// depend on, or be polluted by, another test's use of the process-wide singleton. + #[test] + fn a_finally_refused_coin_is_recalled_and_pruned_with_the_pool() { + let memo = AuthMemo::empty(); + let refused = [0xAAu8; 32]; + let still_pending = [0xBBu8; 32]; + let spent = [0xCCu8; 32]; + + assert_eq!( + memo.recall(refused), + None, + "nothing is remembered before the first refusal" + ); + + memo.remember(refused, "the resolved CAT is not $DIG".to_string()); + assert_eq!( + memo.recall(refused), + Some("the resolved CAT is not $DIG".to_string()), + "a remembered FINAL refusal must be recallable by coin id" + ); + + memo.remember(spent, "the resolved CAT is not $DIG".to_string()); + memo.retain_only(&[refused, still_pending].into_iter().collect()); + assert_eq!( + memo.recall(refused), + Some("the resolved CAT is not $DIG".to_string()), + "a coin still in the live pool must survive pruning" + ); + assert_eq!( + memo.recall(spent), + None, + "a coin no longer in the live pool is spent and must be forgotten -- keeping it would \ + grow the memo without bound against a pool that only ever shrinks" + ); + } + + /// **Proves (dig-node#481, item 1, HIGH):** the memo's own bound refuses a NEW coin id once + /// full, but never evicts an entry already held to make room for one. + #[test] + fn the_memo_bound_refuses_new_entries_rather_than_evicting_held_ones() { + let memo = AuthMemo::empty(); + for i in 0..AuthMemo::MAX_ENTRIES { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&(i as u64).to_be_bytes()); + memo.remember(id, "the resolved CAT is not $DIG".to_string()); + } + + let held = { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&0u64.to_be_bytes()); + id + }; + assert!( + memo.recall(held).is_some(), + "an entry held before the memo filled must still be there" + ); + + let mut overflow = [0u8; 32]; + overflow[..8].copy_from_slice(&(AuthMemo::MAX_ENTRIES as u64).to_be_bytes()); + memo.remember(overflow, "the resolved CAT is not $DIG".to_string()); + + assert_eq!( + memo.recall(overflow), + None, + "a full memo must refuse a genuinely NEW coin id rather than evict for it -- an \ + evict-on-overflow policy is exactly the amplifier this bound exists to prevent \ + (dig-node#527's `VerdictCache` records the same failure mode for the same reason)" + ); + assert!( + memo.recall(held).is_some(), + "the overflowing insert must not have evicted the entry that was already there" + ); + } + /// A short pass, repeated. The operator hears about it ONCE. fn short(have: u64) -> FundingObservation { FundingObservation::Short { diff --git a/crates/dig-wallet/src/sage/corroborated_source.rs b/crates/dig-wallet/src/sage/corroborated_source.rs index 2e274514..dd5349fa 100644 --- a/crates/dig-wallet/src/sage/corroborated_source.rs +++ b/crates/dig-wallet/src/sage/corroborated_source.rs @@ -91,6 +91,21 @@ impl CorroboratedChainSource { self } + /// The floor a caller raised via [`Self::requiring_corroboration`] must actually meet. + #[must_use] + pub fn required_floor(&self) -> usize { + self.floor + } + + /// [`PeerSample::live_count_hint`] for the peers this source reads through — a cheap, + /// non-dialling peek, `None` when unknown. A caller with a strict [`Self::required_floor`] + /// uses this to skip a query round the CURRENTLY held sample cannot possibly satisfy + /// (dig-node#527, item 4), without forcing an extra dial round of its own to find out. + #[must_use] + pub fn live_peer_hint(&self) -> Option { + self.reads.live_count_hint() + } + /// 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 diff --git a/crates/dig-wallet/src/sage/peer_reads.rs b/crates/dig-wallet/src/sage/peer_reads.rs index 232c59f8..f1a82801 100644 --- a/crates/dig-wallet/src/sage/peer_reads.rs +++ b/crates/dig-wallet/src/sage/peer_reads.rs @@ -139,6 +139,21 @@ pub trait PeerSample: Send + Sync { /// The peers for one round. An empty or single-peer draw is a legitimate answer — the tally /// refuses it as [`quorum::Verdict::Insufficient`] rather than the draw pretending otherwise. async fn draw(&self) -> Vec>; + + /// A CHEAP, non-dialling peek at how many peers the LAST draw held live, or `None` when that + /// is unknown (nothing drawn yet, or an implementor that keeps no such state). + /// + /// This exists so a caller with a stricter-than-default corroboration floor + /// (dig-node#527, item 4) can tell, before paying for a query round, that the CURRENTLY held + /// sample cannot possibly meet it — the sample this trait's default `draw()` reuses is sized + /// for [`quorum::CORROBORATION_FLOOR`], never for a stricter caller's floor, and no cache + /// design changes that without forcing a fresh dial round on every such call. A hint is + /// deliberately conservative in the OTHER direction too: it never causes a dial, so it can + /// only ever be stale-low, never stale-high — a caller that trusts it skips work it could not + /// have won, and never skips work that might have succeeded. + fn live_count_hint(&self) -> Option { + None + } } /// Reads the clock the cache ages entries against. A seam so a cache test pins an explicit `NOW` @@ -183,6 +198,11 @@ impl PeerCorroboratedReads { self } + /// [`PeerSample::live_count_hint`] for whichever sample this instance draws through. + pub fn live_count_hint(&self) -> Option { + self.sample.live_count_hint() + } + /// The coin record the CACHE can serve for `coin_id`, dialling nothing (dig_ecosystem#3044). /// /// `Ok(None)` means the cache holds no usable entry — never that the coin does not exist. Only diff --git a/crates/dig-wallet/src/sage/peer_reads/dialed.rs b/crates/dig-wallet/src/sage/peer_reads/dialed.rs index 061cb572..0e5e22b2 100644 --- a/crates/dig-wallet/src/sage/peer_reads/dialed.rs +++ b/crates/dig-wallet/src/sage/peer_reads/dialed.rs @@ -310,4 +310,18 @@ impl PeerSample for DialedPeerSample { }) .unwrap_or_default() } + + /// A non-blocking peek: `try_lock` rather than `lock`, so a caller that only wants a hint never + /// waits behind a concurrent [`Self::draw`] — and a lock momentarily held by one is exactly the + /// case where `None` (unknown) is the honest answer, not a stale count. + fn live_count_hint(&self) -> Option { + let slot = self.held.try_lock().ok()?; + let held = slot.as_ref()?; + Some( + held.peers + .iter() + .filter(|p| !p.failed.load(Ordering::Relaxed)) + .count(), + ) + } }