From 28d4fa3ea7f86caf0922fcbd1cd48a5f320771e5 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 17 Jun 2026 17:58:39 +0900 Subject: [PATCH 1/6] feat: commitment-bound quote pricing (ADR-0003) Bind a quote's price to the node's audited storage commitment so a node cannot lie about how much it stores to inflate what it charges. The delivered guarantee is a price ceiling: to be paid above the empty-node baseline a node must surrender a signed commitment that passes binding checks before payment and faces audit after it. Node side: - Forced price: quotes price from the live storage commitment's committed key count and pin that commitment; price is the public formula of the count by exact recomputation (never price inversion). Both quote types (single-node and merkle candidate) carry and sign the binding. - The node ships the signed commitment alongside each quote so any receiver can verify the binding without an extra round trip. - Storers re-run the arithmetic on every quote and cross-check each quote's claimed count against the pinned commitment (resolved from the shipped sidecar, gossip within the answerability TTL, or a capped off-hot-path fetch), emitting portable mismatch evidence on a contradiction. - Monetized commitments enter a deterministic first-audit queue (newest-per-peer, dedup on real audit, retried across the per-peer cooldown) so the latest commitment earning a peer money faces an audit soon; minting fresh pins faster than the cooldown forfeits the older ones' coverage, never the newest's. - A new GetCommitmentByPin request resolves a pin off the hot path, rate-limited and negatively cached. Commitment sidecars are stripped before a receipt is persisted or replicated, so stored proofs do not grow. - The commitment wire type, its pin, verification, and the pricing formula move to ant-protocol so the client and node verify identically; this crate re-exports them. Enforcement ships behind observe-only flags for a hard cutover, per the ADR's rollout. Includes the ADR document and implementation-slices notes. Depends on the evmlib and ant-protocol ADR-0003 releases; will not build standalone until those publish. --- ...ADR-0003-commitment-bound-quote-pricing.md | 293 ++++ docs/adr/ADR-0003-implementation-slices.md | 109 ++ src/node.rs | 71 +- src/payment/pricing.rs | 260 +-- src/payment/proof.rs | 4 +- src/payment/quote.rs | 343 ++-- src/payment/verifier.rs | 1543 ++++++++++++++++- src/replication/commitment.rs | 153 +- src/replication/commitment_state.rs | 143 ++ src/replication/config.rs | 78 + src/replication/mod.rs | 233 ++- src/replication/protocol.rs | 48 + src/replication/types.rs | 32 + src/storage/handler.rs | 144 +- tests/e2e/merkle_payment.rs | 5 +- tests/e2e/security_attacks.rs | 1 + 16 files changed, 2916 insertions(+), 544 deletions(-) create mode 100644 docs/adr/ADR-0003-commitment-bound-quote-pricing.md create mode 100644 docs/adr/ADR-0003-implementation-slices.md diff --git a/docs/adr/ADR-0003-commitment-bound-quote-pricing.md b/docs/adr/ADR-0003-commitment-bound-quote-pricing.md new file mode 100644 index 00000000..8daa3910 --- /dev/null +++ b/docs/adr/ADR-0003-commitment-bound-quote-pricing.md @@ -0,0 +1,293 @@ +# ADR-0003: Commitment-bound quote pricing + +- **Status:** Proposed +- **Date:** 2026-06-12 +- **Decision owners:** Anselme (@grumbach) +- **Reviewers:** +- **Supersedes:** none +- **Superseded by:** none +- **Related:** ADR-0002 (gossip-triggered contiguous-subtree storage audit) + +## Context + +Nodes are paid to store chunks, and the price a node may charge grows with how +much data it holds: the quoted price is a fixed public formula of the node's record +count, so the count *is* the price. Today that count is self-reported and +free: a quote is just a signed price, the client pays the median of the close +group's seven quotes, and nothing ties the price to anything checkable. A node +can claim any count it likes — the only existing check is a node refusing +*its own* stale underpriced quote; a neighbour's quote is explicitly +unjudgeable. + +Meanwhile ADR-0002 already makes every node publish a signed **storage +commitment** — a Merkle root over the chunks it claims to hold plus the exact +**leaf count** — and makes neighbours audit it against real bytes. So the +network already maintains an audited, signed measure of how much data each +node holds. +Pricing just doesn't use it. + +This ADR binds the two. The delivered guarantee is a **price ceiling**: to be +paid above the empty-node baseline, a node must surrender a signed commitment +that passes synchronous binding checks before payment and faces audit after +it. A node may always charge less; what dies is extraction — charging more +than the storage you can prove. + +Terms used below: *commitment* = the signed `(root, key_count)` of ADR-0002. +*Pin* = the commitment's hash, identifying exactly one signed artifact. +*Forced price* = the price computed by the public formula from the pinned +commitment's `key_count`. *Baseline* = the formula at count zero. + +## Decision Drivers + +- Make it impossible to profit from overstating held data, alone or colluding, + short of capturing a whole neighbourhood. +- Every check must be deterministic where possible: exact arithmetic or a + contradiction between two artifacts signed by the same key — never a + tolerance band, never a remote clock. +- A lie must be stopped **before payment** wherever possible; penalty lanes + are backstops, not the ceiling itself. +- Never wrongly penalise an honest node — rotation races, gossip lag, crash + restarts and missing state must be graced, exactly as in ADR-0002. +- Reuse what ADR-0002 ships (the commitment, its gossip, its retention rules, + its audits, its grace lanes, its evidence path) without inventing new + cryptography. + +## Considered Options + +1. **Client-side plausibility checks only** (compare the seven quoted prices, + reject outliers). Rejected: honest heterogeneity (churn, new nodes) looks + like lying, and a neighbourhood that inflates together passes together. A + heuristic can deprioritise; it cannot convict. + +2. **Neighbour-attested quotes** (a quote is valid only when co-signed by + peers vouching for the count). Rejected: new signature plumbing, extra + round-trips on the hot quoting path, and the vouchers are the same peers + who profit from a rising neighbourhood median — collusion built in. + +3. **On-chain enforcement** (post commitments to the contract; the vault + checks price against count). Rejected: per-rotation gas for every node, and + the chain still cannot know whether a count is *true* — that knowledge only + exists off-chain, in the audits. + +4. **Force the price from the pinned commitment (chosen).** The quote carries + the claimed count and the pin of the commitment it priced against, and the + commitment itself travels with the quote, so the binding is verified before + any payment; ADR-0002's audits then check the artifact against the disk. + +## Decision + +We will make a quote's price a **deterministic function of the node's storage +commitment**, verifiable in full by whoever is about to pay, and auditable +afterwards by everyone else. + +- **Forced price, provable on receipt.** A quote gains two fields, both + covered by its signature and therefore by the quote hash the vault settles + against: the claimed `key_count` and the pin of the commitment it prices + against. The quote response carries that full signed commitment alongside — + no extra round trip — so any receiver can verify the whole binding at once: + the commitment's signature and peer binding, claimed count equals the + commitment's `key_count`, and price equals the formula applied to that + count, checked by exact recomputation (never by inverting the price, which + rounds). A node with no commitment quotes the baseline with no pin; any + count above zero requires the pin and its commitment. A quote may pin only + the node's **live current commitment**, snapshotted atomically at issuance; + retired commitments stay answerable under retention but can never be newly + quoted, so quote traffic cannot keep a stale fat commitment alive forever. + This applies to **both quote types** — the single-node quote and the + merkle-batch candidate — which live in the shared payment library and need + a versioned, breaking change to their signed payloads. Pricing thereby + moves from "all records on disk" to the committed, *responsible* set, so + data a node is about to prune no longer raises its price. + +- **The client pays nothing it cannot resolve.** Before paying, the client + runs the full binding check on every quote. The commitment arrived with the + quote, so an unresolvable, withheld, or mismatched pin is never paid — this + synchronous gate, not any later penalty lane, is the ceiling's load-bearing + wall. A failing quote is treated exactly like an unresponsive quoter + (today's retry/recovery path). + +- **Storers re-check the arithmetic; nobody trusts the client to have.** A + malicious client may pay a malformed bundle on purpose, so every storer + re-runs the price-equals-formula-of-count check on every quote in the + bundle (all seven single-node quotes; all sixteen merkle candidates) before + reconstructing the median. This needs only the bundle itself, so every + honest storer reaches the same verdict: an off-curve quote makes the bundle + objectively malformed, rejected by all with no split-brain risk and no + trust action — the rejection is the consequence. + +- **Quoting is advertising: you stay answerable for what you monetize.** + Issuing a quote refreshes the pinned (current) commitment's answerability + retention exactly as gossiping it does — judged by the node's own clock, + current commitment only. A new small request lets any neighbour fetch a + commitment by its pin. Failing to answer for a quoted pin is **graced, + never confirmed**: an unanswerable pin is indistinguishable from an honest + crash-restart (retention is in-memory by design), so it lands in the + existing timeout-strike lane, not the deterministic one. The funnel still + closes because payment already forced the artifact into the open: a cheater + must serve its commitment to be paid at all, and once seen it is audited. + +- **Peers cross-check the original and route monetized commitments into + audit.** The client forwards each quote's commitment sidecar with the + client-put bundle; storers ingest it exactly like a gossiped commitment + (signature and binding checks) and then drop it from the receipt they + persist — so the cross-check is synchronous and the audit never depends on + a post-payment fetch from the accused. On fresh client-put bundles only (a + replication receipt's pin has legitimately aged out and is skipped), each + storer compares every neighbour quote's claimed count to the pinned + original — from the sidecar, from gossip if seen within the answerability + TTL, or fetched as a fallback. A mismatch is two artifacts signed by the + same key that contradict each other: reported on first occurrence as new + evidence carrying both artifacts, portable and verifiable by anyone. A + *rational* cheater is self-consistent and never trips this; for them the + binding's job is to force the priced count into one auditable artifact, + and the audit convicts: a commitment first seen through the quote channel + enters a per-peer **deterministic first-audit queue** — deduped by pin, + most recently monetized first, drained within the existing per-peer + cooldown and concurrency caps; the lottery applies only to re-audits — so + the latest commitment earning money for a peer always faces an audit soon, + and minting fresh pins faster than the cooldown forfeits the older ones' + coverage, never the newest's. Inflated counts need fake leaves; fake leaves + fail the byte spot-check in proportion to the inflated fraction; one hit is + a deterministic first-occurrence failure. Pin fetches are rate-limited, + capped per bundle and per peer, negatively cached, and run off the payment + hot path. + +- **Freshness without remote clocks.** The client bounds quote age itself (it + requested the quote moments ago and pays promptly — its own clock, its own + risk). Node-side, no check ever gates on the quote's timestamp; staleness is + bounded by pin answerability instead. The existing percentage-based + staleness gate on a node's own quote is retired: the pin identifies the + exact artifact the price came from, so the comparison is equality against a + frozen value. + +- **Rollout.** The quote format change is a **hard cutover**, not a + mixed-fleet observe-only window. The two fields are part of the signed + payload and therefore of the quote hash, so an old quote's signature fails + on a new node (and vice versa) regardless of any flag — there is no version + in which old and new nodes interoperate on the quote wire. The fleet **and** + the clients upgrade together in one coordinated release of the shared + payment library; no flag accepts an old-format signature or hash. What + *is* a rollout dial is the **arithmetic/binding enforcement**: the + `QUOTE_ARITHMETIC_RECHECK_ENABLED` gate ships observe-only first (recompute + the forced-price/binding rule on every quote and log every would-be + rejection, but reject nothing), then flips to reject once the fleet is on + the new format. That gate is reject-only with no silence lane, so it is + independent of timeout eviction. The **unanswerable-quoted-pin** path is the + only part that couples to ADR-0002's timeout-eviction gate: until that gate + is enabled a never-answering node's exposure is bounded but not zero. The + own-quote price-staleness gate is retired for commitment-bound quotes (it + compared against the on-disk count, which the committed responsible count + legitimately differs from). + +## Consequences + +### Positive + +- The ceiling holds before money moves: an off-curve quote dies at every + checker, a withheld or unresolvable pin is never paid, a count that + contradicts its pinned commitment is first-occurrence signed evidence, and + a commitment that contradicts the disk fails its deterministic first audit. + Each lie lands in an existing lane; no new cryptography. +- Overstating is self-defeating even before detection: an inflated forced + price sits above the neighbourhood median, where it earns nothing on new + uploads while the audit clock runs. +- Understating extracts nothing for the understater — it is a discount, and + its commitment still has to be real to be quoted at all. +- The fuzzy staleness tolerance is replaced by exact equality against a + pinned artifact — strictly fewer ways to be wrong, and no remote-clock + false rejects. + +### Negative / Trade-offs + +- **The ceiling is "data held", not "data deserved".** A node that genuinely + stores self-generated junk keyed into its range prices that storage + honestly-by-the-letter: every check passes because the bytes are real. We + accept this: the attack costs real disk for as long as the price is wanted, + and audits keep it real. Junk can also be *spread* through the documented + replication self-dealing hole at the cost of a settled on-chain payment + plus gas per chunk — victims then hold (and rightfully price) real data, so + the price signal stays truthful about disk even when demand was fake. + Closing junk fully — proving sampled leaves were *paid for* by third + parties — is deliberate future work, not this ADR. +- **A ceiling is not a revenue floor.** The median's economic meaning assumes + the quote set is the true close group, but verification today checks seven + unique quoters, not *which* seven; a malicious client can assemble cheap + quorums, and coordinated undercutting (4 of 7) can suppress the median paid + to honest peers. This ADR neither fixes nor worsens that pre-existing gap; + quote-set closeness enforcement and payment policy are the follow-up that + owns the floor. +- **Price freshness equals rotation cadence.** A quote prices the last + commitment, up to one rotation old. Acceptable: a node's record count moves + slowly relative to an hour. The lever, if ever needed, is rotating early on a + large count change, not loosening the binding. +- **The quote format change is a hard cutover** — the signed payload changes, + so the whole fleet and the clients move together in one coordinated release; + there is no mixed-fleet window. Enforcement then has two *independent* dials: + the arithmetic/binding gate (observe-only → reject, no silence lane, so + independent of timeout eviction), and the unanswerable-quoted-pin silence + lane (gated behind ADR-0002's timeout-eviction enable). + +### Neutral / Operational + +- A quote grows by roughly forty bytes; the quote *response* additionally + carries the pinned signed commitment (a few kilobytes next to an + already-kilobytes quote), with no extra round trip. The client-put bundle + forwards the sidecars; persisted and replicated receipts keep only the pin + and count, so stored proofs do not grow. +- One new request type (fetch a commitment by pin), rate-limited and + negatively cached like other replication requests. +- One new deterministic evidence variant carrying the two conflicting signed + artifacts (quote and pinned commitment). An off-curve quote is reject-only: + no evidence, no trust action. No repudiation variant: unanswerable pins are + timeout-class by design. +- Quoted-pin answerability reuses the existing retention machinery and TTL; + the only additions are the issuance-time refresh and the current-only rule. +- Median ties (e.g. several baseline quotes on a young network) are broken by + peer id — canonical, not grindable per quote — so the paid slot is not + client-steerable among equals. A baseline median on a mostly-empty + neighbourhood is correct pricing, not a failure. + +## Validation + +How we will know this decision remains correct: + +- **Tests required before this ADR is Accepted.** A quote whose pin cannot be + resolved, whose commitment is withheld, or whose count mismatches its + commitment is never paid by the client; an off-curve quote in a paid bundle + is rejected identically by every storer (exact recomputation, not + inversion); a count contradicting its pinned commitment produces the + evidence variant on first occurrence, client-put context only; an honest + node is never flagged across rotation races, gossip lag, and crash-restart + (an unanswerable quoted pin is graced, never confirmed — a regression test, + since this is the false-eviction hole); quote issuance refreshes + answerability for the current commitment only, and a retired pin cannot be + newly quoted; a sidecar in a client-put bundle is ingested and cross-checked + with no fetch, and persisted receipts carry no sidecar; a commitment first + seen via the quote channel is audited deterministically within the + cooldown/concurrency budget with the most recently monetized pin + prioritised, and a flood of fresh pins does not amplify into unbounded + fetches or audits; a cached + commitment older than the answerability TTL is treated as unknown; a node + with no commitment quotes baseline with no pin and verifies; both quote + types carry and verify the new fields; end-to-end, an inflating node is + caught and earns nothing meanwhile. +- **Economic check in simulation.** With forced pricing, the expected profit + of any *overstating* strategy — small or large, solo or colluding short of + capturing a whole neighbourhood, including strategic count targeting of the + median slot — is at or below honest earnings once the synchronous client + gate, the deterministic first audit, and eviction are priced in; including + during the window where timeout eviction is still gated. +- **Operational signals and re-open triggers.** Mismatch evidence and + would-be rejections on an honest test network stay at zero; fetch traffic + and deterministic-first-audit load stay within budget. Revisit if + junk-minting or replication-seeded junk is observed at scale (escalate the + paid-leaf proof to its own ADR); revisit when quote-set closeness + enforcement lands (it may strengthen the median claims here); revisit the + rotation cadence if record counts ever move fast enough that hour-stale + prices misprice storage. + +## Notes for AI-assisted work + +AI tools may help draft this ADR, but **must not mark it Accepted without human +review**. Accepted ADRs are immutable: create a new superseding ADR rather than +editing an Accepted ADR. diff --git a/docs/adr/ADR-0003-implementation-slices.md b/docs/adr/ADR-0003-implementation-slices.md new file mode 100644 index 00000000..741efed7 --- /dev/null +++ b/docs/adr/ADR-0003-implementation-slices.md @@ -0,0 +1,109 @@ +# ADR-0003 implementation slicing + +This file tracks the slicing strategy used to ship ADR-0003 incrementally inside +ant-node alone, while the multi-repo evmlib breaking change ripens. The ADR +itself (`ADR-0003-commitment-bound-quote-pricing.md`) describes the end state; +this document describes the order in which the end state lands. + +The constraint that drives the slicing: `PaymentQuote`, `ProofOfPayment`, +`bytes_for_signing`, and `quote.hash()` live in evmlib (crates.io `0.8.1`) and +flow into the on-chain `payForQuotes` interface. Adding signed fields to +`PaymentQuote` is therefore a coordinated four-repo release +(`evmlib` → `ant-protocol` → `ant-client` → `ant-node`). Until that lands, +every part of ADR-0003 that does NOT require new signed quote fields can — +and should — ship behind the rollout const the ADR's "Rollout" section +already specifies. + +## Slice 1 — arithmetic re-check (shipped) + +**What:** every storer re-runs `price == calculate_price(n)` for some +non-negative integer `n`, by exact recomputation, on every quote in every +payment bundle (all 7 single-node quotes and all 16 merkle candidates), in +every `VerificationContext`. Reject-only when enforced; no trust evidence. +Rollout-gated by `QUOTE_ARITHMETIC_RECHECK_ENABLED` (defaults to `false` — +observe-only). Telemetry runs only after ML-DSA-65 signature verification has +passed, so unauthenticated peers cannot poison rollout logs. + +**Why first:** needs no evmlib change, no new state, no new wire types, no +new gossip; it is the ADR's "every storer re-runs the +price-equals-formula-of-count check on every quote in the bundle" rule in +its purest form. The price already encodes the count, so canonicality testing +the price alone catches every off-curve lie (a strictly weaker attack than +on-curve count inflation, which Slice 2 addresses). + +**Files touched:** `src/payment/verifier.rs` (new functions +`validate_quote_arithmetic`, `validate_merkle_candidate_arithmetic`, +`log_off_curve_single_node`, `log_off_curve_merkle`, +`price_off_curve_diagnostics`, `candidate_count_to_usize`, +`quote_price_is_on_curve`), `src/replication/config.rs` (new const +`QUOTE_ARITHMETIC_RECHECK_ENABLED`). + +**Scope it does NOT cover:** an on-curve quote for a fake `n`. That requires +the signed `claimed_key_count` and `commitment_pin` fields that only Slice 3 +can add. + +## Slice 2 — commitment-binding sidecar (no evmlib change) + +**What:** carry the issuing node's current signed `StorageCommitment` as a +sidecar inside the existing payment-proof envelope. Wire the storer-side +cross-check (claimed count from the quote vs. pinned commitment's +`key_count`) using the sidecar where present, the gossiped cache where the +sidecar pin matches, or a `GetCommitmentByPin` fetch otherwise. Adds the +`FailureEvidence::QuoteCommitmentMismatch` variant. Adds the +deterministic-first-audit queue keyed on monetized pins. + +**Why second:** this is the ADR's "peers cross-check the original and route +monetized commitments into audit" paragraph. It lands the full audit funnel +end-to-end against real signed commitments without changing evmlib. The +sidecar's `claimed_count` is not yet covered by the on-chain quote hash, so +the binding is enforced at the gossip/audit layer rather than at the chain +layer — exactly the residual the ADR's rollout phase already names. + +**Files touched (planned):** `src/payment/proof.rs` (sidecar serialization +envelope), `src/payment/verifier.rs` (cross-check rule), +`src/replication/protocol.rs` (`GetCommitmentByPin` request/response), +`src/replication/commitment_state.rs` (quote-issuance answerability refresh), +`src/replication/mod.rs` (first-audit queue alongside +`last_commitment_by_peer`), `src/replication/types.rs` +(`FailureEvidence::QuoteCommitmentMismatch`), `src/payment/quote.rs` (read +current pin from commitment state). + +## Slice 3 — signed quote fields (multi-repo, breaking cutover) — LANDED + +**What:** signed `committed_key_count: u32` and `commitment_pin: +Option<[u8; 32]>` added to `PaymentQuote` and `MerklePaymentCandidateNode` +in evmlib, included in `bytes_for_signing` (single-node) and `bytes_to_sign` +(merkle), with the quote types' fields placed at the struct **tail** so an +old-format value still rmp-decodes (as `(0, None)`). `ant-protocol` is +patched in lockstep to verify the 5-field merkle message, so the merkle +binding is genuine same-key-signed evidence too. Both `evmlib` and +`ant-protocol` are brought in via `[patch.crates-io]` against local +checkouts; the eventual upstream path is published `evmlib` → +`ant-protocol` → `ant-client` → `ant-node` releases. + +**This is a HARD CUTOVER, not a mixed-fleet observe-only.** Appending the +fields to the signed payload changes every quote's signature and +`quote.hash()`, so an old quote fails signature verification on a new node +regardless of any flag — there is no version in which old and new nodes +interoperate on the quote wire. The whole fleet and the clients upgrade +together. The earlier "Slice 3 deferred behind observe-only" framing was +wrong on this point (a round-2 review finding): only the **arithmetic +enforcement** (`QUOTE_ARITHMETIC_RECHECK_ENABLED`, reject vs log) is a +rollout dial; the signed-fields format is a one-shot breaking change. With +the fields signed, Slice 1's arithmetic gate strengthens from curve +canonicality to the exact `price == calculate_price(committed_key_count)` +binding rule (`binding_violation`), and pricing moves off the on-disk count +entirely (no-commitment → baseline). + +## Rollout coupling + +The ADR's "Rollout" section says full enforcement requires the fleet +upgraded **and** the ADR-0002 timeout-eviction gate enabled. +`QUOTE_ARITHMETIC_RECHECK_ENABLED` (reject vs observe-only-log) is +independent of timeout eviction: the arithmetic/binding gate is reject-only +on a confirmed off-curve or mis-shaped quote, with no silence lane. The +own-quote price-staleness gate is retired for commitment-bound quotes (it +compared against the on-disk count, which the committed responsible count +legitimately differs from). The Slice-2 cross-check's silence lane (an +unanswerable quoted pin) is what couples to timeout eviction, exactly as the +ADR specifies. diff --git a/src/node.rs b/src/node.rs index 5f5fd402..337d2380 100644 --- a/src/node.rs +++ b/src/node.rs @@ -143,31 +143,56 @@ impl NodeBuilder { } // Initialize replication engine (if storage is enabled) - let replication_engine = - if let (Some(ref protocol), Some(fresh_rx)) = (&ant_protocol, fresh_write_rx) { - let storage_arc = protocol.storage(); - let payment_verifier_arc = protocol.payment_verifier_arc(); - match ReplicationEngine::new( - repl_config, - Arc::clone(&p2p_arc), - storage_arc, - payment_verifier_arc, - Arc::clone(&identity), - &self.config.root_dir, - fresh_rx, - shutdown.clone(), - ) - .await - { - Ok(engine) => Some(engine), - Err(e) => { - warn!("Failed to initialize replication engine: {e}"); - None + let replication_engine = if let (Some(ref protocol), Some(fresh_rx)) = + (&ant_protocol, fresh_write_rx) + { + let storage_arc = protocol.storage(); + let payment_verifier_arc = protocol.payment_verifier_arc(); + match ReplicationEngine::new( + repl_config, + Arc::clone(&p2p_arc), + storage_arc, + payment_verifier_arc, + Arc::clone(&identity), + &self.config.root_dir, + fresh_rx, + shutdown.clone(), + ) + .await + { + Ok(engine) => { + // ADR-0003: wire the engine's commitment state as the + // quote generator's commitment source so quotes force + // their price from the live storage commitment. Done + // here because the engine owns the commitment state and + // is built after the protocol. + if let Some(ref protocol) = ant_protocol { + let concrete = Arc::clone(engine.commitment_state()); + let source: Arc = concrete; + protocol.attach_commitment_source(source); + // ADR-0003: share the engine's gossip commitment + // cache with the verifier so the cross-check can + // resolve quote pins against neighbours' commitments. + protocol + .payment_verifier_arc() + .attach_commitment_cache(Arc::clone(engine.last_commitment_by_peer())); + // ADR-0003: give the verifier the monetized-pin sender so + // commitments that back a payment get a deterministic + // first audit from the engine's drainer. + protocol + .payment_verifier_arc() + .attach_monetized_pin_sender(engine.monetized_pin_sender()); } + Some(engine) } - } else { - None - }; + Err(e) => { + warn!("Failed to initialize replication engine: {e}"); + None + } + } + } else { + None + }; let node = RunningNode { config: self.config, diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index f8a829e7..5fee7954 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -1,253 +1,13 @@ -//! Quadratic pricing with a baseline floor for ant-node. +//! Quadratic pricing with a baseline floor. //! -//! Formula: `price_per_chunk_ANT(n) = BASELINE + K × (n / D)²` +//! ADR-0003: the pricing formula is now the **single source of truth** in +//! `ant-protocol` (`ant_protocol::payment::pricing`), so the node (when pricing +//! a quote) and the client (when verifying the forced price before paying) +//! compute byte-for-byte identical prices and can never drift. This module +//! re-exports it so every existing `crate::payment::pricing::…` caller keeps +//! working unchanged. //! -//! The non-zero `BASELINE` makes empty nodes charge a meaningful spam-barrier -//! price, and `K` is anchored so per-GB USD pricing matches real-world targets -//! at the current ~$0.10/ANT token price. An earlier formula produced ~$25/GB -//! at the lower stable boundary and ~$0/GB when nodes were empty — both -//! unreasonable. -//! -//! ## Parameters -//! -//! | Constant | Value | Role | -//! |-----------|---------------|-------------------------------------------------| -//! | BASELINE | 0.00390625 ANT| Price at empty (bootstrap-phase spam barrier) | -//! | K | 0.03515625 ANT| Quadratic coefficient | -//! | D | 6000 | Lower stable boundary (records stored) | -//! -//! ## Design Rationale -//! -//! - **Empty / lightly loaded nodes** charge the `BASELINE` floor, preventing -//! free storage and acting as a bootstrap-phase spam barrier. -//! - **Moderately loaded nodes** add a small quadratic contribution on top. -//! - **Heavily loaded nodes** charge quadratically more, pushing clients -//! toward less-loaded nodes elsewhere in the network. - -use evmlib::common::Amount; - -/// Lower stable boundary of the quadratic curve, in records stored. -const PRICING_DIVISOR: u128 = 6000; - -/// `PRICING_DIVISOR²`, precomputed to avoid repeated multiplication. -const DIVISOR_SQUARED: u128 = PRICING_DIVISOR * PRICING_DIVISOR; - -/// Baseline price at empty / bootstrap-phase spam barrier. -/// -/// `0.00390625 ANT × 10¹⁸ wei/ANT = 3_906_250_000_000_000 wei`. -const PRICE_BASELINE_WEI: u128 = 3_906_250_000_000_000; - -/// Quadratic coefficient `K`. -/// -/// `0.03515625 ANT × 10¹⁸ wei/ANT = 35_156_250_000_000_000 wei`. -const PRICE_COEFFICIENT_WEI: u128 = 35_156_250_000_000_000; - -/// Price increment per squared record after simplifying `PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED`. -const PRICE_PER_RECORD_SQUARED_WEI: u128 = PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED; - -/// Derive the quoted record count from a quote price. -/// -/// This is the inverse of [`calculate_price`] and is used to validate quote -/// freshness without relying on wall-clock timestamps. It intentionally floors -/// to the nearest integer record count, matching the existing storage-delta -/// tolerance behaviour. -/// -/// Saturates to `u64::MAX` for any price that would otherwise overflow `u64`. -/// This matters because the verifier calls this on untrusted deserialized -/// `quote.price` values BEFORE signature verification: a panic here is a -/// pre-auth crash vector. Saturating leaves the delta check to reject the -/// quote as out-of-range without aborting the process. -#[must_use] -pub fn derive_records_stored_from_price(price: Amount) -> u64 { - let baseline = Amount::from(PRICE_BASELINE_WEI); - if price <= baseline { - return 0; - } - - let excess = price - baseline; - let n_squared = excess / Amount::from(PRICE_PER_RECORD_SQUARED_WEI); - let root = n_squared.root(2); - // ruint's `Uint::to::()` panics on overflow. We MUST NOT panic here: - // freshness runs on untrusted deserialized `quote.price` before signature - // verification, so a hostile oversized price would otherwise be a pre-auth - // crash vector. Saturate to `u64::MAX` instead; the delta check rejects - // out-of-range quotes. - if root > Amount::from(u64::MAX) { - u64::MAX - } else { - root.to::() - } -} - -/// Calculate storage price in wei from the number of close records stored. -/// -/// Formula: `price_wei = BASELINE + n² × K / D²` -/// -/// where `BASELINE = 0.00390625 ANT`, `K = 0.03515625 ANT`, and `D = 6000`. -/// U256 arithmetic prevents overflow for large record counts. -#[must_use] -pub fn calculate_price(close_records_stored: usize) -> Amount { - let n = Amount::from(close_records_stored); - let n_squared = n.saturating_mul(n); - let quadratic_wei = n_squared.saturating_mul(Amount::from(PRICE_COEFFICIENT_WEI)) - / Amount::from(DIVISOR_SQUARED); - Amount::from(PRICE_BASELINE_WEI).saturating_add(quadratic_wei) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - /// 1 token = 10¹⁸ wei (used for test sanity-checks). - const WEI_PER_TOKEN: u128 = 1_000_000_000_000_000_000; - - /// Helper: expected price matching the formula `BASELINE + n² × K / D²`. - fn expected_price(n: u64) -> Amount { - let n_amt = Amount::from(n); - let quad = - n_amt * n_amt * Amount::from(PRICE_COEFFICIENT_WEI) / Amount::from(DIVISOR_SQUARED); - Amount::from(PRICE_BASELINE_WEI) + quad - } - - #[test] - fn test_zero_records_gets_baseline() { - // At n = 0 the quadratic term vanishes, leaving the baseline floor. - let price = calculate_price(0); - assert_eq!(price, Amount::from(PRICE_BASELINE_WEI)); - } - - #[test] - fn test_baseline_is_nonzero_spam_barrier() { - // The baseline ensures even empty nodes charge a meaningful price, - // making the legacy MIN_PRICE_WEI = 1 sentinel redundant. - assert!(calculate_price(0) > Amount::ZERO); - assert!(calculate_price(1) > calculate_price(0)); - } - - #[test] - fn test_one_record_above_baseline() { - let price = calculate_price(1); - assert_eq!(price, expected_price(1)); - assert!(price > Amount::from(PRICE_BASELINE_WEI)); - } - - #[test] - fn test_at_divisor_is_baseline_plus_k() { - // At n = D the quadratic contribution equals K × 1² = K. - // price = BASELINE + K = 0.00390625 + 0.03515625 = 0.0390625 ANT - let price = calculate_price(6000); - let expected = Amount::from(PRICE_BASELINE_WEI + PRICE_COEFFICIENT_WEI); - assert_eq!(price, expected); - } - - #[test] - fn test_double_divisor_is_baseline_plus_four_k() { - // At n = 2D the quadratic contribution is 4K. - let price = calculate_price(12000); - let expected = Amount::from(PRICE_BASELINE_WEI + 4 * PRICE_COEFFICIENT_WEI); - assert_eq!(price, expected); - } - - #[test] - fn test_triple_divisor_is_baseline_plus_nine_k() { - // At n = 3D the quadratic contribution is 9K. - let price = calculate_price(18000); - let expected = Amount::from(PRICE_BASELINE_WEI + 9 * PRICE_COEFFICIENT_WEI); - assert_eq!(price, expected); - } - - #[test] - fn test_smooth_pricing_no_staircase() { - // 11999 should give a strictly higher price than 6000 (no integer-division plateau). - let price_6k = calculate_price(6000); - let price_11k = calculate_price(11999); - assert!( - price_11k > price_6k, - "11999 records ({price_11k}) should cost more than 6000 ({price_6k})" - ); - } - - #[test] - fn test_price_increases_with_records() { - let price_low = calculate_price(6000); - let price_mid = calculate_price(12000); - let price_high = calculate_price(18000); - assert!(price_mid > price_low); - assert!(price_high > price_mid); - } - - #[test] - fn test_price_increases_monotonically() { - let mut prev_price = Amount::ZERO; - for records in (0..60000).step_by(100) { - let price = calculate_price(records); - assert!( - price >= prev_price, - "Price at {records} records ({price}) should be >= previous ({prev_price})" - ); - prev_price = price; - } - } - - #[test] - fn test_large_value_no_overflow() { - let price = calculate_price(usize::MAX); - assert!(price > Amount::ZERO); - } - - #[test] - fn test_price_deterministic() { - let price1 = calculate_price(12000); - let price2 = calculate_price(12000); - assert_eq!(price1, price2); - } - - #[test] - fn test_quadratic_growth_excluding_baseline() { - // Subtracting the baseline, quadratic contribution should scale with n². - // At 2× records the quadratic portion is 4×; at 4× records it is 16×. - let base = Amount::from(PRICE_BASELINE_WEI); - let quad_6k = calculate_price(6000) - base; - let quad_12k = calculate_price(12000) - base; - let quad_24k = calculate_price(24000) - base; - assert_eq!(quad_12k, quad_6k * Amount::from(4u64)); - assert_eq!(quad_24k, quad_6k * Amount::from(16u64)); - } - - #[test] - fn test_small_record_counts_near_baseline() { - // At small n, price is dominated by the baseline — quadratic term is tiny. - let price = calculate_price(100); - assert_eq!(price, expected_price(100)); - assert!(price < Amount::from(WEI_PER_TOKEN)); // well below 1 ANT - assert!(price > Amount::from(PRICE_BASELINE_WEI)); // strictly above baseline - } - - #[test] - fn test_derive_records_stored_from_price_round_trips() { - for records in [0usize, 1, 5, 100, 6_000, 12_000, 60_000] { - let price = calculate_price(records); - assert_eq!(derive_records_stored_from_price(price), records as u64); - } - } - - #[test] - fn test_derive_records_stored_from_baseline_or_lower_is_zero() { - assert_eq!(derive_records_stored_from_price(Amount::ZERO), 0); - assert_eq!( - derive_records_stored_from_price(Amount::from(PRICE_BASELINE_WEI)), - 0 - ); - } +//! See `ant-protocol/src/payment/pricing.rs` for the formula, constants, and +//! the full unit-test suite. - #[test] - fn test_derive_records_stored_from_max_price_saturates_no_panic() { - // Hostile/malformed quotes may carry an oversized U256 price. - // The verifier calls this BEFORE signature verification, so we MUST - // NOT panic on overflow — saturate to u64::MAX and let the delta - // check reject the quote. - let v = derive_records_stored_from_price(Amount::MAX); - assert_eq!(v, u64::MAX); - } -} +pub use ant_protocol::payment::pricing::{calculate_price, derive_records_stored_from_price}; diff --git a/src/payment/proof.rs b/src/payment/proof.rs index 269a4366..4611138d 100644 --- a/src/payment/proof.rs +++ b/src/payment/proof.rs @@ -5,6 +5,6 @@ //! callers using `crate::payment::proof::…` keep working unchanged. pub use ant_protocol::payment::proof::{ - deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, - serialize_single_node_proof, PaymentProof, ProofType, + deserialize_merkle_proof, deserialize_proof, deserialize_single_node_proof, detect_proof_type, + serialize_merkle_proof, serialize_single_node_proof, PaymentProof, ProofType, }; diff --git a/src/payment/quote.rs b/src/payment/quote.rs index e78070da..826c41f7 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -11,7 +11,6 @@ use crate::error::{Error, Result}; use crate::logging::debug; use crate::payment::metrics::QuotingMetricsTracker; use crate::payment::pricing::calculate_price; -use crate::storage::lmdb::LmdbStorage; use evmlib::merkle_payments::MerklePaymentCandidateNode; use evmlib::PaymentQuote; use evmlib::RewardsAddress; @@ -28,6 +27,45 @@ pub type XorName = [u8; 32]; /// Signing function type that takes bytes and returns a signature. pub type SignFn = Box Vec + Send + Sync>; +/// The commitment binding a quote prices against (ADR-0003). +/// +/// `key_count` is the leaf count of the pinned commitment and the sole input to +/// the price formula; `pin` is that commitment's hash. A quote carries both, +/// signed, so any receiver can recompute the price and resolve the pin to the +/// signed commitment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct QuoteBinding { + /// Number of keys in the pinned commitment (the price driver). + pub key_count: u32, + /// Hash of the pinned commitment. + pub pin: [u8; 32], +} + +/// Source of the live storage commitment a quote prices against (ADR-0003). +/// +/// Implemented by the responder-side commitment state. Decouples +/// [`QuoteGenerator`] from replication internals: the generator only needs the +/// current commitment's `(key_count, pin)` and the guarantee that asking for it +/// refreshes its answerability ("quoting is advertising"). Returns `None` when +/// there is no live current commitment (never rotated, or retired), in which +/// case the node quotes the baseline with no pin. +pub trait CommitmentSource: Send + Sync { + /// Snapshot the current commitment's binding AND refresh its answerability, + /// atomically. `None` if there is no live current commitment. + fn current_binding_for_quote(&self) -> Option; + + /// ADR-0003: the serialized signed commitment for `pin`, if it is still + /// retained, so the quote response can ship it as a sidecar ("the commitment + /// arrived with the quote"). Returns the same canonical bytes a peer would + /// receive via `GetCommitmentByPin`, so the client's pin match is identical + /// across both resolution paths. `None` if the pin is no longer retained + /// (in which case the response carries no commitment and the client falls + /// back to gossip/fetch). Kept separate from [`Self::current_binding_for_quote`] + /// so the ~5 KB blob is only materialised when a response is being built, + /// never on the `Copy` pricing path. + fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option>; +} + /// Quote generator for creating payment quotes. /// /// Uses the node's signing capabilities to sign quotes, which clients @@ -35,23 +73,21 @@ pub type SignFn = Box Vec + Send + Sync>; pub struct QuoteGenerator { /// The rewards address for receiving payments. rewards_address: RewardsAddress, - /// Fallback in-memory record counter for pricing. + /// In-memory record counter, retained for the `records_stored` / + /// `record_store` / `resync_records` accounting surface the storage handler + /// drives. /// - /// Only consulted when no [`LmdbStorage`] is attached (unit tests, or a - /// mis-configured startup). In production the price is derived from the - /// attached store's `current_chunks()` instead — see [`Self::storage`]. + /// ADR-0003: this is NO LONGER a pricing input. A quote's price is bound to + /// the live storage commitment via [`Self::commitment_source`] (or baseline + /// when none); the on-disk/side record count no longer sets the price. metrics_tracker: QuotingMetricsTracker, - /// Authoritative on-disk record-count source for pricing. - /// - /// When attached, quote prices are computed from - /// [`LmdbStorage::current_chunks()`] — the **same** count the - /// [`PaymentVerifier`](crate::payment::PaymentVerifier) price-floor check - /// compares the paid quote against. Keeping pricing and verification on one - /// source means a quote priced at record count `N` is later checked against - /// a current count that differs only by genuine in-flight growth, instead of - /// by a side-counter-vs-store gap. - /// `None` until [`Self::attach_storage`] is called. - storage: RwLock>>, + /// ADR-0003 commitment source: the live storage commitment the price is + /// bound to. When attached, a quote prices against the committed + /// (responsible) key count and pins that commitment, refreshing its + /// answerability on issuance. `None` until [`Self::attach_commitment_source`] + /// is called — in which case the node falls back to baseline pricing with no + /// pin (observe-only / pre-rotation / unit tests). + commitment_source: RwLock>>, /// Signing function provided by the node. /// Takes bytes and returns a signature. sign_fn: Option, @@ -73,45 +109,75 @@ impl QuoteGenerator { Self { rewards_address, metrics_tracker, - storage: RwLock::new(None), + commitment_source: RwLock::new(None), sign_fn: None, pub_key: Vec::new(), } } - /// Attach the node's [`LmdbStorage`] so quote prices reflect the - /// authoritative on-disk record count. + /// Attach the ADR-0003 commitment source so quotes bind their price to the + /// node's live storage commitment. /// - /// This MUST be wired to the same `LmdbStorage` the - /// [`PaymentVerifier`](crate::payment::PaymentVerifier) price-floor check - /// reads via `current_chunks()`; otherwise pricing and verification diverge - /// and healthy payments can be rejected. Idempotent: calling twice replaces - /// the handle. Uses interior mutability so it can be called on an `Arc`. - pub fn attach_storage(&self, storage: Arc) { - *self.storage.write() = Some(storage); - debug!("QuoteGenerator: LmdbStorage attached for current-records pricing"); + /// Idempotent: calling twice replaces the handle. Uses interior mutability + /// so it can be called on an `Arc` after construction. When attached, + /// [`Self::create_quote`] and [`Self::create_merkle_candidate_quote`] price + /// against the committed responsible key count, pin the current commitment, + /// and refresh its answerability. When absent, both fall back to baseline + /// (no-pin) quotes. + pub fn attach_commitment_source(&self, source: Arc) { + *self.commitment_source.write() = Some(source); + debug!("QuoteGenerator: ADR-0003 commitment source attached"); + } + + /// ADR-0003: the serialized signed commitment for `pin`, so the quote + /// response can ship it as a sidecar. `None` when no commitment source is + /// attached (baseline / pre-rotation / tests) or the pin is no longer + /// retained — in which case the response carries no commitment and the + /// client falls back to gossip/fetch. Lock is dropped before the (heavier) + /// blob materialisation in the impl. + #[must_use] + pub fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option> { + let source = self.commitment_source.read().as_ref().map(Arc::clone); + source.and_then(|src| src.commitment_blob_for_pin(pin)) } - /// Record count used to price quotes. + /// Resolve the ADR-0003 pricing inputs a quote should carry, refreshing the + /// pinned commitment's answerability as a side effect. /// - /// Prefers the attached `LmdbStorage` count (authoritative — counts client - /// PUTs, replication stores, and repair fetches alike, exactly matching the - /// verifier's price-floor source). Falls back to the in-memory - /// `metrics_tracker` when no storage is attached or the read fails, so - /// pricing never panics or stalls. - fn pricing_records_stored(&self) -> usize { - if let Some(storage) = self.storage.read().as_ref() { - match storage.current_chunks() { - Ok(n) => return usize::try_from(n).unwrap_or(usize::MAX), - Err(e) => { - debug!( - "QuoteGenerator: current_chunks() failed ({e}); \ - falling back to metrics_tracker for pricing" - ); - } - } - } - self.metrics_tracker.records_stored() + /// Returns `(committed_key_count, commitment_pin, price_count)`: + /// - with a live commitment, the price is driven by the committed key count + /// and the quote pins that commitment (the ADR-0003 forced price); + /// - with no commitment source or no live current commitment, the node + /// emits a true **baseline** quote: `(0, None)` priced at + /// `calculate_price(0)`. + /// + /// Critically, the no-commitment branch prices at `0`, NOT at the on-disk + /// record count. A `(committed_key_count = 0, commitment_pin = None)` quote + /// is the canonical baseline shape, and ADR-0003's forced-price rule binds + /// that shape to `calculate_price(0)`. Pricing the no-pin quote off the disk + /// count would mint a `(0, None, price > baseline)` quote — a shape a + /// modified node could forge to charge above baseline while carrying no + /// auditable pin. A node that genuinely holds data prices through its + /// commitment (the `Some` branch) once it has rotated one; until then it can + /// only charge baseline, which is correct: it has nothing it can prove. + /// + /// Shared by both quote-generation paths so they stay byte-for-byte + /// consistent in how they bind price to commitment. + fn resolve_quote_pricing(&self) -> (u32, Option<[u8; 32]>, usize) { + // Resolve (and drop) the lock guard before branching: the binding is a + // plain `Copy` value, so the commitment-source lock is never held. + let binding = self + .commitment_source + .read() + .as_ref() + .and_then(|src| src.current_binding_for_quote()); + binding.map_or((0u32, None, 0usize), |binding| { + ( + binding.key_count, + Some(binding.pin), + usize::try_from(binding.key_count).unwrap_or(usize::MAX), + ) + }) } /// Set the signing function for quote generation. @@ -182,17 +248,29 @@ impl QuoteGenerator { let timestamp = SystemTime::now(); - // Calculate price from the authoritative current record count (the same - // count the verifier's price-floor check reads), falling back to the - // in-memory counter only when no storage is attached. - let price = calculate_price(self.pricing_records_stored()); + // ADR-0003 forced price: when a live commitment exists, the price is a + // deterministic function of its committed (responsible) key count, and + // the quote pins that commitment (refreshing its answerability). Absent + // a commitment source — observe-only, pre-first-rotation, or unit tests + // — the node emits the canonical baseline quote `(0, None)` priced at + // `calculate_price(0)`, NOT a price off the on-disk count (see + // `resolve_quote_pricing`: an unpinned, above-baseline price would be a + // forgeable shape). + let (committed_key_count, commitment_pin, price_count) = self.resolve_quote_pricing(); + let price = calculate_price(price_count); // Convert XorName to xor_name::XorName let xor_name = xor_name::XorName(content); // Create bytes for signing (following autonomi's pattern) - let bytes = - PaymentQuote::bytes_for_signing(xor_name, timestamp, &price, &self.rewards_address); + let bytes = PaymentQuote::bytes_for_signing( + xor_name, + timestamp, + &price, + &self.rewards_address, + committed_key_count, + &commitment_pin, + ); // Sign the bytes let signature = sign_fn(&bytes); @@ -206,8 +284,10 @@ impl QuoteGenerator { content: xor_name, timestamp, price, - pub_key: self.pub_key.clone(), rewards_address: self.rewards_address, + committed_key_count, + commitment_pin, + pub_key: self.pub_key.clone(), signature, }; @@ -272,13 +352,22 @@ impl QuoteGenerator { .as_ref() .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?; - let price = calculate_price(self.pricing_records_stored()); + // ADR-0003 forced price for the merkle-batch candidate, mirroring the + // single-node path: bind to the live commitment when present, else + // baseline with no pin. + let (committed_key_count, commitment_pin, price_count) = self.resolve_quote_pricing(); + let price = calculate_price(price_count); - // Compute the same bytes_to_sign used by the upstream library + // ADR-0003: sign the commitment binding into the merkle candidate + // payload too (5-field `bytes_to_sign`), so a count/pin mismatch is + // genuine same-key-signed evidence. ant-protocol verifies this same + // 5-field message. let msg = MerklePaymentCandidateNode::bytes_to_sign( &price, &self.rewards_address, merkle_payment_timestamp, + committed_key_count, + &commitment_pin, ); // Sign with ML-DSA-65 @@ -294,6 +383,8 @@ impl QuoteGenerator { price, reward_address: self.rewards_address, merkle_payment_timestamp, + committed_key_count, + commitment_pin, signature, }; @@ -380,37 +471,38 @@ mod tests { generator } - /// Regression test for the STG-01 quote-pricing mismatch: pricing must read - /// the attached store's `current_chunks()`, NOT the side counter. - /// - /// Before the fix, the price came from `metrics_tracker` (client-PUT count - /// only) while verifier checks read `current_chunks()` (all records, - /// including replicated ones). On a replicating network the store count ran - /// far ahead of the side counter, so every quote looked underpriced. - /// Here we attach a store, write records WITHOUT touching the side counter - /// (mimicking replication stores), and assert the quote prices off the - /// store count — i.e. the two sources now agree. - #[tokio::test] - async fn test_pricing_tracks_attached_storage_not_side_counter() { - use crate::payment::pricing::derive_records_stored_from_price; - use crate::storage::{LmdbStorage, LmdbStorageConfig}; - use tempfile::TempDir; - - let temp_dir = TempDir::new().expect("temp dir"); - let storage = Arc::new( - LmdbStorage::new(LmdbStorageConfig { - root_dir: temp_dir.path().to_path_buf(), - ..LmdbStorageConfig::test_default() + /// Fixed-binding [`CommitmentSource`] for tests: always reports the same + /// `(key_count, pin)` so we can assert the forced-price wiring exactly. + struct FixedCommitmentSource { + key_count: u32, + pin: [u8; 32], + } + impl CommitmentSource for FixedCommitmentSource { + fn current_binding_for_quote(&self) -> Option { + Some(QuoteBinding { + key_count: self.key_count, + pin: self.pin, }) - .await - .expect("create storage"), - ); + } - // Side counter deliberately starts well BELOW the store count to model - // a node whose records arrived mostly via replication (which never - // increments the side counter). - let metrics_tracker = QuotingMetricsTracker::new(3); - let mut generator = QuoteGenerator::new(RewardsAddress::new([1u8; 20]), metrics_tracker); + fn commitment_blob_for_pin(&self, _pin: [u8; 32]) -> Option> { + // The fixed source has no real commitment to serialize; the + // forced-price tests assert on the binding, not the sidecar. + None + } + } + + /// ADR-0003 forced price (single-node): with a commitment source attached, + /// the quote price is exactly `calculate_price(committed_key_count)`, the + /// quote carries that count, and it pins the commitment hash. This replaces + /// the pre-ADR-0003 "price tracks on-disk count" behaviour — pricing is now + /// bound to the signed commitment, not the raw store count. + #[test] + fn test_forced_price_binds_to_commitment() { + let mut generator = QuoteGenerator::new( + RewardsAddress::new([1u8; 20]), + QuotingMetricsTracker::new(3), + ); generator.set_signer(vec![0u8; 64], |bytes| { let mut sig = vec![0u8; 64]; for (i, b) in bytes.iter().take(64).enumerate() { @@ -418,41 +510,55 @@ mod tests { } sig }); - generator.attach_storage(Arc::clone(&storage)); - - // Write 25 distinct records straight to the store, as a replication - // store would — the side counter stays at 3. - for i in 0..25u32 { - let content = format!("replicated-record-{i}"); - let address = LmdbStorage::compute_address(content.as_bytes()); - storage - .put(&address, content.as_bytes()) - .await - .expect("put"); - } - assert_eq!( - generator.records_stored(), - 3, - "side counter must be untouched" - ); - assert_eq!(storage.current_chunks().expect("count"), 25); + + let pin = [7u8; 32]; + generator.attach_commitment_source(Arc::new(FixedCommitmentSource { key_count: 25, pin })); let quote = generator .create_quote([42u8; 32], 1024, 0) .expect("create quote"); - // Price must encode 25 (the store count), not 3 (the side counter). assert_eq!( quote.price, calculate_price(25), - "price must be derived from current_chunks(), not metrics_tracker" + "price must be calculate_price(committed_key_count)" + ); + assert_eq!( + quote.committed_key_count, 25, + "quote carries committed count" ); + assert_eq!(quote.commitment_pin, Some(pin), "quote pins the commitment"); + } + + /// ADR-0003 baseline: with NO commitment source (fresh node, pre first + /// rotation), the quote is the canonical baseline shape — `(0, None)` priced + /// at `calculate_price(0)` — NOT priced off the on-disk count. A node can + /// only charge baseline until it has a commitment it can be audited against. + #[test] + fn test_no_commitment_source_prices_baseline() { + let mut generator = QuoteGenerator::new( + RewardsAddress::new([1u8; 20]), + QuotingMetricsTracker::new(99), + ); + generator.set_signer(vec![0u8; 64], |bytes| { + let mut sig = vec![0u8; 64]; + for (i, b) in bytes.iter().take(64).enumerate() { + sig[i] = *b; + } + sig + }); + + let quote = generator + .create_quote([42u8; 32], 1024, 0) + .expect("create quote"); + assert_eq!( - derive_records_stored_from_price(quote.price), - 25, - "verifier's price-inverse must recover the store count, keeping the \ - local price comparison aligned for a freshly issued quote" + quote.price, + calculate_price(0), + "no commitment source must price at baseline calculate_price(0)" ); + assert_eq!(quote.committed_key_count, 0); + assert_eq!(quote.commitment_pin, None); } #[test] @@ -618,6 +724,8 @@ mod tests { timestamp: SystemTime::now(), price: Amount::from(1u64), rewards_address: RewardsAddress::new([0u8; 20]), + committed_key_count: 0, + commitment_pin: None, pub_key: vec![], signature: vec![], }; @@ -714,8 +822,13 @@ mod tests { // Verify the timestamp was set correctly assert_eq!(candidate.merkle_payment_timestamp, timestamp); - // Verify price was calculated from records_stored using the pricing formula - assert_eq!(candidate.price, calculate_price(50)); + // ADR-0003: with no commitment source attached, the merkle candidate is + // a baseline quote — price `calculate_price(0)`, count 0, no pin — + // regardless of the side counter. Pricing is bound to the commitment, + // not the metrics tracker. + assert_eq!(candidate.price, calculate_price(0)); + assert_eq!(candidate.committed_key_count, 0); + assert_eq!(candidate.commitment_pin, None); // Verify the public key is the ML-DSA-65 public key (not ed25519) assert_eq!( @@ -754,7 +867,13 @@ mod tests { .as_secs(); let price = Amount::from(42u64); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &rewards_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign( + &price, + &rewards_address, + timestamp, + 0, + &None, + ); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); @@ -763,6 +882,8 @@ mod tests { price, reward_address: rewards_address, merkle_payment_timestamp: timestamp, + committed_key_count: 0, + commitment_pin: None, signature, } } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 21848c32..7d2470fb 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -9,7 +9,7 @@ use crate::logging::{debug, info, warn}; use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; use crate::payment::pricing::{calculate_price, derive_records_stored_from_price}; use crate::payment::proof::{ - deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, + deserialize_merkle_proof, deserialize_single_node_proof, detect_proof_type, ProofType, }; use crate::replication::config::K_BUCKET_SIZE; use crate::storage::lmdb::LmdbStorage; @@ -26,7 +26,6 @@ use parking_lot::{Mutex, RwLock}; use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; use saorsa_core::identity::PeerId; use saorsa_core::P2PNode; -#[cfg(any(test, feature = "test-utils"))] use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; @@ -176,6 +175,25 @@ pub enum PaymentStatus { PaymentVerified, } +/// Outcome of the ADR-0003 quote-vs-commitment cross-check (see +/// [`PaymentVerifier::cross_check_binding`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrossCheck { + /// Pin resolves to the commitment and the counts agree: nothing to report. + Match, + /// Pin resolves to the commitment but the claimed and committed counts + /// disagree: deterministic, first-occurrence contradiction (evidence). + Mismatch { + /// The key count the quote claimed. + quoted_key_count: u32, + /// The key count the pinned commitment actually attests. + committed_key_count: u32, + }, + /// The supplied commitment does not hash to the quote's pin: the pin is + /// unresolved (treat as fetch/skip), never evidence. + PinDoesNotResolve, +} + impl PaymentStatus { /// Returns true if the data can be stored (cached or payment verified). #[must_use] @@ -193,6 +211,29 @@ impl PaymentStatus { /// Default capacity for the merkle pool cache (number of pool hashes to cache). const DEFAULT_POOL_CACHE_CAPACITY: usize = 1_000; +/// ADR-0003: max commitment sidecars processed per bundle. A legitimate bundle +/// carries at most one commitment per quote/candidate — `CANDIDATES_PER_POOL` +/// (16) is the larger of the single-node (`CLOSE_GROUP_SIZE` = 7) and merkle +/// cases, so it covers both. Excess sidecars from a malicious client are +/// ignored before any deserialize/verify work (bounds the hot-path cost). +const MAX_SIDECARS_PER_BUNDLE: usize = evmlib::merkle_batch_payment::CANDIDATES_PER_POOL; + +/// Shared handle to the replication engine's gossip commitment cache +/// (`last_commitment_by_peer`), used by the ADR-0003 cross-check to resolve a +/// quote's pin against a neighbour's recently gossiped commitment. A `tokio` +/// `RwLock` to match the engine's; read with `.await` on the async path. +type CommitmentCache = Arc< + tokio::sync::RwLock< + HashMap, + >, +>; + +/// Per-`(peer, pin)` negative cache for unresolved ADR-0003 pin fetches: a pin a +/// peer answered `NotRetained` (or that timed out) is remembered so repeated +/// bundles don't re-fetch it. Behind an `Arc` so the detached fetch task owns a +/// handle without borrowing the verifier. +type PinFetchNegativeCache = Arc>>; + /// Main payment verifier for ant-node. /// /// Uses: @@ -244,6 +285,34 @@ pub struct PaymentVerifier { /// exercise the full verifier path without starting an EVM chain. #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock>, + /// Test-only override for this node's own peer ID, used by + /// `validate_quote_freshness` to pick out the node's own quote from the + /// payment bundle. Production code derives it from the attached + /// [`P2PNode`]; set via [`Self::set_peer_id_for_tests`] so unit tests can + /// drive the freshness logic without wiring a real `P2PNode`. + test_peer_id_override: RwLock>, + /// ADR-0003 gossip commitment cache, shared with the replication engine + /// (`last_commitment_by_peer`). The cross-check resolves a quote's + /// `commitment_pin` against the neighbour's most recently gossiped + /// commitment held here, *only if seen within the answerability TTL*; + /// otherwise the pin is treated as unknown (fetch/skip), never a penalty. + /// A `tokio` `RwLock` to match the engine's; read with `.await` on the + /// async verification path. `None` until [`Self::attach_commitment_cache`] + /// (unit tests, or pre-replication startup). + commitment_cache: RwLock>, + /// ADR-0003 negative cache for unresolved pin fetches: a `(peer, pin)` that + /// resolved to `NotRetained` or timed out is remembered here so repeated + /// bundles citing the same unknown pin don't re-fetch (bounding the + /// amplification an attacker can drive). Keyed by `(PeerId, pin)`. Behind an + /// `Arc` so the detached background fetch task (which runs off the payment + /// hot path) can read and update it without borrowing the verifier. + pin_fetch_negative_cache: PinFetchNegativeCache, + /// ADR-0003: sender to surface monetized pins (commitments that backed a + /// payment) to the replication engine's deterministic first-audit drainer. + /// `None` until [`Self::attach_monetized_pin_sender`] (unit tests, or + /// pre-replication startup), in which case no first audit is scheduled. + monetized_pin_tx: + RwLock>>, /// Configuration. config: PaymentVerifierConfig, } @@ -366,10 +435,42 @@ impl PaymentVerifier { test_paid_quote_k_closest_override: RwLock::new(None), #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock::new(HashMap::new()), + test_peer_id_override: RwLock::new(None), + commitment_cache: RwLock::new(None), + pin_fetch_negative_cache: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(crate::replication::config::PIN_FETCH_NEGATIVE_CACHE_CAPACITY) + .unwrap_or(NonZeroUsize::MIN), + ))), + monetized_pin_tx: RwLock::new(None), config, } } + /// Attach the ADR-0003 monetized-pin sender (the replication engine's + /// first-audit drainer channel) so the cross-check can route commitments + /// that backed a payment into a deterministic first audit. Idempotent; + /// absent (unit tests / pre-replication) no first audit is scheduled. + pub fn attach_monetized_pin_sender( + &self, + tx: tokio::sync::mpsc::UnboundedSender, + ) { + *self.monetized_pin_tx.write() = Some(tx); + debug!("PaymentVerifier: ADR-0003 monetized-pin sender attached"); + } + + /// Attach the ADR-0003 gossip commitment cache (the replication engine's + /// `last_commitment_by_peer`) so the cross-check can resolve a quote's + /// `commitment_pin` against the neighbour's recently gossiped commitment. + /// + /// Wired by the node once the replication engine exists, alongside the + /// quote generator's commitment source. Idempotent. Absent (unit tests, + /// pre-replication startup), the cross-check resolves no pins from gossip + /// and falls back to fetch/skip — never a penalty. + pub fn attach_commitment_cache(&self, cache: CommitmentCache) { + *self.commitment_cache.write() = Some(cache); + debug!("PaymentVerifier: ADR-0003 commitment cache attached"); + } + /// Attach the node's [`P2PNode`] handle so paid-quote verification can /// check issuer closeness, and merkle-payment verification can check /// candidate `pub_keys` against the DHT's actual closest peers to the pool @@ -450,7 +551,10 @@ impl PaymentVerifier { /// paid-quote floor rejects client PUTs because the local floor is /// the economic security gate for this proof policy. fn current_records_stored(&self) -> Option { - if let Some(storage) = self.storage.read().as_ref() { + // Clone the Arc out and drop the guard before the storage read, so the + // `RwLock>>` is never held across `current_chunks()`. + let storage = self.storage.read().as_ref().map(Arc::clone); + if let Some(storage) = storage { match storage.current_chunks() { Ok(n) => return Some(n), Err(e) => { @@ -612,15 +716,24 @@ impl PaymentVerifier { self.verify_merkle_payment(xorname, proof, context).await?; } Some(ProofType::SingleNode) => { - let (payment, tx_hashes) = deserialize_proof(proof).map_err(|e| { + let parsed = deserialize_single_node_proof(proof).map_err(|e| { Error::Payment(format!("Failed to deserialize payment proof: {e}")) })?; - if !tx_hashes.is_empty() { - debug!("Proof includes {} transaction hash(es)", tx_hashes.len()); + if !parsed.tx_hashes.is_empty() { + debug!( + "Proof includes {} transaction hash(es)", + parsed.tx_hashes.len() + ); } - self.verify_evm_payment(xorname, &payment, context).await?; + self.verify_evm_payment( + xorname, + &parsed.proof_of_payment, + &parsed.commitment_sidecars, + context, + ) + .await?; } None => { let tag = proof.first().copied().unwrap_or(0); @@ -718,6 +831,7 @@ impl PaymentVerifier { &self, xorname: &XorName, payment: &ProofOfPayment, + commitment_sidecars: &[Vec], context: VerificationContext, ) -> Result<()> { if crate::logging::enabled!(crate::logging::Level::DEBUG) { @@ -729,6 +843,18 @@ impl PaymentVerifier { } Self::validate_quote_structure(payment)?; + // ADR-0003: re-run the `price == calculate_price(committed_key_count)` + // arithmetic/binding check on EVERY quote in the bundle (all single-node + // quotes), per the ADR's "every storer re-runs the + // price-equals-formula-of-count check on every quote in the bundle" + // rule — bundle-level, before median selection (the candidate loop below + // only sees median-priced quotes). This hard cutover also RETIRES the + // percentage-based own-quote price-staleness gate: a quote's price is + // now exactly bound to its committed count here (both the `(n>0, Some)` + // and baseline `(0, None)` shapes), and the committed responsible count + // legitimately differs from the on-disk count, so the old gate would + // FALSE-REJECT healthy ADR quotes. The binding gate supersedes it. + Self::validate_quote_arithmetic(payment)?; let candidates = Self::legacy_median_candidates(payment)?; let mut failures = Vec::with_capacity(candidates.len()); let mut verified_paid_quote = false; @@ -758,6 +884,20 @@ impl PaymentVerifier { ))); } + // ADR-0003 observe-only telemetry: log off-curve quotes only AFTER the + // paid (median) quote's ML-DSA-65 signature has verified above, so + // unauthenticated senders cannot poison rollout logs. In enforce mode + // `validate_quote_arithmetic` already rejected; this is a no-op there. + Self::log_off_curve_single_node(payment); + + // ADR-0003 cross-check + first-audit enqueue (ClientPut only) runs ONLY + // after on-chain payment verification has SUCCEEDED above, so an unpaid + // (but signed) bundle can never enqueue audits or drive pin fetches — + // closing the free-amplification path. Fresh client-put bundles only. + if context == VerificationContext::ClientPut { + self.cross_check_quotes(payment, commitment_sidecars).await; + } + if crate::logging::enabled!(crate::logging::Level::INFO) { let xorname_hex = hex::encode(xorname); info!("EVM payment verified for {xorname_hex}"); @@ -1036,6 +1176,816 @@ impl PaymentVerifier { Ok(()) } + /// Verify all quotes target the correct content address. + fn validate_quote_content(payment: &ProofOfPayment, xorname: &XorName) -> Result<()> { + for (encoded_peer_id, quote) in &payment.peer_quotes { + if !verify_quote_content(quote, xorname) { + let expected_hex = hex::encode(xorname); + let actual_hex = hex::encode(quote.content.0); + return Err(Error::Payment(format!( + "Quote content address mismatch for peer {encoded_peer_id:?}: expected {expected_hex}, got {actual_hex}" + ))); + } + } + Ok(()) + } + + /// ADR-0003: enforce that every quoted price lies exactly on the public + /// pricing curve. + /// + /// **Scope** (this slice): canonicality only. The gate proves the price is + /// some `calculate_price(n)` for a non-negative integer `n`; it does NOT + /// yet prove `n` matches a signed commitment, because `PaymentQuote` lives + /// in evmlib (crates.io) and has no `claimed_key_count` / `commitment_pin` + /// fields yet. A future slice will bind `n` to a signed commitment once + /// the evmlib quote payload is extended. Until then, an attacker can still + /// quote `calculate_price(fake_n)` for any fake count and pass this gate; + /// what dies here is the strictly weaker attack of picking a price *off* + /// the curve altogether. + /// + /// **Check**: exact recomputation, never price-inversion. We derive the + /// candidate `n` for which `quote.price` would be the curve value (using + /// the existing inverse `derive_records_stored_from_price`, which floors), + /// then recompute `calculate_price(n)` and require strict equality. + /// On-curve prices round-trip exactly; off-curve prices floor to a smaller + /// `n` whose recomputed value is strictly less than `quote.price` and so + /// are rejected. Floor-then-equality is the canonicality test the ADR + /// specifies; price inversion alone would silently accept any value + /// between two curve points. + /// + /// **Where it runs**: in every [`VerificationContext`] over **every** + /// quote in **both** quote types — all 7 single-node quotes + /// ([`Self::validate_quote_arithmetic`]) and all 16 merkle candidates + /// ([`Self::validate_merkle_candidate_arithmetic`]) — because the rule + /// "every storer re-runs the price-equals-formula-of-count check on every + /// quote in the bundle" (ADR-0003) needs no peer-specific state and depends + /// only on the bundle itself, so every honest storer reaches the same + /// verdict with no split-brain risk. + /// + /// **Reject-only**, per ADR-0003: no trust evidence is emitted, no audit + /// is scheduled. The rejection is the consequence. The gate is + /// rollout-gated by + /// [`crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED`]; when + /// `false`, off-curve quotes are accepted and only telemetered + /// ([`Self::log_off_curve_single_node`] / + /// [`Self::log_off_curve_merkle`]), matching ADR-0003's observe-only + /// rollout. Telemetry is invoked **after** ML-DSA-65 signature + /// verification so unauthenticated senders cannot poison the rollout + /// logs. + fn validate_quote_arithmetic(payment: &ProofOfPayment) -> Result<()> { + if !crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return Ok(()); + } + for (encoded_peer_id, quote) in &payment.peer_quotes { + if let Some(detail) = Self::quote_arithmetic_violation(quote) { + return Err(Error::Payment(format!( + "ADR-0003 off-curve quote rejected for peer {encoded_peer_id:?}: {detail}" + ))); + } + } + Ok(()) + } + + /// The ADR-0003 forced-price rule for a single quote, returning a human + /// diagnostic iff the quote violates it. Shared by single-node quotes and + /// merkle candidates via [`Self::binding_violation`]. + /// + /// The rule is the ADR's exact one — `price == calculate_price( + /// committed_key_count)`, recomputed, never inverted from the price (which + /// rounds) — PLUS a binding-shape check. There is no "legacy degradation" + /// that infers an old quote from its field values: a `(0, None)` quote is + /// rejected unless its price is exactly `calculate_price(0)`, closing the + /// bypass where a modified node strips the pin yet prices above baseline. + /// Old-format quotes (which never carried these fields) are tolerated only + /// at the *wire-decode* layer, where they decode as `(0, None)` and are then + /// held to the same baseline rule; an explicit version negotiation, not + /// field inference, is the sanctioned path if non-baseline legacy quotes + /// must ever be accepted. + fn quote_arithmetic_violation(quote: &evmlib::PaymentQuote) -> Option { + Self::binding_violation( + quote.committed_key_count, + quote.commitment_pin, + "e.price, + ) + } + + /// The shared ADR-0003 binding rule over a `(committed_key_count, + /// commitment_pin, price)` triple, used for both quote types. + /// + /// Enforces, in order: + /// 1. **Shape.** `(0, None)` baseline or `(n>0, Some(pin))` bound; the mixed + /// shapes `(n>0, None)` and `(0, Some(_))` are always rejected — a count + /// without a pin is unauditable, and a pin without a count is incoherent. + /// 2. **Cap.** `committed_key_count <= MAX_COMMITMENT_KEY_COUNT`; a count a + /// commitment could never legitimately attest is rejected before pricing. + /// 3. **Forced price.** `price == calculate_price(committed_key_count)`, by + /// exact recomputation. + fn binding_violation( + committed_key_count: u32, + commitment_pin: Option<[u8; 32]>, + price: &Amount, + ) -> Option { + match (committed_key_count, commitment_pin.is_some()) { + (0, false) | (1.., true) => {} + (1.., false) => { + return Some(format!( + "binding shape invalid: committed_key_count={committed_key_count} > 0 \ + but commitment_pin is None (unauditable count)" + )); + } + (0, true) => { + return Some( + "binding shape invalid: committed_key_count=0 with a commitment_pin \ + (incoherent baseline)" + .to_string(), + ); + } + } + if committed_key_count > crate::replication::commitment::MAX_COMMITMENT_KEY_COUNT { + return Some(format!( + "committed_key_count={committed_key_count} exceeds MAX_COMMITMENT_KEY_COUNT={}", + crate::replication::commitment::MAX_COMMITMENT_KEY_COUNT + )); + } + let expected = calculate_price(Self::candidate_count_to_usize(u64::from( + committed_key_count, + ))); + if &expected == price { + None + } else { + Some(format!( + "price {price} does not equal calculate_price(committed_key_count={committed_key_count}) = {expected}" + )) + } + } + + /// Pure ADR-0003 cross-check: compare a quote's claimed `(key_count, pin)` + /// against a resolved signed commitment. + /// + /// This is the decision core of "peers cross-check the original": given a + /// quote's binding and the actual `StorageCommitment` the pin was resolved + /// to (from the sidecar, the gossip cache, or a fetch), decide whether the + /// quote contradicts the commitment. It is deliberately a pure function over + /// the two artifacts so it is exhaustively unit-testable without any cache, + /// network, or trust wiring; the caller owns resolution and emission. + /// + /// Outcomes: + /// - [`CrossCheck::Match`] — the pin matches the commitment's hash and the + /// counts agree: nothing to report. + /// - [`CrossCheck::Mismatch`] — the pin matches the commitment's hash but + /// the quote's `committed_key_count` differs from the commitment's + /// `key_count`. Two artifacts signed by the same key contradict each + /// other: this is the deterministic, first-occurrence evidence. + /// - [`CrossCheck::PinDoesNotResolve`] — the supplied commitment's hash does + /// not equal the quote's pin (wrong/garbled resolution). NOT evidence: the + /// caller must treat it as an unresolved pin (fetch/skip), never a + /// penalty, exactly like an unanswerable pin. + /// + /// A baseline quote `(0, None)` is never cross-checked (it pins nothing); + /// callers skip it before reaching here. + fn cross_check_binding( + quoted_key_count: u32, + quoted_pin: [u8; 32], + commitment: &crate::replication::commitment::StorageCommitment, + ) -> CrossCheck { + // The pin IS the commitment hash; if the resolved commitment hashes to + // something else, this is not the artifact the quote pinned. + match crate::replication::commitment::commitment_hash(commitment) { + Some(h) if h == quoted_pin => { + if commitment.key_count == quoted_key_count { + CrossCheck::Match + } else { + CrossCheck::Mismatch { + quoted_key_count, + committed_key_count: commitment.key_count, + } + } + } + _ => CrossCheck::PinDoesNotResolve, + } + } + + /// ADR-0003 "peers cross-check the original": for each non-baseline quote in + /// a client-put bundle, resolve its `commitment_pin` against the gossip + /// commitment cache and report a count/pin contradiction. + /// + /// Resolution today is the gossip cache only, and only if the neighbour's + /// commitment was seen within `GOSSIP_ANSWERABILITY_TTL` — a staler cache + /// entry is treated as unknown (the ADR's "cached commitment older than the + /// answerability TTL is treated as unknown"). An unresolved pin is never a + /// penalty: it is skipped here (the sidecar and `GetCommitmentByPin` fetch + /// fallbacks resolve more pins and are layered on next, but a pin that + /// resolves nowhere stays graced, exactly like an unanswerable audit pin). + /// + /// A genuine [`CrossCheck::Mismatch`] is a deterministic, first-occurrence + /// contradiction between two same-key-signed artifacts: when enforcing, it + /// emits [`FailureEvidence::QuoteCommitmentMismatch`] to the trust engine + /// (same lane as a confirmed deterministic audit failure — NOT the timeout + /// silence lane); when observe-only, it only logs. Always best-effort: a + /// missing cache or absent `P2PNode` degrades to "resolve nothing", never an + /// error on the payment path — the synchronous arithmetic gate and the + /// later audit remain the load-bearing checks. + /// Resolve a cached peer commitment record to its commitment *only if* it + /// was seen within the answerability TTL; a staler entry is treated as + /// unknown (ADR-0003: "a cached commitment older than the answerability TTL + /// is treated as unknown"). Pure over `(record, now, ttl)` so the TTL + /// boundary is unit-testable without the async cache/network path. + fn fresh_cached_commitment( + rec: &crate::replication::commitment_state::PeerCommitmentRecord, + pin: [u8; 32], + now: std::time::Instant, + ttl: std::time::Duration, + ) -> Option { + if now.saturating_duration_since(rec.received_at) >= ttl { + return None; // stale cache entry -> treat as unknown + } + // Only resolve when the cached commitment is actually the one the quote + // pinned. The auditor cache holds a peer's LATEST gossiped commitment, + // which may be a DIFFERENT pin than this quote's; returning it would make + // `cross_check_binding` yield `PinDoesNotResolve` and wrongly suppress + // the fetch fallback for the quoted pin. A pin mismatch here means "not + // cached" -> fall through to fetch. + if rec.commitment_hash() != Some(pin) { + return None; + } + rec.last_commitment().cloned() + } + + /// Resolve a `(peer, pin)` from the gossip commitment cache, if the cache is + /// wired and holds a fresh entry whose hash matches the pin. Shared by the + /// single-node and merkle cross-check paths. + async fn cache_resolve( + cache: Option<&CommitmentCache>, + peer_id: PeerId, + pin: [u8; 32], + now: std::time::Instant, + ttl: std::time::Duration, + ) -> Option { + let cache = cache?; + let guard = cache.read().await; + guard + .get(&peer_id) + .and_then(|rec| Self::fresh_cached_commitment(rec, pin, now, ttl)) + } + + /// Parse and validate ADR-0003 commitment sidecars into a `(peer, pin) -> + /// commitment` map. Each blob is deserialized and held to the SAME gates as + /// a gossip-ingested or fetched commitment (peer id derived from its own + /// `sender_peer_id`, `BLAKE3(pubkey) == sender_peer_id`, valid signature), + /// keyed by `(its own peer, its own hash)`. Resolution then matches a quote + /// only when both the quote's peer AND pin equal the sidecar's, so a sidecar + /// can never satisfy a different peer's or a different pin's quote. An + /// unparseable or invalid sidecar is silently skipped (resolution falls back + /// to gossip/fetch), never a hard error on the payment path. + fn index_valid_sidecars( + sidecars: &[Vec], + ) -> HashMap<(PeerId, [u8; 32]), crate::replication::commitment::StorageCommitment> { + use crate::replication::commitment::MAX_COMMITMENT_SIDECAR_BYTES; + let mut map = HashMap::new(); + // Bound the number of sidecars we even look at: a legitimate bundle has + // at most one commitment per quote/candidate. `MAX_SIDECARS_PER_BUNDLE` + // (= CANDIDATES_PER_POOL, the larger of the two) caps the deserialize/ + // verify work a malicious client can force on the hot path. + for blob in sidecars.iter().take(MAX_SIDECARS_PER_BUNDLE) { + // Cap blob size before parsing: never attempt to deserialize an + // oversized commitment. + if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES { + continue; + } + let Ok(commitment) = + rmp_serde::from_slice::(blob) + else { + continue; // unparseable -> skip + }; + let peer_id = PeerId::from_bytes(commitment.sender_peer_id); + let Some(pin) = crate::replication::commitment::commitment_hash(&commitment) else { + continue; + }; + // Validate against its own (peer, pin): peer binding + pubkey + // derivation + signature + hash==pin. + if Self::fetched_commitment_is_valid(&commitment, &peer_id, pin) { + map.insert((peer_id, pin), commitment); + } + } + map + } + + async fn cross_check_quotes(&self, payment: &ProofOfPayment, commitment_sidecars: &[Vec]) { + let now = std::time::Instant::now(); + let ttl = crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL; + let p2p = self.p2p_node.read().as_ref().map(Arc::clone); + let monetized_pin_tx = self.monetized_pin_tx.read().as_ref().cloned(); + let cache = self.commitment_cache.read().as_ref().map(Arc::clone); + + // ADR-0003 "the commitment arrived with the quote": parse and FULLY + // validate the sidecars (peer/pubkey/signature/hash gates, keyed by + // `(peer, pin)`), so the cross-check resolves synchronously without a + // gossip-cache hit or a post-payment fetch. An invalid sidecar is simply + // dropped (resolution falls back to gossip/fetch), never a hard error. + let sidecar_map = Self::index_valid_sidecars(commitment_sidecars); + + // Inline pass: resolve from the sidecar first, then the gossip cache + // (cheap, no network). Pins that don't resolve here are collected for + // the off-hot-path fetch. + let mut unresolved: Vec<(PeerId, [u8; 32], u32, Vec)> = Vec::new(); + for (encoded_peer_id, quote) in &payment.peer_quotes { + let Some(pin) = quote.commitment_pin else { + continue; // baseline quote pins nothing + }; + let peer_id = PeerId::from_bytes(*encoded_peer_id.as_bytes()); + + // ADR-0003: this commitment backed a payment — route it for a + // deterministic first audit (the drainer dedups by pin and respects + // the cooldown). Best-effort: a closed channel just means no first + // audit is scheduled, never an error on the payment path. + if let Some(ref tx) = monetized_pin_tx { + let _ = tx.send(crate::replication::MonetizedPinEvent { + peer: peer_id, + pin, + key_count: quote.committed_key_count, + }); + } + // Resolution order: sidecar (synchronous, no state) -> gossip cache + // (fresh within TTL) -> fetch fallback (collected as unresolved). + let resolved = match sidecar_map.get(&(peer_id, pin)) { + Some(c) => Some(c.clone()), + None => Self::cache_resolve(cache.as_ref(), peer_id, pin, now, ttl).await, + }; + match resolved { + Some(commitment) => { + let artifact = rmp_serde::to_vec(quote).unwrap_or_default(); + Self::handle_cross_check( + &peer_id, + pin, + quote.committed_key_count, + artifact, + &commitment, + p2p.as_ref(), + ) + .await; + } + None => unresolved.push(( + peer_id, + pin, + quote.committed_key_count, + rmp_serde::to_vec(quote).unwrap_or_default(), + )), + } + } + + // Off-hot-path fallback: fetch the unresolved pins via + // `GetCommitmentByPin` and cross-check the results in a detached task, + // so `verify_payment` does not block on the network. + if unresolved.is_empty() { + return; + } + let Some(p2p) = p2p else { + return; // no P2P handle: cannot fetch, leave graced + }; + let neg_cache = Arc::clone(&self.pin_fetch_negative_cache); + tokio::spawn(async move { + Self::drain_unresolved_pin_fetches(&p2p, &neg_cache, unresolved).await; + }); + } + + /// Fetch each unresolved pin via `GetCommitmentByPin` and cross-check the + /// result. Bounded at [`MAX_PIN_FETCHES_PER_BUNDLE`] per call, negatively + /// cached per `(peer, pin)`, and graced on any miss/timeout. Shared by the + /// single-node and merkle cross-check paths; meant to run in a detached task + /// off the payment hot path. + async fn drain_unresolved_pin_fetches( + p2p: &Arc, + neg_cache: &PinFetchNegativeCache, + unresolved: Vec<(PeerId, [u8; 32], u32, Vec)>, + ) { + let mut fetched = 0usize; + for (peer_id, pin, quoted_key_count, artifact) in unresolved { + if fetched >= crate::replication::config::MAX_PIN_FETCHES_PER_BUNDLE { + debug!("ADR-0003 pin-fetch cap reached for this bundle; leaving rest graced"); + break; + } + // Skip pins already known-unresolvable for this peer. + if neg_cache.lock().get(&(peer_id, pin)).is_some() { + continue; + } + fetched += 1; + match Self::fetch_commitment_by_pin(p2p, &peer_id, pin).await { + Some(commitment) => { + Self::handle_cross_check( + &peer_id, + pin, + quoted_key_count, + artifact, + &commitment, + Some(p2p), + ) + .await; + } + None => { + // NotRetained / timeout / malformed: graced (never a + // penalty), but remembered so we don't re-fetch. + neg_cache.lock().put((peer_id, pin), ()); + } + } + } + } + + /// Apply the ADR-0003 cross-check verdict for one resolved `(peer, pin, + /// quoted_count)` against `commitment`, emitting a trust failure on a + /// genuine mismatch (when enforcing) or logging it (observe-only). Shared by + /// the inline cache pass and the background fetch path so both reach the + /// same verdict and emission. + async fn handle_cross_check( + peer_id: &PeerId, + pin: [u8; 32], + quoted_key_count: u32, + quote_artifact: Vec, + commitment: &crate::replication::commitment::StorageCommitment, + p2p: Option<&Arc>, + ) { + let CrossCheck::Mismatch { + quoted_key_count, + committed_key_count, + } = Self::cross_check_binding(quoted_key_count, pin, commitment) + else { + return; // Match or PinDoesNotResolve: nothing to report + }; + // The evidence is only meaningful if it carries the signed quote + // artifact (one of the two contradicting same-key signatures). An empty + // artifact — a re-serialization failure upstream — would produce + // non-portable, unverifiable evidence, so grace it (log) instead of + // emitting it: the deterministic first audit still convicts a genuine + // inflater on the disk bytes. + if quote_artifact.is_empty() { + warn!( + "ADR-0003 quote/commitment mismatch for {peer_id}: dropping evidence, \ + quote artifact failed to serialize (graced; the audit still runs)" + ); + return; + } + // Build the portable evidence variant — the two same-key-signed + // artifacts that contradict each other, carried in full so any third + // party can re-verify both signatures and recompute the contradiction. + // This value IS the record; `emit_mismatch_evidence` turns it into the + // trust action (or an observe-only log). + let evidence = crate::replication::types::FailureEvidence::QuoteCommitmentMismatch { + peer: *peer_id, + pinned_commitment: pin, + quoted_key_count, + committed_key_count, + quote_artifact, + commitment: Box::new(commitment.clone()), + }; + Self::emit_mismatch_evidence(&evidence, p2p).await; + } + + /// Route a `QuoteCommitmentMismatch` evidence record: when enforcing, report + /// it to the trust engine as a confirmed deterministic failure (an + /// `ApplicationFailure` — same lane as a confirmed audit failure, NOT the + /// timeout silence lane); when observe-only, only log it. Separated so the + /// evidence→action mapping is unit-testable independent of resolution. + async fn emit_mismatch_evidence( + evidence: &crate::replication::types::FailureEvidence, + p2p: Option<&Arc>, + ) { + let crate::replication::types::FailureEvidence::QuoteCommitmentMismatch { + peer, + quoted_key_count, + committed_key_count, + .. + } = evidence + else { + return; // only this variant is handled here + }; + let enforce = crate::replication::config::QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED; + if enforce { + warn!( + "ADR-0003 quote/commitment mismatch (enforcing) for {peer}: quote claims \ + {quoted_key_count} keys but pinned commitment attests {committed_key_count}" + ); + if let Some(p2p) = p2p { + p2p.report_trust_event( + peer, + saorsa_core::TrustEvent::ApplicationFailure( + crate::replication::config::AUDIT_FAILURE_TRUST_WEIGHT, + ), + ) + .await; + } + } else { + warn!( + "ADR-0003 quote/commitment mismatch observed (not enforcing) for {peer}: quote \ + claims {quoted_key_count} keys but pinned commitment attests {committed_key_count}" + ); + } + } + + /// Fetch a peer's commitment by pin via `GetCommitmentByPin`, returning it + /// only if the peer answered `Found` with a commitment that (a) is validly + /// signed and peer-bound and (b) actually hashes to the requested pin. + /// `None` on `NotRetained`, timeout, malformed, or any verification failure + /// — all graced (the caller never penalises an unresolved pin). + async fn fetch_commitment_by_pin( + p2p: &Arc, + peer_id: &PeerId, + pin: [u8; 32], + ) -> Option { + use crate::replication::config::{PIN_FETCH_TIMEOUT, REPLICATION_PROTOCOL_ID}; + use crate::replication::protocol::{ + GetCommitmentByPin, GetCommitmentByPinResponse, ReplicationMessage, + ReplicationMessageBody, + }; + let msg = ReplicationMessage { + request_id: 0, + body: ReplicationMessageBody::GetCommitmentByPin(GetCommitmentByPin { pin }), + }; + let encoded = msg.encode().ok()?; + let resp = p2p + .send_request(peer_id, REPLICATION_PROTOCOL_ID, encoded, PIN_FETCH_TIMEOUT) + .await + .ok()?; + let decoded = ReplicationMessage::decode(&resp.data).ok()?; + let ReplicationMessageBody::GetCommitmentByPinResponse(GetCommitmentByPinResponse::Found { + commitment, + }) = decoded.body + else { + return None; // NotRetained / unexpected -> graced + }; + Self::fetched_commitment_is_valid(&commitment, peer_id, pin).then_some(commitment) + } + + /// The untrusted-fetched-commitment validation gates, pure over + /// `(commitment, peer_id, pin)` so they are unit-testable. A fetched + /// commitment is accepted only if it passes the SAME gates as a gossip + /// ingest, so a peer cannot answer with another peer's (validly signed) + /// commitment and have it pass as its own: + /// (a) it is bound to THIS peer (`sender_peer_id == peer_id`), + /// (b) the embedded pubkey derives that peer id (`BLAKE3(pk) == id`), + /// (c) its signature is valid (binds the pubkey), + /// (d) it actually hashes to the pin we asked for. + fn fetched_commitment_is_valid( + commitment: &crate::replication::commitment::StorageCommitment, + peer_id: &PeerId, + pin: [u8; 32], + ) -> bool { + commitment.sender_peer_id == *peer_id.as_bytes() + && *blake3::hash(&commitment.sender_public_key).as_bytes() == commitment.sender_peer_id + && crate::replication::commitment::verify_commitment_signature(commitment) + && crate::replication::commitment::commitment_hash(commitment) == Some(pin) + } + + /// Single-node telemetry for off-curve quotes. Always returns; never + /// errors. MUST be called only after ML-DSA-65 signature verification has + /// passed, so unauthenticated peers cannot drive log volume. + fn log_off_curve_single_node(payment: &ProofOfPayment) { + if crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return; // enforce mode already rejected; no separate telemetry. + } + for (encoded_peer_id, quote) in &payment.peer_quotes { + if let Some(detail) = Self::quote_arithmetic_violation(quote) { + warn!( + "ADR-0003 off-curve single-node quote observed (not enforcing): \ + peer {encoded_peer_id:?} {detail}" + ); + } + } + } + + /// ADR-0003 sister gate for the merkle batch path: every candidate's + /// `price` field must lie on the pricing curve, by exact recomputation. + /// See [`Self::validate_quote_arithmetic`] for the rationale; semantics + /// (reject-only, rollout-gated, no trust evidence) are identical. + fn validate_merkle_candidate_arithmetic( + pool: &evmlib::merkle_payments::MerklePaymentCandidatePool, + ) -> Result<()> { + if !crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return Ok(()); + } + for candidate in &pool.candidate_nodes { + if let Some(detail) = Self::binding_violation( + candidate.committed_key_count, + candidate.commitment_pin, + &candidate.price, + ) { + return Err(Error::Payment(format!( + "ADR-0003 merkle candidate rejected (reward {}): {detail}", + candidate.reward_address + ))); + } + } + Ok(()) + } + + /// Merkle batch telemetry for off-curve candidates. Always returns; never + /// errors. MUST be called only after ML-DSA-65 signature verification has + /// passed. + fn log_off_curve_merkle(pool: &evmlib::merkle_payments::MerklePaymentCandidatePool) { + if crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return; // enforce mode already rejected; no separate telemetry. + } + for candidate in &pool.candidate_nodes { + if let Some(detail) = Self::binding_violation( + candidate.committed_key_count, + candidate.commitment_pin, + &candidate.price, + ) { + warn!( + "ADR-0003 merkle candidate violation observed (not enforcing): \ + reward {} {detail}", + candidate.reward_address + ); + } + } + } + + /// Pure curve-canonicality predicate: does `price` lie exactly on the + /// pricing curve? Equivalent to "there exists some non-negative integer + /// `n` such that `calculate_price(n) == price`". + /// + /// Separated from the rollout-gated outer gates so the canonicality rule + /// itself is unit-testable independent of the gate. Callers MUST use this + /// and not `derive_records_stored_from_price` directly: the latter floors + /// and is not a canonicality test. + /// + /// Saturation: `derive_records_stored_from_price` saturates to `u64::MAX` + /// for prices beyond `calculate_price(u64::MAX)`, and + /// [`Self::candidate_count_to_usize`] saturates to `usize::MAX` on 32-bit + /// targets. Both saturation regimes converge on `calculate_price`'s own + /// saturation ceiling; an honest in-range price (which can never approach + /// these regions — `MAX_COMMITMENT_KEY_COUNT` is `1_000_000`) round-trips + /// exactly. + #[allow(dead_code)] // boolean convenience for tests + follow-up slices + fn quote_price_is_on_curve(price: &Amount) -> bool { + Self::price_off_curve_diagnostics(price).is_none() + } + + /// Returns `Some((candidate_count, recomputed))` iff `price` is off-curve; + /// `None` iff `price` is on-curve. The tuple is the diagnostic detail used + /// by both the rejection error message and the telemetry warning. + fn price_off_curve_diagnostics(price: &Amount) -> Option<(u64, Amount)> { + let candidate_count = derive_records_stored_from_price(*price); + let recomputed = calculate_price(Self::candidate_count_to_usize(candidate_count)); + if recomputed == *price { + None + } else { + Some((candidate_count, recomputed)) + } + } + + /// Narrow the canonicality predicate's `u64` candidate into `usize` for + /// [`calculate_price`]. On every 64-bit target (the only supported + /// production target) this is the identity; on 32-bit targets we saturate + /// to `usize::MAX`, which matches `calculate_price`'s own + /// `Amount::saturating_mul` behaviour so the round-trip still terminates + /// in the same saturation regime rather than panicking. + fn candidate_count_to_usize(candidate_count: u64) -> usize { + usize::try_from(candidate_count).unwrap_or(usize::MAX) + } + + /// Verify quote freshness by price staleness, not wall-clock time and not a + /// symmetric record-count delta. + /// + /// The quote price encodes the quoting node's record count via the quadratic + /// pricing formula. We compute the price the node would charge *now* for its + /// current fullness and reject the quote only if the client under-paid that + /// current price by more than [`QUOTE_PRICE_STALENESS_PCT_TOLERANCE`]. This: + /// + /// - removes the platform clock dependency that caused Windows/UTC false + /// rejections (timestamps are deliberately unused); + /// - never rejects an over-payment (the previous symmetric `abs_diff` check + /// rejected quotes where the node had *fewer* records than when it quoted, + /// i.e. the client paid for a fuller, pricier node — nonsensical to + /// reject); and + /// - self-scales with the pricing curve, so benign in-flight churn (a node + /// storing a few replicated records between quoting and verifying) — a + /// negligible price move where the curve is flat — no longer rejects an + /// otherwise-valid payment. On a fresh, rapidly-filling testnet that churn + /// routinely exceeded the old fixed 5-record tolerance and rejected ~100% + /// of uploads via the multiplicative per-chunk effect. + /// + /// The current record count comes from the attached [`LmdbStorage`] via + /// `current_chunks()` — an O(1) B-tree page-header read, authoritative + /// regardless of which path stored the record (client PUT, replication + /// store, repair fetch) or removed it (prune delete). If no storage source + /// is available (mis-configured production startup, or a unit test that + /// didn't set a test override), the gate is skipped entirely rather than + /// rejecting every quote — see [`Self::current_records_stored`]. + /// + /// **Only this node's own quote is gated.** A bundle contains one quote + /// per close-group peer, and fullness across a close group is wildly + /// heterogeneous on a real network (a freshly joined node holds tens of + /// records while an established neighbour holds thousands). Comparing a + /// *neighbour's* quote price against *this node's* record count therefore + /// rejects honest payments whenever the group spans more than the + /// tolerance — on ant-prod-01 a close group spanning 47..=1788 records + /// made the three fullest nodes reject every bundle containing the + /// emptiest node's (perfectly fresh, 10-second-old) quote, failing the + /// PUT after the client had already paid on-chain. The node can only + /// re-derive *its own* price from its own record count, so its own quote + /// is the only one it can legitimately call stale. Replay of another + /// node's old cheap quote is that node's gate to enforce when the PUT + /// reaches it; the on-chain median payment binding is unaffected either + /// way. + /// + /// A bundle holds at most one quote per peer — [`Self::validate_quote_structure`] + /// rejects duplicate peer IDs and runs before this gate on every path — + /// so the loop below matches at most one own quote. + /// + /// RETIRED (ADR-0003 hard cutover): no longer called on the verification + /// path. Forced pricing binds a quote's price exactly to its committed count + /// via `validate_quote_arithmetic`, so a valid quote can never be "stale"; + /// and the committed *responsible* count legitimately differs from the live + /// on-disk `current_chunks()` this gate reads, so running it would + /// false-reject healthy ADR quotes (notably baseline quotes from a node that + /// holds records but has no live commitment yet). Kept, with its unit tests, + /// only to document the legacy gate's exact semantics. + #[allow(dead_code)] + fn validate_quote_freshness(&self, payment: &ProofOfPayment) -> Result<()> { + let Some(current_records) = self.current_records_stored() else { + debug!( + "PaymentVerifier: no record-count source attached; skipping \ + quote price-staleness check" + ); + return Ok(()); + }; + + let Some(self_peer_id) = self.self_peer_id_bytes() else { + debug!( + "PaymentVerifier: no self peer-id source attached; skipping \ + quote price-staleness check" + ); + return Ok(()); + }; + + // The price the node would charge right now for its current fullness, + // and the floor a quote may not drop below (one-directional: paying at + // or above `current_price` is always accepted). + let current_price = calculate_price(usize::try_from(current_records).unwrap_or(usize::MAX)); + let min_acceptable_price = current_price.saturating_mul(Amount::from( + 100u64.saturating_sub(QUOTE_PRICE_STALENESS_PCT_TOLERANCE), + )) / Amount::from(100u64); + + let mut own_quote_seen = false; + for (encoded_peer_id, quote) in &payment.peer_quotes { + if encoded_peer_id.as_bytes() != &self_peer_id { + // A neighbour's quote prices the *neighbour's* fullness; this + // node has no basis to judge it against its own record count. + continue; + } + own_quote_seen = true; + if quote.price < min_acceptable_price { + let quoted_records = derive_records_stored_from_price(quote.price); + return Err(Error::Payment(format!( + "Own quote {encoded_peer_id:?} stale: quoted price encodes \ + {quoted_records} records but node currently holds {current_records} \ + (quoted {}, minimum acceptable {min_acceptable_price} at \ + {QUOTE_PRICE_STALENESS_PCT_TOLERANCE}% under-payment tolerance)", + quote.price + ))); + } + } + + // Two self-identity notions coexist in this verifier and are expected + // to refer to the same node: `validate_local_recipient` matches "us" + // by rewards address, this gate by peer ID. They legitimately diverge + // when a PUT reaches a node whose own quote isn't in the bundle but + // whose rewards address is shared with a quoted sibling (common in + // fleet deployments). The gate fail-opens in that case — leave a + // breadcrumb, because a silent no-op is exactly what makes a + // production incident hard to reconstruct from node logs. + if !own_quote_seen { + let our_rewards_address_quoted = payment + .peer_quotes + .iter() + .any(|(_, quote)| quote.rewards_address == self.config.local_rewards_address); + if our_rewards_address_quoted { + debug!( + "PaymentVerifier: bundle contains our rewards address but no quote \ + under our peer ID; skipping quote price-staleness check" + ); + } + } + Ok(()) + } + + /// Verify each quote's `pub_key` matches the claimed peer ID via BLAKE3. + fn validate_peer_bindings(payment: &ProofOfPayment) -> Result<()> { + for (encoded_peer_id, quote) in &payment.peer_quotes { + let expected_peer_id = peer_id_from_public_key_bytes("e.pub_key) + .map_err(|e| Error::Payment(format!("Invalid ML-DSA public key in quote: {e}")))?; + + if expected_peer_id.as_bytes() != encoded_peer_id.as_bytes() { + let expected_hex = expected_peer_id.to_hex(); + let actual_hex = hex::encode(encoded_peer_id.as_bytes()); + return Err(Error::Payment(format!( + "Quote pub_key does not belong to claimed peer {encoded_peer_id:?}: \ + BLAKE3(pub_key) = {expected_hex}, peer_id = {actual_hex}" + ))); + } + } + Ok(()) + } + /// Minimum number of candidate `pub_keys` (out of 16) whose derived /// `PeerId` must be among the DHT's actual closest peers to the pool /// midpoint address for the pool to be accepted. @@ -1565,6 +2515,15 @@ impl PaymentVerifier { } } + // ADR-0003: every storer re-runs the price-equals-formula-of-count + // check on every merkle candidate, in every context, before median + // reconstruction. Runs AFTER signature verification so observe-only + // telemetry cannot be spoofed by unauthenticated senders. Reject-only + // when enforcement is enabled; no trust evidence emitted in either + // mode. + Self::validate_merkle_candidate_arithmetic(&merkle_proof.winner_pool)?; + Self::log_off_curve_merkle(&merkle_proof.winner_pool); + // Pay-yourself defence: the candidate pub_keys must map to peers the // live DHT actually considers closest to the pool midpoint. Without // this, an attacker can point all 16 reward_address fields at a @@ -1743,6 +2702,107 @@ impl PaymentVerifier { ); } + // ADR-0003: route the merkle-batch candidates through the SAME + // cross-check + first-audit funnel as single-node quotes, AFTER on-chain + // verification has succeeded (so an unpaid pool cannot drive audits or + // fetches). ClientPut only — a replication receipt's pins have aged out. + if context == VerificationContext::ClientPut { + self.cross_check_merkle_candidates( + &merkle_proof.winner_pool, + &merkle_proof.commitment_sidecars, + ) + .await; + } + + Ok(()) + } + + /// ADR-0003 cross-check for the merkle-batch path: every candidate carries + /// the same signed `(committed_key_count, commitment_pin)` binding as a + /// single-node quote, so each non-baseline candidate is resolved against the + /// gossip cache (or fetched) and routed into the deterministic first audit, + /// exactly like [`Self::cross_check_quotes`]. The candidate's peer id is + /// derived from its `pub_key` (`PeerId = BLAKE3(pub_key)`), matching how the + /// network binds identities. + async fn cross_check_merkle_candidates( + &self, + pool: &evmlib::merkle_payments::MerklePaymentCandidatePool, + commitment_sidecars: &[Vec], + ) { + let now = std::time::Instant::now(); + let ttl = crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL; + let p2p = self.p2p_node.read().as_ref().map(Arc::clone); + let monetized_pin_tx = self.monetized_pin_tx.read().as_ref().cloned(); + let cache = self.commitment_cache.read().as_ref().map(Arc::clone); + // ADR-0003 "the commitment arrived with the quote" for the merkle path: + // validate sidecars exactly as the single-node path does. + let sidecar_map = Self::index_valid_sidecars(commitment_sidecars); + + let mut unresolved: Vec<(PeerId, [u8; 32], u32, Vec)> = Vec::new(); + for candidate in &pool.candidate_nodes { + let Some(pin) = candidate.commitment_pin else { + continue; // baseline candidate pins nothing + }; + let peer_id = PeerId::from_bytes(*blake3::hash(&candidate.pub_key).as_bytes()); + + if let Some(ref tx) = monetized_pin_tx { + let _ = tx.send(crate::replication::MonetizedPinEvent { + peer: peer_id, + pin, + key_count: candidate.committed_key_count, + }); + } + + let resolved = match sidecar_map.get(&(peer_id, pin)) { + Some(c) => Some(c.clone()), + None => Self::cache_resolve(cache.as_ref(), peer_id, pin, now, ttl).await, + }; + match resolved { + Some(commitment) => { + let artifact = rmp_serde::to_vec(candidate).unwrap_or_default(); + Self::handle_cross_check( + &peer_id, + pin, + candidate.committed_key_count, + artifact, + &commitment, + p2p.as_ref(), + ) + .await; + } + None => unresolved.push(( + peer_id, + pin, + candidate.committed_key_count, + rmp_serde::to_vec(candidate).unwrap_or_default(), + )), + } + } + + if unresolved.is_empty() { + return; + } + let Some(p2p) = p2p else { + return; + }; + let neg_cache = Arc::clone(&self.pin_fetch_negative_cache); + tokio::spawn(async move { + Self::drain_unresolved_pin_fetches(&p2p, &neg_cache, unresolved).await; + }); + } + + /// Verify this node is among the paid recipients. + fn validate_local_recipient(&self, payment: &ProofOfPayment) -> Result<()> { + let local_addr = &self.config.local_rewards_address; + let is_recipient = payment + .peer_quotes + .iter() + .any(|(_, quote)| quote.rewards_address == *local_addr); + if !is_recipient { + return Err(Error::Payment( + "Payment proof does not include this node as a recipient".to_string(), + )); + } Ok(()) } } @@ -2710,6 +3770,7 @@ mod tests { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![FixedBytes::from([0xABu8; 32])], + commitment_sidecars: vec![], }; let proof_bytes = @@ -2748,6 +3809,8 @@ mod tests { timestamp: SystemTime::now(), price: Amount::from(1u64), rewards_address: RewardsAddress::new([1u8; 20]), + committed_key_count: 0, + commitment_pin: None, pub_key: vec![0u8; 64], signature: vec![0u8; 64], }; @@ -2761,6 +3824,7 @@ mod tests { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![], + commitment_sidecars: vec![], }; let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); @@ -2794,6 +3858,8 @@ mod tests { timestamp, price: Amount::from(1u64), rewards_address, + committed_key_count: 0, + commitment_pin: None, pub_key: vec![0u8; 64], signature: vec![0u8; 64], } @@ -2806,6 +3872,7 @@ mod tests { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![], + commitment_sidecars: vec![], }; serialize_single_node_proof(&proof).expect("serialize proof") } @@ -3141,6 +4208,7 @@ mod tests { peer_quotes: peer_quotes.clone(), }, tx_hashes: vec![], + commitment_sidecars: vec![], }; let tagged_bytes = serialize_single_node_proof(&proof).expect("serialize tagged proof"); @@ -3410,7 +4478,13 @@ mod tests { let price = evmlib::common::Amount::from(1024u64); #[allow(clippy::cast_possible_truncation)] let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign( + &price, + &reward_address, + timestamp, + 0, + &None, + ); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); @@ -3419,6 +4493,8 @@ mod tests { price, reward_address, merkle_payment_timestamp: timestamp, + committed_key_count: 0, + commitment_pin: None, signature, } }) @@ -4161,4 +5237,455 @@ mod tests { other => panic!("expected sparse-DHT rejection, got: {other:?}"), } } + + // ---------- ADR-0003: quote arithmetic re-check ---------- + + /// Curve canonicality: any price produced by `calculate_price(n)` is + /// on-curve by construction. We exercise a spread of `n` covering the + /// baseline floor (n=0), small counts, the pricing-curve knee + /// (`n=PRICING_DIVISOR=6000`), and a saturating-arithmetic regime. + #[test] + fn adr0003_on_curve_prices_round_trip() { + for &n in &[0usize, 1, 2, 100, 5999, 6000, 6001, 50_000, 1_000_000] { + let price = crate::payment::pricing::calculate_price(n); + assert!( + PaymentVerifier::quote_price_is_on_curve(&price), + "calculate_price({n}) = {price} must be on-curve" + ); + } + } + + /// Off-curve canonicality: a price one wei above or below an on-curve + /// point is between two adjacent curve values and must fail the + /// canonicality predicate. The check IS exact equality, not a tolerance. + #[test] + fn adr0003_off_curve_prices_rejected_by_predicate() { + // n=100 is well above baseline so price ± 1 is non-saturating. + let on = crate::payment::pricing::calculate_price(100); + let just_above = on + Amount::from(1u64); + let just_below = on - Amount::from(1u64); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&just_above), + "price one wei above an on-curve point must be off-curve" + ); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&just_below), + "price one wei below an on-curve point must be off-curve" + ); + } + + /// A price strictly below the baseline floor is off-curve: the formula's + /// minimum value is `calculate_price(0) = BASELINE`, so any smaller value + /// has no corresponding `n`. + #[test] + fn adr0003_sub_baseline_price_is_off_curve() { + let baseline = crate::payment::pricing::calculate_price(0); + let sub_baseline = baseline - Amount::from(1u64); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&sub_baseline), + "price strictly below baseline must be off-curve" + ); + } + + /// ADR-0003 storer-side gate: a bundle in which every quote is on-curve + /// passes the gate **and** the per-quote canonicality predicate. Runs in + /// every context (no `ClientPut` split): the rule depends only on the + /// bundle, not on per-peer state. The outer `validate_quote_arithmetic` + /// short-circuits to `Ok` under the observe-only rollout const, so the + /// per-quote diagnostics assertion is what proves the bundle is genuinely + /// on-curve regardless of how the const ships. + #[test] + fn adr0003_validate_quote_arithmetic_passes_for_honest_bundle() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let payment = ProofOfPayment { + peer_quotes: (0..crate::ant_protocol::CLOSE_GROUP_SIZE) + .map(|i| { + let id: [u8; 32] = rand::random(); + let byte = u8::try_from(i & 0xFF).unwrap_or(0); + let quote = make_fake_quote_at_records( + [0xC0u8; 32], + SystemTime::now(), + RewardsAddress::new([byte; 20]), + 100 * (i + 1), + ); + (EncodedPeerId::new(id), quote) + }) + .collect(), + }; + PaymentVerifier::validate_quote_arithmetic(&payment) + .expect("honest on-curve bundle must pass the gate (any const value)"); + for (_, quote) in &payment.peer_quotes { + assert!( + PaymentVerifier::price_off_curve_diagnostics("e.price).is_none(), + "every quote in honest bundle must be canonically on-curve" + ); + } + } + + /// Off-curve quote behaviour follows the rollout gate + /// [`QUOTE_ARITHMETIC_RECHECK_ENABLED`]. We assert the gate's current + /// observe-only stance: an off-curve quote is accepted with no error. + /// The enforcement-branch behaviour is exercised separately by + /// `adr0003_off_curve_diagnostics_yields_reject_payload` so both branches + /// of the const-gated split are covered in CI. + #[test] + fn adr0003_observe_only_does_not_reject_off_curve_quote() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let mut quote = make_fake_quote_at_records( + [0xC1u8; 32], + SystemTime::now(), + RewardsAddress::new([1u8; 20]), + 100, + ); + // Bump one wei off the curve. + quote.price += Amount::from(1u64); + + let id: [u8; 32] = rand::random(); + let payment = ProofOfPayment { + peer_quotes: vec![(EncodedPeerId::new(id), quote)], + }; + + // This test is only meaningful in the observe-only configuration + // (which is the default at slice ship). If a future change flips the + // const, the assertion documents the regression instead of silently + // changing semantics. + if !crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + assert!( + PaymentVerifier::validate_quote_arithmetic(&payment).is_ok(), + "observe-only rollout must not reject off-curve quotes" + ); + } + } + + /// Enforcement-branch coverage: the rejection payload (peer id, candidate + /// `n`, recomputed price) is produced for off-curve prices independently + /// of the rollout const, so CI exercises the rejection code path even + /// while [`QUOTE_ARITHMETIC_RECHECK_ENABLED`] ships as `false`. Flipping + /// the const to `true` then merely wires this diagnostic into the outer + /// `Err` return, which is what `validate_quote_arithmetic` does. + #[test] + fn adr0003_off_curve_diagnostics_yields_reject_payload() { + let on = crate::payment::pricing::calculate_price(100); + let off = on + Amount::from(1u64); + + let diag = PaymentVerifier::price_off_curve_diagnostics(&off) + .expect("off-curve price must produce diagnostics"); + let (candidate_count, recomputed) = diag; + assert_eq!(candidate_count, 100, "floor candidate must be n=100"); + assert_eq!( + recomputed, on, + "recomputed price must equal the floor curve point" + ); + assert!( + recomputed < off, + "off-curve diagnostics' recomputed price must be strictly below the off-curve input" + ); + + // And an on-curve price must produce no diagnostics. + assert!( + PaymentVerifier::price_off_curve_diagnostics(&on).is_none(), + "on-curve price must yield no off-curve diagnostics" + ); + } + + /// Saturation regime: a price strictly above + /// `calculate_price(u64::MAX-equivalent saturating ceiling)` is rejected. + /// We do not have direct access to that ceiling, but `Amount::MAX` is + /// guaranteed above it (since `calculate_price(usize::MAX)` saturates to + /// some value strictly less than `Amount::MAX` due to the additive + /// baseline). The gate must reject it. + #[test] + fn adr0003_amount_max_price_is_off_curve() { + let price = Amount::MAX; + assert!( + !PaymentVerifier::quote_price_is_on_curve(&price), + "Amount::MAX must not be a valid on-curve price" + ); + } + + /// Merkle gate, predicate-level: the same canonicality rule applies to + /// `MerklePaymentCandidateNode.price`. We don't construct a full merkle + /// proof here (the test fixtures for that live elsewhere); we prove the + /// underlying decision matches the single-node side, so the Merkle gate + /// inherits the same correctness as `validate_quote_arithmetic`. + #[test] + fn adr0003_merkle_candidate_canonicality_matches_single_node() { + // Every on-curve `n` produces a price the predicate accepts; one wei + // off produces a price the predicate rejects. This is the entire + // contract; the Merkle gate's outer wrapper enforces the same const + // as the single-node gate, so the wrappers are mechanically + // equivalent. + for &n in &[0usize, 1, 100, 6000, 1_000_000] { + let on = crate::payment::pricing::calculate_price(n); + assert!( + PaymentVerifier::quote_price_is_on_curve(&on), + "merkle candidate price for n={n} must be on-curve" + ); + if n > 0 { + let off = on + Amount::from(1u64); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&off), + "merkle candidate price one wei above n={n} must be off-curve" + ); + } + } + } + + /// Merkle gate, pool-level: build a real signed candidate pool, set every + /// candidate's price to the same on-curve value, and assert the gate + /// passes. Then bump one candidate's price one wei off-curve and assert + /// the per-candidate diagnostics correctly identify it. We use the + /// diagnostics predicate rather than the outer `validate_merkle_candidate_arithmetic` + /// because the outer wrapper short-circuits to `Ok` under the observe-only + /// rollout const; the diagnostics path is what carries the rejection + /// information when enforcement flips on. + #[test] + fn adr0003_merkle_pool_off_curve_candidate_caught_by_diagnostics() { + use evmlib::merkle_payments::MerklePaymentCandidatePool; + + let timestamp = 1_700_000_000u64; + let mut candidates = make_candidate_nodes(timestamp); + + // Set every candidate price to calculate_price(500) so the pool is + // honestly on-curve to start. + let on_curve = crate::payment::pricing::calculate_price(500); + for c in &mut candidates { + c.price = on_curve; + } + let pool = MerklePaymentCandidatePool { + midpoint_proof: fake_midpoint_proof(), + candidate_nodes: candidates, + }; + // The outer wrapper is rollout-gated, but a fully on-curve pool must + // pass it under any const value because the loop finds no off-curve + // candidate to reject. + PaymentVerifier::validate_merkle_candidate_arithmetic(&pool) + .expect("honest on-curve pool must pass merkle gate (any const value)"); + for c in &pool.candidate_nodes { + assert!( + PaymentVerifier::price_off_curve_diagnostics(&c.price).is_none(), + "every honest candidate must be canonically on-curve" + ); + } + + // Now bump exactly one candidate off-curve and check that the + // diagnostics path catches it. (The outer wrapper still short-circuits + // under observe-only; this proves the underlying detection works + // independently of the rollout const, exercising the rejection-payload + // path in CI.) + let mut tampered = pool; + tampered.candidate_nodes[3].price += Amount::from(1u64); + let mut off_curve_seen = 0; + for c in &tampered.candidate_nodes { + if PaymentVerifier::price_off_curve_diagnostics(&c.price).is_some() { + off_curve_seen += 1; + } + } + assert_eq!( + off_curve_seen, 1, + "exactly one tampered candidate must register as off-curve" + ); + } + + // === ADR-0003 binding-shape + cross-check unit tests === + + use crate::payment::pricing::calculate_price as cp; + + /// Build a real signed commitment over `n` synthetic keys for tests. + fn test_built_commitment(n: u32) -> crate::replication::commitment_state::BuiltCommitment { + use saorsa_pqc::api::sig::ml_dsa_65; + let (pk, sk) = ml_dsa_65().generate_keypair().expect("keypair"); + let pk_bytes = pk.to_bytes(); + let peer_id = blake3::hash(&pk_bytes); + let entries: Vec<([u8; 32], [u8; 32])> = (0..n) + .map(|i| { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_le_bytes()); + let mut b = [1u8; 32]; + b[..4].copy_from_slice(&i.to_le_bytes()); + (k, b) + }) + .collect(); + crate::replication::commitment_state::BuiltCommitment::build( + entries, + peer_id.as_bytes(), + &sk, + &pk_bytes, + ) + .expect("build commitment") + } + + #[test] + fn binding_baseline_ok_only_at_baseline_price() { + // (0, None) with calculate_price(0) is the valid baseline. + assert!(PaymentVerifier::binding_violation(0, None, &cp(0)).is_none()); + // (0, None) with a non-baseline price is REJECTED — this is the BLOCKER + // bypass the round-1 review found (unpinned quote priced above baseline). + assert!(PaymentVerifier::binding_violation(0, None, &cp(500)).is_some()); + } + + #[test] + fn binding_bound_ok_only_with_pin_and_exact_price() { + let pin = [9u8; 32]; + // (n>0, Some(pin)) priced exactly is valid. + assert!(PaymentVerifier::binding_violation(500, Some(pin), &cp(500)).is_none()); + // (n>0, Some(pin)) priced for a DIFFERENT count is rejected (on-curve + // but wrong count — stronger than canonicality). + assert!(PaymentVerifier::binding_violation(500, Some(pin), &cp(499)).is_some()); + } + + #[test] + fn binding_rejects_incoherent_shapes() { + let pin = [9u8; 32]; + // count > 0 but no pin: unauditable. + assert!(PaymentVerifier::binding_violation(500, None, &cp(500)).is_some()); + // count 0 but a pin: incoherent baseline. + assert!(PaymentVerifier::binding_violation(0, Some(pin), &cp(0)).is_some()); + } + + #[test] + fn binding_rejects_count_above_cap() { + let pin = [9u8; 32]; + let over = crate::replication::commitment::MAX_COMMITMENT_KEY_COUNT + 1; + assert!(PaymentVerifier::binding_violation(over, Some(pin), &cp(over as usize)).is_some()); + } + + #[test] + fn cross_check_match_when_pin_and_count_agree() { + let built = test_built_commitment(12); + let outcome = PaymentVerifier::cross_check_binding(12, built.hash(), built.commitment()); + assert_eq!(outcome, CrossCheck::Match); + } + + #[test] + fn cross_check_mismatch_when_count_inflated() { + let built = test_built_commitment(12); + // Quote claims 999 but the pinned commitment attests 12. + let outcome = PaymentVerifier::cross_check_binding(999, built.hash(), built.commitment()); + assert_eq!( + outcome, + CrossCheck::Mismatch { + quoted_key_count: 999, + committed_key_count: 12, + } + ); + } + + #[test] + fn cross_check_unresolved_when_pin_wrong() { + let built = test_built_commitment(12); + // Pin does not match the supplied commitment's hash: not evidence. + let outcome = PaymentVerifier::cross_check_binding(12, [0xFFu8; 32], built.commitment()); + assert_eq!(outcome, CrossCheck::PinDoesNotResolve); + } + + #[test] + fn fresh_cached_commitment_honours_ttl_boundary() { + use crate::replication::commitment_state::PeerCommitmentRecord; + let built = test_built_commitment(5); + let commitment = built.commitment().clone(); + let pin = built.hash(); + let ttl = std::time::Duration::from_secs(3 * 3600); + let now = std::time::Instant::now(); + + // Fresh AND matching pin -> resolves to the commitment. + let fresh = PeerCommitmentRecord::from_verified(commitment.clone(), now); + assert!( + PaymentVerifier::fresh_cached_commitment(&fresh, pin, now, ttl).is_some(), + "a fresh cache entry whose hash matches the pin must resolve" + ); + + // Fresh but a DIFFERENT pin -> treated as not-cached (None), so the + // caller falls through to fetch the actually-quoted pin instead of + // mis-resolving against the peer's latest (different) commitment. + assert!( + PaymentVerifier::fresh_cached_commitment(&fresh, [0xEEu8; 32], now, ttl).is_none(), + "a fresh cache entry for a DIFFERENT pin must not resolve (fetch fallback runs)" + ); + + // Stale: received older than the TTL -> treated as unknown (None), the + // ADR-0003 false-positive guard against an aged cache entry. + let stale_at = now + .checked_sub(ttl + std::time::Duration::from_secs(1)) + .expect("instant in range"); + let stale = PeerCommitmentRecord::from_verified(commitment, stale_at); + assert!( + PaymentVerifier::fresh_cached_commitment(&stale, pin, now, ttl).is_none(), + "a cache entry older than the answerability TTL must be treated as unknown" + ); + } + + #[test] + fn fetched_commitment_must_be_bound_to_the_queried_peer() { + // A fetched commitment is accepted only when it is bound to the peer we + // asked (sender_peer_id == peer_id) and hashes to the requested pin. + let built = test_built_commitment(8); + let commitment = built.commitment().clone(); + let pin = built.hash(); + let owner = PeerId::from_bytes(commitment.sender_peer_id); + + // Correct owner + correct pin -> accepted. + assert!( + PaymentVerifier::fetched_commitment_is_valid(&commitment, &owner, pin), + "a peer's own validly-signed commitment, hashing to the pin, must be accepted" + ); + + // Same (validly signed) commitment but attributed to a DIFFERENT peer -> + // rejected. This is the MAJOR fix: a peer must not be able to answer with + // someone else's commitment and have it pass as its own. + let other = PeerId::from_bytes([0xABu8; 32]); + assert!( + !PaymentVerifier::fetched_commitment_is_valid(&commitment, &other, pin), + "another peer's commitment must be rejected for the queried peer" + ); + + // Correct owner but wrong pin -> rejected. + assert!( + !PaymentVerifier::fetched_commitment_is_valid(&commitment, &owner, [0u8; 32]), + "a commitment that does not hash to the requested pin must be rejected" + ); + } + + #[tokio::test] + async fn emit_mismatch_evidence_is_observe_only_safe_without_p2p() { + // The evidence variant is constructed and routed; in observe-only mode + // (and with no P2P handle) it must log without panicking and take no + // trust action. This exercises the evidence->action mapping directly. + let built = test_built_commitment(12); + let evidence = crate::replication::types::FailureEvidence::QuoteCommitmentMismatch { + peer: PeerId::from_bytes([1u8; 32]), + pinned_commitment: [2u8; 32], + quoted_key_count: 999, + committed_key_count: 12, + quote_artifact: vec![0xAA; 16], + commitment: Box::new(built.commitment().clone()), + }; + // No P2P -> no trust event even if enforce were on; must not panic. + PaymentVerifier::emit_mismatch_evidence(&evidence, None).await; + } + + #[test] + fn valid_sidecar_is_indexed_and_resolves_synchronously() { + // A valid sidecar blob is indexed under its own (peer, pin) so the + // cross-check resolves it synchronously — "the commitment arrived with + // the quote", no gossip-cache hit or fetch needed. + let built = test_built_commitment(9); + let commitment = built.commitment().clone(); + let pin = built.hash(); + let owner = PeerId::from_bytes(commitment.sender_peer_id); + let blob = rmp_serde::to_vec(&commitment).expect("serialize sidecar"); + + let map = PaymentVerifier::index_valid_sidecars(std::slice::from_ref(&blob)); + assert!( + map.contains_key(&(owner, pin)), + "a valid sidecar must be indexed under its own (peer, pin)" + ); + + // A garbage blob is silently skipped (resolution falls back), never a + // hard error. + let map2 = PaymentVerifier::index_valid_sidecars(&[vec![0xFF; 8]]); + assert!(map2.is_empty(), "an unparseable sidecar must be skipped"); + } } diff --git a/src/replication/commitment.rs b/src/replication/commitment.rs index 87d004e2..0b3aab0d 100644 --- a/src/replication/commitment.rs +++ b/src/replication/commitment.rs @@ -23,21 +23,20 @@ //! reward-eligibility cache) lives here yet — that's the next phase. use blake3::Hasher; -use saorsa_pqc::api::sig::{ - ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature, MlDsaVariant, -}; -use serde::{Deserialize, Serialize}; +use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaSecretKey}; use crate::ant_protocol::XorName; -/// Domain-separation tag for the commitment signature. -/// -/// Signed payload is BLAKE3 over (this tag || canonical commitment fields). -pub const DOMAIN_COMMITMENT: &[u8] = b"autonomi.ant.replication.storage_commitment.v1"; - -/// Domain-separation tag for the auditor's pin: BLAKE3 over (this tag || -/// canonical commitment blob). -pub const DOMAIN_COMMITMENT_HASH: &[u8] = b"autonomi.ant.replication.commitment_hash.v1"; +// ADR-0003: the commitment wire type, its pin (`commitment_hash`), its +// signature verification, and the key-count cap are the SINGLE SOURCE OF TRUTH +// in `ant-protocol` so the paying client and the node verify identically. +// Re-exported here so all existing `crate::replication::commitment::…` callers +// keep resolving. The Merkle tree, inclusion paths, and signing stay node-side +// below (the client never builds or signs a commitment, only verifies one). +pub use ::ant_protocol::payment::commitment::{ + commitment_hash, verify_commitment_signature, StorageCommitment, DOMAIN_COMMITMENT, + DOMAIN_COMMITMENT_HASH, MAX_COMMITMENT_KEY_COUNT, MAX_COMMITMENT_SIDECAR_BYTES, +}; /// Domain-separation tag for Merkle leaves: `BLAKE3(this || key || H(bytes))`. pub const DOMAIN_LEAF: &[u8] = b"autonomi.ant.replication.storage_leaf.v1"; @@ -45,56 +44,9 @@ pub const DOMAIN_LEAF: &[u8] = b"autonomi.ant.replication.storage_leaf.v1"; /// Domain-separation tag for Merkle internal nodes: `BLAKE3(this || left || right)`. pub const DOMAIN_NODE: &[u8] = b"autonomi.ant.replication.storage_node.v1"; -/// Maximum number of keys a single commitment may cover. -/// -/// Bounds the Merkle path depth (audit responses carry `O(log2 key_count)` -/// hashes per key) and the responder-side tree memory. A node storing more -/// keys than this would need to split its claim — out of scope for v1. -pub const MAX_COMMITMENT_KEY_COUNT: u32 = 1_000_000; - -/// Signed storage commitment. -/// -/// Piggybacked on neighbour-sync gossip. The signature commits to the -/// Merkle root, key count, sender peer ID, **and the sender's ML-DSA-65 -/// public key** under [`DOMAIN_COMMITMENT`]. -/// -/// Embedding the public key lets any receiver verify the signature -/// without an external `PeerId → MlDsaPublicKey` lookup. Binding the -/// public key in the signed payload prevents a key-swap attack where an -/// adversary keeps the message body but re-signs it under a different key -/// to claim a different identity. The peer-id binding (gate 2a in -/// `verify_commitment_bound_response`) still ensures the embedded key -/// belongs to the gossiping peer. -/// -/// # Wire size -/// -/// One commitment is approximately 5.3 KiB: -/// - root: 32 B -/// - `key_count`: 4 B -/// - `sender_peer_id`: 32 B -/// - `sender_public_key`: 1952 B (ML-DSA-65 public key) -/// - signature: 3293 B (ML-DSA-65 signature) -/// -/// Piggybacked on every `NeighborSyncRequest`/`Response` (~1 h interval -/// per close-group peer at the neighbour-sync cooldown cadence). At a -/// realistic close-group size of 8 with bidirectional sync, that's -/// roughly 8 × 2 × 5.3 KiB / hour = ~85 KiB/h of additional gossip -/// per node. Negligible against typical chunk-transfer bandwidth. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct StorageCommitment { - /// Merkle root over the responder's claimed keys. - pub root: [u8; 32], - /// Number of leaves committed over. - pub key_count: u32, - /// Sender peer ID, bound to the signature. - pub sender_peer_id: [u8; 32], - /// Sender's ML-DSA-65 public key bytes (1952 bytes). Embedded so - /// receivers can verify the signature without a separate pubkey - /// directory. Bound by the signature. - pub sender_public_key: Vec, - /// ML-DSA-65 signature over canonical commitment fields. 3293 bytes. - pub signature: Vec, -} +// `MAX_COMMITMENT_KEY_COUNT` and `StorageCommitment` are re-exported from +// `ant-protocol` above (single source of truth); their fields and wire size are +// documented there. // --------------------------------------------------------------------------- // Hashing helpers @@ -123,33 +75,8 @@ pub fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { *h.finalize().as_bytes() } -/// The auditor's pin: `BLAKE3(DOMAIN_COMMITMENT_HASH || postcard(commitment))`. -/// -/// Equal commitments produce equal hashes; any change to `root`, `key_count`, -/// peer ID, or signature changes the hash because postcard's canonical -/// encoding includes a length prefix for `signature`. The audit challenge -/// carries this value; the audit response must include a commitment that -/// hashes to the same value, defeating fresh-commitment substitution. -/// -/// Postcard encoding is the same canonical wire form the rest of the -/// replication protocol uses (`MessageCodec::encode`), so an encoded -/// commitment from a `NeighborSyncRequest` produces the same hash as the -/// same commitment received in an `AuditResponse`. -/// -/// # Errors -/// -/// Returns `None` only if postcard fails to serialize the commitment, which -/// in practice means the signature is somehow `> isize::MAX` bytes — not -/// reachable for ML-DSA-65 (3293 bytes). Callers may safely treat `None` as -/// a malformed commitment and drop it. -#[must_use] -pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> { - let serialized = postcard::to_allocvec(c).ok()?; - let mut h = Hasher::new(); - h.update(DOMAIN_COMMITMENT_HASH); - h.update(&serialized); - Some(*h.finalize().as_bytes()) -} +// `commitment_hash` is re-exported from `ant-protocol` above (single source of +// truth for the pin), so the paying client and the node compute the same pin. /// Canonical bytes the ML-DSA signature covers: the commitment fields /// minus the signature itself. @@ -474,48 +401,11 @@ pub fn sign_commitment( Ok(sig.to_bytes()) } -/// Verify a commitment's signature using the embedded `sender_public_key`. -/// -/// Returns `true` iff the signature is valid for `(root, key_count, -/// sender_peer_id, sender_public_key)` under `c.sender_public_key` and -/// [`DOMAIN_COMMITMENT`]. Returns `false` on key-format or signature-format -/// errors so the caller can simply drop the gossip. -/// -/// Verifying against the embedded key removes the need for an external -/// `PeerId → MlDsaPublicKey` lookup. The peer-id binding gate in -/// `ingest_peer_commitment` (and the auditor's `evaluate_subtree_structure`) -/// still ensures the embedded key belongs to the claimed peer. -#[must_use] -pub fn verify_commitment_signature(c: &StorageCommitment) -> bool { - let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key) - else { - return false; - }; - verify_commitment_signature_with_key(c, &public_key) -} - -/// Verify a commitment's signature against an externally provided key. -/// -/// Test-helper variant. Production code should use [`verify_commitment_signature`] -/// since the key is embedded in the commitment. -#[must_use] -pub fn verify_commitment_signature_with_key( - c: &StorageCommitment, - public_key: &MlDsaPublicKey, -) -> bool { - let payload = commitment_signed_payload( - &c.root, - c.key_count, - &c.sender_peer_id, - &c.sender_public_key, - ); - let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else { - return false; - }; - let dsa = ml_dsa_65(); - dsa.verify_with_context(public_key, &payload, &sig, DOMAIN_COMMITMENT) - .unwrap_or(false) -} +// `verify_commitment_signature` (embedded-key) is re-exported from +// `ant-protocol` above (single source of truth), so the paying client and the +// node accept exactly the same commitments. The externally-keyed variant was +// removed in the ADR-0003 move — it had no remaining callers once the embedded- +// key verify moved to `ant-protocol`. // --------------------------------------------------------------------------- // Errors @@ -546,6 +436,7 @@ pub enum CommitmentError { #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; + use saorsa_pqc::api::sig::MlDsaPublicKey; fn xn(byte: u8) -> XorName { [byte; 32] diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs index a7663db0..550affad 100644 --- a/src/replication/commitment_state.rs +++ b/src/replication/commitment_state.rs @@ -422,6 +422,34 @@ impl ResponderCommitmentState { Some(current) } + /// Atomically snapshot the current commitment to PIN IN A QUOTE and refresh + /// its answerability, under a single lock. Returns the live current + /// commitment, or `None` if there is no live current (never rotated, or + /// retired) — in which case the caller must quote the baseline with no pin. + /// + /// ADR-0003 ("quoting is advertising"): issuing a quote that prices against + /// the current commitment must extend that commitment's answerability + /// exactly as gossiping it does, so a recently-quoted pin stays resolvable + /// for its TTL and a peer auditing it cannot false-fail an honest node. + /// This deliberately mirrors [`Self::current_for_gossip`]: same atomic + /// snapshot-and-stamp, same TOCTOU-free guarantee that anything a quote can + /// pin is simultaneously retained. It refreshes the CURRENT commitment only + /// — a retired or merely-retained-but-not-current commitment is never + /// returned here, so quote traffic can never keep a stale fat commitment + /// alive (it can only be answered, via `lookup_by_hash`, until its own + /// gossip/quote stamp lapses). + #[must_use] + pub fn current_for_quote(&self) -> Option> { + let now = Instant::now(); + let mut guard = self.inner.write(); + if !guard.has_current { + return None; + } + let current = guard.slots.first().map(Arc::clone)?; + mark_gossiped_locked(&mut guard, current.cached_hash, now); + Some(current) + } + /// Expire retention purely by the wall clock, without building, signing, or /// rotating anything. Call once per rotation tick so a gossiped commitment's /// answerability deadline advances even when the rotation no-op guard @@ -512,6 +540,31 @@ impl ResponderCommitmentState { } } +/// ADR-0003: the responder commitment state is the quote generator's commitment +/// source. `current_binding_for_quote` snapshots the live current commitment's +/// `(key_count, pin)` and refreshes its answerability in one atomic step (via +/// [`ResponderCommitmentState::current_for_quote`]), so a quote that prices +/// against the current commitment keeps it answerable for its TTL. +impl crate::payment::quote::CommitmentSource for ResponderCommitmentState { + fn current_binding_for_quote(&self) -> Option { + self.current_for_quote() + .map(|built| crate::payment::quote::QuoteBinding { + key_count: built.commitment().key_count, + pin: built.hash(), + }) + } + + fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option> { + // rmp-encode the `StorageCommitment` itself — the EXACT form the storer's + // `index_valid_sidecars` deserializes (`rmp_serde::from_slice::`), + // so a sidecar shipped here resolves identically to one fetched via + // `GetCommitmentByPin`. Only retained pins resolve; a rotated-out pin + // yields `None` and the response simply carries no commitment. + let built = self.lookup_by_hash(&pin)?; + rmp_serde::to_vec(built.commitment()).ok() + } +} + /// Enforce retention as of `now`: first expire any gossip record older than /// `GOSSIP_ANSWERABILITY_TTL`, then keep the live current slot (only while /// `has_current`) and any slot whose hash is still among the unexpired @@ -1139,4 +1192,94 @@ mod tests { "gossiped commitment must survive ungossiped rebuilds" ); } + + // === ADR-0003: current_for_quote (quote-issuance answerability) === + + use crate::payment::quote::CommitmentSource; + + #[test] + fn current_for_quote_returns_current_binding_and_is_current_only() { + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let state = ResponderCommitmentState::new(); + + // No current yet -> baseline (None). + assert!(state.current_binding_for_quote().is_none()); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + state.mark_gossiped(h1); + let c2 = BuiltCommitment::build( + vec![(key(2), bh(2)), (key(3), bh(3))], + &peer_id, + &sk, + &pk_bytes, + ) + .unwrap(); + let h2 = c2.hash(); + state.rotate(c2); + + // current_for_quote returns the CURRENT (c2) binding, never the + // previous (c1) — a quote may pin only the live current commitment. + let binding = state + .current_binding_for_quote() + .expect("current binding present"); + assert_eq!( + binding.pin, h2, + "must bind the current commitment, not previous" + ); + assert_eq!(binding.key_count, 2, "current commitment's key count"); + assert_ne!( + binding.pin, h1, + "must never pin the retired/previous commitment" + ); + } + + #[test] + fn current_for_quote_refreshes_answerability() { + // Issuing a quote must refresh the current commitment's answerability, + // exactly like gossiping it (ADR-0003 "quoting is advertising"). + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let state = ResponderCommitmentState::new(); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + // NOT gossiped; instead "quote" it. The quote-issuance refresh must make + // it answerable just as a gossip emission would. + let binding = state.current_binding_for_quote().expect("binding"); + assert_eq!(binding.pin, h1); + assert!( + state.lookup_by_hash(&h1).is_some(), + "a quoted current pin must be answerable (issuance refreshed retention)" + ); + } + + #[test] + fn retired_current_cannot_be_quoted() { + // After retire_current (node has no responsible keys), there is no live + // current commitment, so current_for_quote yields baseline — a retired + // commitment can never be newly quoted. + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let state = ResponderCommitmentState::new(); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + state.mark_gossiped(h1); + state.retire_current(); + + assert!( + state.current_binding_for_quote().is_none(), + "a retired current commitment must not be quotable" + ); + // ...but it stays answerable for any in-flight pin until its TTL lapses. + assert!(state.lookup_by_hash(&h1).is_some()); + } } diff --git a/src/replication/config.rs b/src/replication/config.rs index 571c934f..63b1e83b 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -294,6 +294,68 @@ const _: () = assert!( /// sync, and the flip is one line. pub const TIMEOUT_EVICTION_ENABLED: bool = false; +/// Rollout gate for ADR-0003 quote-arithmetic enforcement. +/// +/// When `true`, the payment verifier rejects any quote whose signed price does +/// not lie exactly on the public pricing curve, i.e. there is no integer +/// `key_count` for which `calculate_price(key_count) == quote.price`. The check +/// is exact recomputation against the curve, never price-inversion (which +/// rounds), per ADR-0003's "never by inverting the price" rule. +/// +/// When `false`, the check still runs and logs every would-be rejection, but +/// does not reject — matching the ADR-0003 rollout: ship observe-only first, +/// enforce only once the fleet has upgraded. Off-curve quotes are honest +/// errors, not signs of an old node (every honest implementation derives its +/// price from the same public formula), so flipping this to `true` does not +/// risk evicting un-upgraded peers; it only catches modified nodes that minted +/// prices off the curve. The flip is independent of [`TIMEOUT_EVICTION_ENABLED`]. +/// +/// This is a reject-only gate: an off-curve quote produces no trust evidence +/// and no audit, per ADR-0003 ("an off-curve quote is reject-only"). It does +/// NOT gate the quote/commitment mismatch trust report — see +/// [`QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED`] for that, kept separate because +/// the two have different ADR-0003 contracts (this one rejects with no trust +/// action; that one reports a deterministic contradiction to the trust engine). +pub const QUOTE_ARITHMETIC_RECHECK_ENABLED: bool = false; + +/// Rollout gate for ADR-0003 quote/commitment **mismatch** trust reporting. +/// +/// When a client-put quote's signed `committed_key_count` contradicts the +/// `key_count` of the commitment it pinned (resolved from the gossip cache, a +/// sidecar, or a fetch), that is two artifacts signed by the same key that +/// contradict each other — a deterministic, first-occurrence misbehaviour. When +/// `true`, the cross-check reports it to the trust engine as an +/// `ApplicationFailure` (the same lane as a confirmed deterministic audit +/// failure, NOT the timeout silence lane). When `false`, the cross-check only +/// logs the would-be report (observe-only). +/// +/// Kept independent of [`QUOTE_ARITHMETIC_RECHECK_ENABLED`] (which is +/// reject-only with no trust action) and of [`TIMEOUT_EVICTION_ENABLED`] (which +/// gates only the unanswerable-pin silence lane): a confirmed mismatch is not a +/// silence and not a mere off-curve price, so it deserves its own dial. +pub const QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED: bool = false; + +/// ADR-0003: max unresolved quote pins to fetch per payment bundle. +/// +/// A bundle has at most `CLOSE_GROUP_SIZE` quotes; capping fetches per bundle +/// bounds the amplification a single malicious upload (many distinct unknown +/// pins) can drive. Excess unresolved pins in one bundle are dropped (left +/// unresolved, i.e. graced — the audit funnel still catches a serving cheater). +pub const MAX_PIN_FETCHES_PER_BUNDLE: usize = 3; + +/// ADR-0003: capacity of the per-peer negative cache for unresolved pin fetches. +/// +/// A pin that a peer answered `NotRetained` (or that timed out) is remembered so +/// repeated bundles citing the same unknown pin don't re-fetch. +pub const PIN_FETCH_NEGATIVE_CACHE_CAPACITY: usize = 4096; + +/// ADR-0003: timeout for a `GetCommitmentByPin` fetch. +/// +/// Sized for a single small round-trip plus the responder's bounded in-memory +/// lookup; a fetch is off the payment hot path, so this only bounds the +/// background cross-check. +pub const PIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); + /// Verification request timeout (per-batch). const VERIFICATION_REQUEST_TIMEOUT_SECS: u64 = 15; /// Verification request timeout (per-batch). @@ -343,6 +405,22 @@ pub const AUDIT_ON_GOSSIP_PROBABILITY: f64 = 0.2; /// seconds. Bounds how often any one peer is audited regardless of gossip rate. pub const AUDIT_ON_GOSSIP_COOLDOWN_SECS: u64 = 30 * 60; +/// ADR-0003: first-audit drainer retry cadence for cooldown-pending pins. +/// +/// How often the drainer retries pins it kept pending because their peer was on +/// cooldown. Finer than the cooldown itself so a monetized commitment is +/// first-audited promptly after its peer's window reopens; the retry just +/// re-checks a small per-peer map, so the tick is cheap. +pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_secs(60); + +/// ADR-0003: max monetized-pin events the first-audit drainer drains from its +/// channel per wake before it must run the audit-launch phase. +/// +/// Bounds the synchronous `try_recv` batch so a sustained producer flood cannot +/// starve audit launching by spinning in the drain loop forever. After this many +/// events the drainer processes audits, then loops back to drain more. +pub const FIRST_AUDIT_DRAIN_BATCH: usize = 64; + /// Number of subtree leaves spot-checked against real chunk bytes per audit /// (ADR-0002 real-bytes layer). /// diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 0f6394a4..47e122d1 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -33,10 +33,12 @@ pub mod subtree; pub mod types; use std::collections::{HashMap, HashSet}; +use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; +use lru::LruCache; use std::pin::Pin; use crate::logging::{debug, error, info, warn}; @@ -311,6 +313,13 @@ pub struct ReplicationEngine { /// When present, `start()` spawns a drainer task that calls /// `replicate_fresh` for each event. fresh_write_rx: Option>, + /// ADR-0003: sender the payment verifier clones to surface monetized pins + /// for a deterministic first audit. The matching receiver is drained by + /// `start_first_audit_drainer`. + monetized_pin_tx: mpsc::UnboundedSender, + /// ADR-0003: receiver half of the monetized-pin channel, taken by + /// `start_first_audit_drainer`. + monetized_pin_rx: Option>, /// Shutdown token. shutdown: CancellationToken, /// Background task handles. @@ -346,6 +355,9 @@ impl ReplicationEngine { let initial_neighbors = NeighborSyncState::new_cycle(Vec::new()); let config = Arc::new(config); + // ADR-0003: monetized-pin channel (verifier -> first-audit drainer). + let (monetized_pin_tx, monetized_pin_rx) = mpsc::unbounded_channel(); + Ok(Self { config: Arc::clone(&config), p2p_node, @@ -373,11 +385,21 @@ impl ReplicationEngine { audit_responder_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_AUDIT_RESPONSES)), audit_responder_inflight: Arc::new(RwLock::new(HashMap::new())), fresh_write_rx: Some(fresh_write_rx), + monetized_pin_tx, + monetized_pin_rx: Some(monetized_pin_rx), shutdown, task_handles: Vec::new(), }) } + /// ADR-0003: a sender the payment verifier uses to surface monetized pins + /// (commitments that backed a payment) for a deterministic first audit. + /// Cloneable; the engine drains the matching receiver. + #[must_use] + pub fn monetized_pin_sender(&self) -> mpsc::UnboundedSender { + self.monetized_pin_tx.clone() + } + /// Get a reference to the `PaidList`. #[must_use] pub fn paid_list(&self) -> &Arc { @@ -500,6 +522,9 @@ impl ReplicationEngine { self.start_verification_worker(); self.start_bootstrap_sync(dht_events); self.start_fresh_write_drainer(); + // ADR-0003: deterministic first audit of commitments that backed a + // payment (surfaced by the verifier cross-check). + self.start_first_audit_drainer(); info!( "Replication engine started with {} background tasks", @@ -620,6 +645,158 @@ impl ReplicationEngine { self.task_handles.push(handle); } + /// ADR-0003: drain monetized pins surfaced by the verifier cross-check and + /// run a **deterministic first audit** of each — the same `run_subtree_audit` + /// as the gossip path, under the same per-peer cooldown and concurrency + /// caps, but with the probability lottery BYPASSED (the lottery governs + /// re-audits only). Deduped by pin via a bounded set so a pin gets one + /// deterministic first audit; a peer minting fresh pins faster than the + /// cooldown forfeits the older ones' coverage, never the newest's (the + /// channel surfaces newest pins as they are monetized). + fn start_first_audit_drainer(&mut self) { + let Some(mut rx) = self.monetized_pin_rx.take() else { + return; + }; + let gossip_audit = GossipAuditTrigger { + p2p_node: Arc::clone(&self.p2p_node), + config: Arc::clone(&self.config), + recent_provers: Arc::clone(&self.recent_provers), + sync_state: Arc::clone(&self.sync_state), + audit_timeout_strikes: Arc::clone(&self.audit_timeout_strikes), + cooldown: Arc::clone(&self.audit_on_gossip_cooldown), + }; + let shutdown = self.shutdown.clone(); + + let handle = tokio::spawn(async move { + // Bounded dedup of pins that have ALREADY been given their + // deterministic first audit. A pin is inserted ONLY when an audit is + // actually launched (never on a cooldown skip), so a pin skipped now + // can still be first-audited later. + let mut first_audited: LruCache<[u8; 32], ()> = LruCache::new( + NonZeroUsize::new(MAX_LAST_COMMITMENT_BY_PEER).unwrap_or(NonZeroUsize::MIN), + ); + // PERSISTENT pending queue: the most-recently-monetized pin per peer + // that has NOT yet been first-audited. A pin stays here until it is + // ACTUALLY first-audited (enters `first_audited`) — never removed for + // any weaker reason (e.g. a cooldown stamp), so an unaudited monetized + // pin is never silently forgotten. Newest-per-peer: a fresher pin for + // the same peer replaces the older one. Memory is bounded by an LRU: + // each entry needs a SETTLED on-chain payment, so the realistic count + // is tiny; the LRU is a pure DoS backstop that, only under an absurd + // flood, evicts the LEAST-RECENTLY-MONETIZED peer (the one most likely + // already superseded) — never the newest. + let mut pending: LruCache = LruCache::new( + NonZeroUsize::new(MAX_LAST_COMMITMENT_BY_PEER).unwrap_or(NonZeroUsize::MIN), + ); + // Periodic retry tick for pending (cooldown-blocked) pins. Created + // once; `Skip` so a backlog of missed ticks collapses to one. + let mut tick = tokio::time::interval(config::FIRST_AUDIT_RETRY_INTERVAL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + // Wake on: shutdown, a new monetized pin, OR a periodic tick so + // pending (cooldown-blocked) pins get retried once their window + // reopens even if no new pin arrives. + let drained_new = tokio::select! { + () = shutdown.cancelled() => break, + event = rx.recv() => match event { + Some(e) => { + // Newest-per-peer: a fresher pin replaces the older one, + // BUT only if it is not already first-audited — an + // already-audited duplicate must never overwrite an + // unaudited pending pin for the same peer (it would then + // be dropped as "done" and the unaudited pin lost). Cap + // the per-wake batch drain (FIRST_AUDIT_DRAIN_BATCH) so a + // sustained flood can't starve the audit-launch phase. + if !first_audited.contains(&e.pin) { + pending.put(e.peer, e); + } + let mut drained = 1usize; + while drained < config::FIRST_AUDIT_DRAIN_BATCH { + match rx.try_recv() { + Ok(e) => { + if !first_audited.contains(&e.pin) { + pending.put(e.peer, e); + } + drained += 1; + } + Err(_) => break, + } + } + true + } + None => break, + }, + _ = tick.tick() => false, + }; + let _ = drained_new; + + if pending.is_empty() { + continue; + } + + // Try to launch an audit for each pending peer; keep the ones + // still blocked by cooldown for the next tick. Drain into a vec + // first so we can re-insert the still-blocked ones afterwards + // (LruCache has no drain). `iter()` yields most- to least-recently- + // used; we reverse so re-inserting blocked entries below restores + // their relative recency (oldest re-put first → stays the eviction + // victim, newest stays most-recently-used). + let snapshot: Vec<(PeerId, MonetizedPinEvent)> = + pending.iter().rev().map(|(p, e)| (*p, *e)).collect(); + pending.clear(); + for (peer, event) in snapshot { + // Dedup: a pin already first-audited is dropped (done). + if first_audited.contains(&event.pin) { + continue; + } + // Cooldown: if the peer's per-peer audit window is closed, keep + // this pin pending and retry on a later tick once it reopens. + // We do NOT treat "cooldown closed" as "already audited" (a + // losing gossip lottery can stamp the window without auditing), + // so the pin stays pending until it gets a REAL first audit; it + // is only ever evicted by the LRU memory backstop above, which + // drops the least-recently-monetized peer, not this newest one. + { + let now = Instant::now(); + let mut map = gossip_audit.cooldown.write().await; + if !cooldown_allows_audit(&mut map, &peer, now) { + pending.put(peer, event); + continue; + } + } + // Audit is launching: now mark the pin first-audited. + first_audited.put(event.pin, ()); + let trigger = gossip_audit.clone(); + tokio::spawn(async move { + let credit = storage_commitment_audit::AuditCredit { + recent_provers: &trigger.recent_provers, + }; + let result = storage_commitment_audit::run_subtree_audit( + &trigger.p2p_node, + &trigger.config, + &event.peer, + event.pin, + event.key_count, + Some(&credit), + ) + .await; + handle_subtree_audit_result( + &result, + &trigger.p2p_node, + &trigger.sync_state, + &trigger.recent_provers, + &trigger.audit_timeout_strikes, + &trigger.config, + ) + .await; + }); + } + } + debug!("First-audit drainer shut down"); + }); + self.task_handles.push(handle); + } + #[allow(clippy::too_many_lines)] fn start_message_handler(&mut self) { let mut p2p_events = self.p2p_node.subscribe_events(); @@ -1870,6 +2047,41 @@ async fn handle_replication_message( }); Ok(()) } + ReplicationMessageBody::GetCommitmentByPin(ref request) => { + // ADR-0003: answer a commitment-by-pin fetch from the retained set + // only. `lookup_by_hash` is an allocation-light read over the + // bounded slot set; it returns the live current commitment or any + // still-answerable recently-gossiped/quoted one. A miss is reported + // as `NotRetained` (graced, never confirmed) rather than an error, + // so an aged-out pin can never brand an honest node. + // + // Reuse the audit-responder admission guard (global ceiling + per-peer + // cap) so a flood of fetches cannot drive unbounded commitment + // clone/encode/send work; over-limit is dropped, which the fetching + // peer graces exactly like a missed audit response. + let Some(_guard) = + admit_audit_responder(audit_responder_semaphore, audit_responder_inflight, source) + .await + else { + debug!("GetCommitmentByPin from {source} dropped: responder capacity reached"); + return Ok(()); + }; + let response = my_commitment_state.lookup_by_hash(&request.pin).map_or( + protocol::GetCommitmentByPinResponse::NotRetained { pin: request.pin }, + |built| protocol::GetCommitmentByPinResponse::Found { + commitment: built.commitment().clone(), + }, + ); + send_replication_response( + source, + p2p_node, + msg.request_id, + ReplicationMessageBody::GetCommitmentByPinResponse(response), + rr_message_id, + ) + .await; + Ok(()) + } // Response messages are handled by their respective request initiators. ReplicationMessageBody::FreshReplicationResponse(_) | ReplicationMessageBody::NeighborSyncResponse(_) @@ -1877,7 +2089,8 @@ async fn handle_replication_message( | ReplicationMessageBody::FetchResponse(_) | ReplicationMessageBody::AuditResponse(_) | ReplicationMessageBody::SubtreeAuditResponse(_) - | ReplicationMessageBody::SubtreeByteResponse(_) => Ok(()), + | ReplicationMessageBody::SubtreeByteResponse(_) + | ReplicationMessageBody::GetCommitmentByPinResponse(_) => Ok(()), } } @@ -4124,6 +4337,24 @@ struct AuditTarget { key_count: u32, } +/// ADR-0003: a commitment that backed a payment, surfaced by the payment +/// verifier's cross-check so it can receive a **deterministic first audit**. +/// +/// Sent from the verifier to the replication engine's first-audit drainer. The +/// drainer dedups by `pin` (a pin gets one deterministic first audit; later +/// audits of the same peer revert to the gossip lottery), orders most-recently- +/// monetized first, and runs the same `run_subtree_audit` under the same +/// per-peer cooldown and concurrency caps — only the lottery is bypassed. +#[derive(Debug, Clone, Copy)] +pub struct MonetizedPinEvent { + /// The peer whose commitment backed the payment. + pub peer: PeerId, + /// The pinned commitment hash. + pub pin: [u8; 32], + /// The committed key count (sizes the audit deadline). + pub key_count: u32, +} + /// Per-peer audit cooldown check-and-stamp (ADR-0002 "occasional surprise /// exams, keeps load low"). Returns `true` if `peer` may be audited now (and /// stamps `now`), `false` if it was audited within diff --git a/src/replication/protocol.rs b/src/replication/protocol.rs index 5058bab3..ec3392ad 100644 --- a/src/replication/protocol.rs +++ b/src/replication/protocol.rs @@ -125,6 +125,19 @@ pub enum ReplicationMessageBody { SubtreeByteChallenge(SubtreeByteChallenge), /// Response carrying the requested chunks' original bytes (round 2). SubtreeByteResponse(SubtreeByteResponse), + + // === Commitment fetch by pin (ADR-0003) === + // APPENDED at the end so postcard variant discriminants of all the + // pre-existing variants are unchanged — old nodes keep decoding every + // message they already understood; only these two new indices are unknown + // to them (and they never receive them, since old nodes never send the + // matching request). + /// Fetch a retained commitment by its pin (ADR-0003): used to resolve a + /// quote's `commitment_pin` when the sidecar is absent and the gossip cache + /// has no fresh copy. + GetCommitmentByPin(GetCommitmentByPin), + /// Response to [`Self::GetCommitmentByPin`]. + GetCommitmentByPinResponse(GetCommitmentByPinResponse), } // --------------------------------------------------------------------------- @@ -290,6 +303,41 @@ pub enum FetchResponse { }, } +// --------------------------------------------------------------------------- +// Commitment fetch by pin (ADR-0003) +// --------------------------------------------------------------------------- + +/// Request a retained commitment by its pin (commitment hash). +/// +/// ADR-0003: a storer cross-checking a quote whose `commitment_pin` it does not +/// already hold (no sidecar, no fresh gossip copy) fetches the signed +/// commitment so it can verify the binding and route the commitment into audit. +/// The responder answers only from its retained set, so this never forces a +/// node to reconstruct or re-sign anything. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetCommitmentByPin { + /// The commitment hash (pin) being resolved. + pub pin: [u8; 32], +} + +/// Response to [`GetCommitmentByPin`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GetCommitmentByPinResponse { + /// The pin resolved to a retained, signed commitment. + Found { + /// The signed commitment matching the requested pin. The fetcher + /// re-verifies its signature and peer binding before trusting it. + commitment: crate::replication::commitment::StorageCommitment, + }, + /// The pin is not among the responder's retained commitments (rotated/aged + /// out, or never held). ADR-0003 treats this as graced, never confirmed: + /// an unanswerable pin is indistinguishable from an honest crash-restart. + NotRetained { + /// Echo of the requested pin, for matching. + pin: [u8; 32], + }, +} + // --------------------------------------------------------------------------- // Audit Messages // --------------------------------------------------------------------------- diff --git a/src/replication/types.rs b/src/replication/types.rs index 59e6b417..923311c9 100644 --- a/src/replication/types.rs +++ b/src/replication/types.rs @@ -225,6 +225,38 @@ pub enum FailureEvidence { /// When this peer was first seen. first_seen: Instant, }, + /// ADR-0003: a quote's claimed committed key count contradicts the signed + /// commitment it pinned. The quote and the commitment are both signed by + /// the same key, so this is a deterministic, first-occurrence + /// contradiction — not bad luck — and lands in the same trust lane as a + /// confirmed deterministic audit failure. Reported only in the + /// client-put context (a replication receipt's pin has legitimately aged + /// out). The fields are the portable contradiction: any third party + /// holding the two signed artifacts can recompute it. + QuoteCommitmentMismatch { + /// The peer whose quote and commitment disagree. + peer: PeerId, + /// The commitment hash the quote pinned (identifies the signed + /// commitment artifact); equals `commitment.hash()`. + pinned_commitment: [u8; 32], + /// The key count the quote claimed (and priced against). + quoted_key_count: u32, + /// The key count the pinned commitment actually attests. + committed_key_count: u32, + /// The signed quote artifact that carries the contradicting + /// `committed_key_count` + pin, ML-DSA-signed by `peer`, as canonical + /// serialized bytes. Carried opaquely (rmp of a `PaymentQuote` on the + /// single-node path, or a `MerklePaymentCandidateNode` on the merkle + /// path) so the one variant serves both paths; a third party + /// deserializes and re-verifies it against `peer`. + quote_artifact: Vec, + /// The signed commitment artifact the quote pinned (ML-DSA-signed by + /// the same `peer`). Together with `quote_artifact`, this is the + /// PORTABLE evidence: any third party re-verifies both signatures and + /// recomputes `quoted_key_count != commitment.key_count` without + /// trusting the reporter. + commitment: Box, + }, } /// Reason for audit failure. diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 586b5178..2bb13eb4 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -75,15 +75,19 @@ impl AntProtocol { payment_verifier: Arc, quote_generator: Arc, ) -> Self { - // Keep the PaymentVerifier's paid-quote price floor and the - // QuoteGenerator's pricing wired to the same authoritative store used - // by this protocol handler. Both must read the same record count: the - // generator prices quotes from current_chunks(), and the verifier later - // checks the paid median quote against current_chunks(). Attaching both - // here makes the invariant automatic for every AntProtocol - // construction path, including tests and future startup variants. + // Wire the PaymentVerifier to the same authoritative store used by this + // protocol handler: it checks paid quotes against `current_chunks()` + // (the paid-quote price floor / residual freshness gate), so it must + // read the same record count this handler serves. Attaching here makes + // the invariant automatic for every AntProtocol construction path, + // including tests and future startup variants. + // + // ADR-0003: the QuoteGenerator no longer prices off `current_chunks()` + // — its price is bound to the live storage commitment (see + // `attach_commitment_source`, wired by the node once the replication + // engine exists), or baseline when none — so it is NOT attached to the + // store here. payment_verifier.attach_storage(Arc::clone(&storage)); - quote_generator.attach_storage(Arc::clone(&storage)); Self { storage, @@ -137,6 +141,52 @@ impl AntProtocol { Arc::clone(&self.payment_verifier) } + /// ADR-0003: attach the replication engine's commitment state as the quote + /// generator's commitment source, so quotes force their price from the live + /// storage commitment and refresh its answerability on issuance. + /// + /// Called once, after both the protocol and the replication engine exist + /// (the engine owns the [`ResponderCommitmentState`](crate::replication::commitment_state::ResponderCommitmentState)). + /// Until this is wired, the quote generator has no commitment source and + /// falls back to baseline (no-pin) pricing. + pub fn attach_commitment_source( + &self, + source: Arc, + ) { + self.quote_generator.attach_commitment_source(source); + } + + /// ADR-0003: return the proof with any commitment sidecars stripped, so a + /// replicated/persisted receipt carries only the pin and count (stored + /// proofs do not grow). Handles BOTH proof types — single-node and + /// merkle-batch — since both now carry sidecars. An unknown-tagged or + /// unparseable proof is returned unchanged. Best-effort: any + /// deserialize/reserialize failure returns the original bytes rather than + /// corrupting the proof. + fn strip_commitment_sidecars(proof: Vec) -> Vec { + use crate::payment::proof::{ + deserialize_merkle_proof, deserialize_single_node_proof, detect_proof_type, + serialize_merkle_proof, serialize_single_node_proof, ProofType, + }; + match detect_proof_type(&proof) { + Some(ProofType::SingleNode) => match deserialize_single_node_proof(&proof) { + Ok(mut parsed) if !parsed.commitment_sidecars.is_empty() => { + parsed.commitment_sidecars.clear(); + serialize_single_node_proof(&parsed).unwrap_or(proof) + } + _ => proof, // already sidecar-free, or unparseable: leave as-is + }, + Some(ProofType::Merkle) => match deserialize_merkle_proof(&proof) { + Ok(mut parsed) if !parsed.commitment_sidecars.is_empty() => { + parsed.commitment_sidecars.clear(); + serialize_merkle_proof(&parsed).unwrap_or(proof) + } + _ => proof, + }, + _ => proof, // unknown tag: nothing to strip + } + } + /// Handle an incoming request and produce a response. /// /// Decodes the raw message, processes it if it is a request variant, @@ -323,6 +373,16 @@ impl AntProtocol { // PUTs have no proof to forward, and the chunk would have // already replicated on the original write that carried one. if let (Some(ref tx), Some(proof)) = (&self.fresh_write_tx, request.payment_proof) { + // ADR-0003: strip any commitment sidecars before forwarding + // to replication — the ADR specifies persisted/replicated + // receipts "keep only the pin and count, so stored proofs do + // not grow." The sidecars were already consumed by this + // node's client-put cross-check; replicas resolve pins via + // gossip/fetch (their context is Replication, which skips the + // cross-check anyway). Best-effort: if sanitisation fails, + // fall back to the original proof rather than dropping the + // replication entirely. + let proof = Self::strip_commitment_sidecars(proof); // `request.content` is now `bytes::Bytes`; FreshWriteEvent // still carries the chunk as `Vec` for compatibility // with the replication wire format, so materialise once @@ -473,11 +533,22 @@ impl AntProtocol { .create_quote(request.address, data_size_usize, request.data_type) { Ok(quote) => { + // ADR-0003: ship the signed commitment the price was bound to + // alongside the quote ("the commitment arrived with the quote"), + // so the client can verify the binding before paying and forward + // it as a sidecar in the PUT bundle. Only a commitment-bound + // quote pins anything; a baseline `(0, None)` quote carries no + // commitment. A pin that has rotated out resolves to `None` and + // the client falls back to gossip/fetch. + let commitment = quote + .commitment_pin + .and_then(|pin| self.quote_generator.commitment_blob_for_pin(pin)); // Serialize the quote match rmp_serde::to_vec("e) { Ok(quote_bytes) => ChunkQuoteResponse::Success { quote: quote_bytes, already_stored, + commitment, }, Err(e) => ChunkQuoteResponse::Error(ProtocolError::QuoteFailed(format!( "Failed to serialize quote: {e}" @@ -521,14 +592,23 @@ impl AntProtocol { request.data_type, request.merkle_payment_timestamp, ) { - Ok(candidate_node) => match rmp_serde::to_vec(&candidate_node) { - Ok(bytes) => MerkleCandidateQuoteResponse::Success { - candidate_node: bytes, - }, - Err(e) => MerkleCandidateQuoteResponse::Error(ProtocolError::QuoteFailed(format!( - "Failed to serialize merkle candidate node: {e}" - ))), - }, + Ok(candidate_node) => { + // ADR-0003: ship the signed commitment this candidate priced + // against, so the client can verify the binding before paying + // and forward it as a sidecar. Baseline candidates ship none. + let commitment = candidate_node + .commitment_pin + .and_then(|pin| self.quote_generator.commitment_blob_for_pin(pin)); + match rmp_serde::to_vec(&candidate_node) { + Ok(bytes) => MerkleCandidateQuoteResponse::Success { + candidate_node: bytes, + commitment, + }, + Err(e) => MerkleCandidateQuoteResponse::Error(ProtocolError::QuoteFailed( + format!("Failed to serialize merkle candidate node: {e}"), + )), + } + } Err(e) => { MerkleCandidateQuoteResponse::Error(ProtocolError::QuoteFailed(e.to_string())) } @@ -1082,7 +1162,7 @@ mod tests { assert_eq!(response.request_id, 600); match response.body { ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Success { candidate_node }, + MerkleCandidateQuoteResponse::Success { candidate_node, .. }, ) => { let candidate: MerklePaymentCandidateNode = rmp_serde::from_slice(&candidate_node).expect("deserialize candidate node"); @@ -1290,4 +1370,34 @@ mod tests { (full={price_full:?}, after={price_after:?})" ); } + + /// ADR-0003: `strip_commitment_sidecars` removes sidecars from a single-node + /// proof before replication/persistence (stored proofs do not grow), and is + /// a no-op on a sidecar-free proof. + #[test] + fn strip_commitment_sidecars_clears_single_node_sidecars() { + use crate::payment::proof::{ + deserialize_single_node_proof, serialize_single_node_proof, PaymentProof, + }; + use evmlib::ProofOfPayment; + + let with_sidecars = PaymentProof { + proof_of_payment: ProofOfPayment { + peer_quotes: vec![], + }, + tx_hashes: vec![], + commitment_sidecars: vec![vec![1u8; 16], vec![2u8; 16]], + }; + let bytes = serialize_single_node_proof(&with_sidecars).expect("serialize"); + let stripped = AntProtocol::strip_commitment_sidecars(bytes); + let parsed = deserialize_single_node_proof(&stripped).expect("deserialize stripped"); + assert!( + parsed.commitment_sidecars.is_empty(), + "sidecars must be stripped before replication" + ); + + // Idempotent: stripping an already-sidecar-free proof returns it intact. + let again = AntProtocol::strip_commitment_sidecars(stripped.clone()); + assert_eq!(again, stripped, "stripping a sidecar-free proof is a no-op"); + } } diff --git a/tests/e2e/merkle_payment.rs b/tests/e2e/merkle_payment.rs index 960fc988..df01e219 100644 --- a/tests/e2e/merkle_payment.rs +++ b/tests/e2e/merkle_payment.rs @@ -213,7 +213,8 @@ fn build_candidate_nodes(timestamp: u64) -> [MerklePaymentCandidateNode; CANDIDA let price = Amount::from(1024u64); #[allow(clippy::cast_possible_truncation)] let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); + let msg = + MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp, 0, &None); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); @@ -222,6 +223,8 @@ fn build_candidate_nodes(timestamp: u64) -> [MerklePaymentCandidateNode; CANDIDA price, reward_address, merkle_payment_timestamp: timestamp, + committed_key_count: 0, + commitment_pin: None, signature, } }) diff --git a/tests/e2e/security_attacks.rs b/tests/e2e/security_attacks.rs index 528609b4..8b45a36a 100644 --- a/tests/e2e/security_attacks.rs +++ b/tests/e2e/security_attacks.rs @@ -187,6 +187,7 @@ async fn test_attack_valid_msgpack_empty_quotes() -> Result<(), Box Date: Tue, 30 Jun 2026 15:45:52 +0200 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20renumber=20ADR-0003=20=E2=86=92=20A?= =?UTF-8?q?DR-0004=20(avoid=20collision=20with=20full-node-shunning=20ADR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...DR-0004-commitment-bound-quote-pricing.md} | 2 +- ...s.md => ADR-0004-implementation-slices.md} | 8 +- src/node.rs | 6 +- src/payment/pricing.rs | 2 +- src/payment/quote.rs | 36 ++++---- src/payment/verifier.rs | 90 +++++++++---------- src/replication/commitment.rs | 4 +- src/replication/commitment_state.rs | 8 +- src/replication/config.rs | 22 ++--- src/replication/mod.rs | 16 ++-- src/replication/protocol.rs | 10 +-- src/replication/types.rs | 2 +- src/storage/handler.rs | 14 +-- 13 files changed, 110 insertions(+), 110 deletions(-) rename docs/adr/{ADR-0003-commitment-bound-quote-pricing.md => ADR-0004-commitment-bound-quote-pricing.md} (99%) rename docs/adr/{ADR-0003-implementation-slices.md => ADR-0004-implementation-slices.md} (96%) diff --git a/docs/adr/ADR-0003-commitment-bound-quote-pricing.md b/docs/adr/ADR-0004-commitment-bound-quote-pricing.md similarity index 99% rename from docs/adr/ADR-0003-commitment-bound-quote-pricing.md rename to docs/adr/ADR-0004-commitment-bound-quote-pricing.md index 8daa3910..7f5bcd10 100644 --- a/docs/adr/ADR-0003-commitment-bound-quote-pricing.md +++ b/docs/adr/ADR-0004-commitment-bound-quote-pricing.md @@ -1,4 +1,4 @@ -# ADR-0003: Commitment-bound quote pricing +# ADR-0004: Commitment-bound quote pricing - **Status:** Proposed - **Date:** 2026-06-12 diff --git a/docs/adr/ADR-0003-implementation-slices.md b/docs/adr/ADR-0004-implementation-slices.md similarity index 96% rename from docs/adr/ADR-0003-implementation-slices.md rename to docs/adr/ADR-0004-implementation-slices.md index 741efed7..1f18a286 100644 --- a/docs/adr/ADR-0003-implementation-slices.md +++ b/docs/adr/ADR-0004-implementation-slices.md @@ -1,8 +1,8 @@ -# ADR-0003 implementation slicing +# ADR-0004 implementation slicing -This file tracks the slicing strategy used to ship ADR-0003 incrementally inside +This file tracks the slicing strategy used to ship ADR-0004 incrementally inside ant-node alone, while the multi-repo evmlib breaking change ripens. The ADR -itself (`ADR-0003-commitment-bound-quote-pricing.md`) describes the end state; +itself (`ADR-0004-commitment-bound-quote-pricing.md`) describes the end state; this document describes the order in which the end state lands. The constraint that drives the slicing: `PaymentQuote`, `ProofOfPayment`, @@ -10,7 +10,7 @@ The constraint that drives the slicing: `PaymentQuote`, `ProofOfPayment`, flow into the on-chain `payForQuotes` interface. Adding signed fields to `PaymentQuote` is therefore a coordinated four-repo release (`evmlib` → `ant-protocol` → `ant-client` → `ant-node`). Until that lands, -every part of ADR-0003 that does NOT require new signed quote fields can — +every part of ADR-0004 that does NOT require new signed quote fields can — and should — ship behind the rollout const the ADR's "Rollout" section already specifies. diff --git a/src/node.rs b/src/node.rs index 337d2380..fcd1cb27 100644 --- a/src/node.rs +++ b/src/node.rs @@ -161,7 +161,7 @@ impl NodeBuilder { .await { Ok(engine) => { - // ADR-0003: wire the engine's commitment state as the + // ADR-0004: wire the engine's commitment state as the // quote generator's commitment source so quotes force // their price from the live storage commitment. Done // here because the engine owns the commitment state and @@ -170,13 +170,13 @@ impl NodeBuilder { let concrete = Arc::clone(engine.commitment_state()); let source: Arc = concrete; protocol.attach_commitment_source(source); - // ADR-0003: share the engine's gossip commitment + // ADR-0004: share the engine's gossip commitment // cache with the verifier so the cross-check can // resolve quote pins against neighbours' commitments. protocol .payment_verifier_arc() .attach_commitment_cache(Arc::clone(engine.last_commitment_by_peer())); - // ADR-0003: give the verifier the monetized-pin sender so + // ADR-0004: give the verifier the monetized-pin sender so // commitments that back a payment get a deterministic // first audit from the engine's drainer. protocol diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index 5fee7954..40563221 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -1,6 +1,6 @@ //! Quadratic pricing with a baseline floor. //! -//! ADR-0003: the pricing formula is now the **single source of truth** in +//! ADR-0004: the pricing formula is now the **single source of truth** in //! `ant-protocol` (`ant_protocol::payment::pricing`), so the node (when pricing //! a quote) and the client (when verifying the forced price before paying) //! compute byte-for-byte identical prices and can never drift. This module diff --git a/src/payment/quote.rs b/src/payment/quote.rs index 826c41f7..f7232269 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -27,7 +27,7 @@ pub type XorName = [u8; 32]; /// Signing function type that takes bytes and returns a signature. pub type SignFn = Box Vec + Send + Sync>; -/// The commitment binding a quote prices against (ADR-0003). +/// The commitment binding a quote prices against (ADR-0004). /// /// `key_count` is the leaf count of the pinned commitment and the sole input to /// the price formula; `pin` is that commitment's hash. A quote carries both, @@ -41,7 +41,7 @@ pub struct QuoteBinding { pub pin: [u8; 32], } -/// Source of the live storage commitment a quote prices against (ADR-0003). +/// Source of the live storage commitment a quote prices against (ADR-0004). /// /// Implemented by the responder-side commitment state. Decouples /// [`QuoteGenerator`] from replication internals: the generator only needs the @@ -54,7 +54,7 @@ pub trait CommitmentSource: Send + Sync { /// atomically. `None` if there is no live current commitment. fn current_binding_for_quote(&self) -> Option; - /// ADR-0003: the serialized signed commitment for `pin`, if it is still + /// ADR-0004: the serialized signed commitment for `pin`, if it is still /// retained, so the quote response can ship it as a sidecar ("the commitment /// arrived with the quote"). Returns the same canonical bytes a peer would /// receive via `GetCommitmentByPin`, so the client's pin match is identical @@ -77,11 +77,11 @@ pub struct QuoteGenerator { /// `record_store` / `resync_records` accounting surface the storage handler /// drives. /// - /// ADR-0003: this is NO LONGER a pricing input. A quote's price is bound to + /// ADR-0004: this is NO LONGER a pricing input. A quote's price is bound to /// the live storage commitment via [`Self::commitment_source`] (or baseline /// when none); the on-disk/side record count no longer sets the price. metrics_tracker: QuotingMetricsTracker, - /// ADR-0003 commitment source: the live storage commitment the price is + /// ADR-0004 commitment source: the live storage commitment the price is /// bound to. When attached, a quote prices against the committed /// (responsible) key count and pins that commitment, refreshing its /// answerability on issuance. `None` until [`Self::attach_commitment_source`] @@ -115,7 +115,7 @@ impl QuoteGenerator { } } - /// Attach the ADR-0003 commitment source so quotes bind their price to the + /// Attach the ADR-0004 commitment source so quotes bind their price to the /// node's live storage commitment. /// /// Idempotent: calling twice replaces the handle. Uses interior mutability @@ -126,10 +126,10 @@ impl QuoteGenerator { /// (no-pin) quotes. pub fn attach_commitment_source(&self, source: Arc) { *self.commitment_source.write() = Some(source); - debug!("QuoteGenerator: ADR-0003 commitment source attached"); + debug!("QuoteGenerator: ADR-0004 commitment source attached"); } - /// ADR-0003: the serialized signed commitment for `pin`, so the quote + /// ADR-0004: the serialized signed commitment for `pin`, so the quote /// response can ship it as a sidecar. `None` when no commitment source is /// attached (baseline / pre-rotation / tests) or the pin is no longer /// retained — in which case the response carries no commitment and the @@ -141,19 +141,19 @@ impl QuoteGenerator { source.and_then(|src| src.commitment_blob_for_pin(pin)) } - /// Resolve the ADR-0003 pricing inputs a quote should carry, refreshing the + /// Resolve the ADR-0004 pricing inputs a quote should carry, refreshing the /// pinned commitment's answerability as a side effect. /// /// Returns `(committed_key_count, commitment_pin, price_count)`: /// - with a live commitment, the price is driven by the committed key count - /// and the quote pins that commitment (the ADR-0003 forced price); + /// and the quote pins that commitment (the ADR-0004 forced price); /// - with no commitment source or no live current commitment, the node /// emits a true **baseline** quote: `(0, None)` priced at /// `calculate_price(0)`. /// /// Critically, the no-commitment branch prices at `0`, NOT at the on-disk /// record count. A `(committed_key_count = 0, commitment_pin = None)` quote - /// is the canonical baseline shape, and ADR-0003's forced-price rule binds + /// is the canonical baseline shape, and ADR-0004's forced-price rule binds /// that shape to `calculate_price(0)`. Pricing the no-pin quote off the disk /// count would mint a `(0, None, price > baseline)` quote — a shape a /// modified node could forge to charge above baseline while carrying no @@ -248,7 +248,7 @@ impl QuoteGenerator { let timestamp = SystemTime::now(); - // ADR-0003 forced price: when a live commitment exists, the price is a + // ADR-0004 forced price: when a live commitment exists, the price is a // deterministic function of its committed (responsible) key count, and // the quote pins that commitment (refreshing its answerability). Absent // a commitment source — observe-only, pre-first-rotation, or unit tests @@ -352,13 +352,13 @@ impl QuoteGenerator { .as_ref() .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?; - // ADR-0003 forced price for the merkle-batch candidate, mirroring the + // ADR-0004 forced price for the merkle-batch candidate, mirroring the // single-node path: bind to the live commitment when present, else // baseline with no pin. let (committed_key_count, commitment_pin, price_count) = self.resolve_quote_pricing(); let price = calculate_price(price_count); - // ADR-0003: sign the commitment binding into the merkle candidate + // ADR-0004: sign the commitment binding into the merkle candidate // payload too (5-field `bytes_to_sign`), so a count/pin mismatch is // genuine same-key-signed evidence. ant-protocol verifies this same // 5-field message. @@ -492,10 +492,10 @@ mod tests { } } - /// ADR-0003 forced price (single-node): with a commitment source attached, + /// ADR-0004 forced price (single-node): with a commitment source attached, /// the quote price is exactly `calculate_price(committed_key_count)`, the /// quote carries that count, and it pins the commitment hash. This replaces - /// the pre-ADR-0003 "price tracks on-disk count" behaviour — pricing is now + /// the pre-ADR-0004 "price tracks on-disk count" behaviour — pricing is now /// bound to the signed commitment, not the raw store count. #[test] fn test_forced_price_binds_to_commitment() { @@ -530,7 +530,7 @@ mod tests { assert_eq!(quote.commitment_pin, Some(pin), "quote pins the commitment"); } - /// ADR-0003 baseline: with NO commitment source (fresh node, pre first + /// ADR-0004 baseline: with NO commitment source (fresh node, pre first /// rotation), the quote is the canonical baseline shape — `(0, None)` priced /// at `calculate_price(0)` — NOT priced off the on-disk count. A node can /// only charge baseline until it has a commitment it can be audited against. @@ -822,7 +822,7 @@ mod tests { // Verify the timestamp was set correctly assert_eq!(candidate.merkle_payment_timestamp, timestamp); - // ADR-0003: with no commitment source attached, the merkle candidate is + // ADR-0004: with no commitment source attached, the merkle candidate is // a baseline quote — price `calculate_price(0)`, count 0, no pin — // regardless of the side counter. Pricing is bound to the commitment, // not the metrics tracker. diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 7d2470fb..d71a31e9 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -175,7 +175,7 @@ pub enum PaymentStatus { PaymentVerified, } -/// Outcome of the ADR-0003 quote-vs-commitment cross-check (see +/// Outcome of the ADR-0004 quote-vs-commitment cross-check (see /// [`PaymentVerifier::cross_check_binding`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CrossCheck { @@ -211,7 +211,7 @@ impl PaymentStatus { /// Default capacity for the merkle pool cache (number of pool hashes to cache). const DEFAULT_POOL_CACHE_CAPACITY: usize = 1_000; -/// ADR-0003: max commitment sidecars processed per bundle. A legitimate bundle +/// ADR-0004: max commitment sidecars processed per bundle. A legitimate bundle /// carries at most one commitment per quote/candidate — `CANDIDATES_PER_POOL` /// (16) is the larger of the single-node (`CLOSE_GROUP_SIZE` = 7) and merkle /// cases, so it covers both. Excess sidecars from a malicious client are @@ -219,7 +219,7 @@ const DEFAULT_POOL_CACHE_CAPACITY: usize = 1_000; const MAX_SIDECARS_PER_BUNDLE: usize = evmlib::merkle_batch_payment::CANDIDATES_PER_POOL; /// Shared handle to the replication engine's gossip commitment cache -/// (`last_commitment_by_peer`), used by the ADR-0003 cross-check to resolve a +/// (`last_commitment_by_peer`), used by the ADR-0004 cross-check to resolve a /// quote's pin against a neighbour's recently gossiped commitment. A `tokio` /// `RwLock` to match the engine's; read with `.await` on the async path. type CommitmentCache = Arc< @@ -228,7 +228,7 @@ type CommitmentCache = Arc< >, >; -/// Per-`(peer, pin)` negative cache for unresolved ADR-0003 pin fetches: a pin a +/// Per-`(peer, pin)` negative cache for unresolved ADR-0004 pin fetches: a pin a /// peer answered `NotRetained` (or that timed out) is remembered so repeated /// bundles don't re-fetch it. Behind an `Arc` so the detached fetch task owns a /// handle without borrowing the verifier. @@ -291,7 +291,7 @@ pub struct PaymentVerifier { /// [`P2PNode`]; set via [`Self::set_peer_id_for_tests`] so unit tests can /// drive the freshness logic without wiring a real `P2PNode`. test_peer_id_override: RwLock>, - /// ADR-0003 gossip commitment cache, shared with the replication engine + /// ADR-0004 gossip commitment cache, shared with the replication engine /// (`last_commitment_by_peer`). The cross-check resolves a quote's /// `commitment_pin` against the neighbour's most recently gossiped /// commitment held here, *only if seen within the answerability TTL*; @@ -300,14 +300,14 @@ pub struct PaymentVerifier { /// async verification path. `None` until [`Self::attach_commitment_cache`] /// (unit tests, or pre-replication startup). commitment_cache: RwLock>, - /// ADR-0003 negative cache for unresolved pin fetches: a `(peer, pin)` that + /// ADR-0004 negative cache for unresolved pin fetches: a `(peer, pin)` that /// resolved to `NotRetained` or timed out is remembered here so repeated /// bundles citing the same unknown pin don't re-fetch (bounding the /// amplification an attacker can drive). Keyed by `(PeerId, pin)`. Behind an /// `Arc` so the detached background fetch task (which runs off the payment /// hot path) can read and update it without borrowing the verifier. pin_fetch_negative_cache: PinFetchNegativeCache, - /// ADR-0003: sender to surface monetized pins (commitments that backed a + /// ADR-0004: sender to surface monetized pins (commitments that backed a /// payment) to the replication engine's deterministic first-audit drainer. /// `None` until [`Self::attach_monetized_pin_sender`] (unit tests, or /// pre-replication startup), in which case no first audit is scheduled. @@ -446,7 +446,7 @@ impl PaymentVerifier { } } - /// Attach the ADR-0003 monetized-pin sender (the replication engine's + /// Attach the ADR-0004 monetized-pin sender (the replication engine's /// first-audit drainer channel) so the cross-check can route commitments /// that backed a payment into a deterministic first audit. Idempotent; /// absent (unit tests / pre-replication) no first audit is scheduled. @@ -455,10 +455,10 @@ impl PaymentVerifier { tx: tokio::sync::mpsc::UnboundedSender, ) { *self.monetized_pin_tx.write() = Some(tx); - debug!("PaymentVerifier: ADR-0003 monetized-pin sender attached"); + debug!("PaymentVerifier: ADR-0004 monetized-pin sender attached"); } - /// Attach the ADR-0003 gossip commitment cache (the replication engine's + /// Attach the ADR-0004 gossip commitment cache (the replication engine's /// `last_commitment_by_peer`) so the cross-check can resolve a quote's /// `commitment_pin` against the neighbour's recently gossiped commitment. /// @@ -468,7 +468,7 @@ impl PaymentVerifier { /// and falls back to fetch/skip — never a penalty. pub fn attach_commitment_cache(&self, cache: CommitmentCache) { *self.commitment_cache.write() = Some(cache); - debug!("PaymentVerifier: ADR-0003 commitment cache attached"); + debug!("PaymentVerifier: ADR-0004 commitment cache attached"); } /// Attach the node's [`P2PNode`] handle so paid-quote verification can @@ -843,7 +843,7 @@ impl PaymentVerifier { } Self::validate_quote_structure(payment)?; - // ADR-0003: re-run the `price == calculate_price(committed_key_count)` + // ADR-0004: re-run the `price == calculate_price(committed_key_count)` // arithmetic/binding check on EVERY quote in the bundle (all single-node // quotes), per the ADR's "every storer re-runs the // price-equals-formula-of-count check on every quote in the bundle" @@ -884,13 +884,13 @@ impl PaymentVerifier { ))); } - // ADR-0003 observe-only telemetry: log off-curve quotes only AFTER the + // ADR-0004 observe-only telemetry: log off-curve quotes only AFTER the // paid (median) quote's ML-DSA-65 signature has verified above, so // unauthenticated senders cannot poison rollout logs. In enforce mode // `validate_quote_arithmetic` already rejected; this is a no-op there. Self::log_off_curve_single_node(payment); - // ADR-0003 cross-check + first-audit enqueue (ClientPut only) runs ONLY + // ADR-0004 cross-check + first-audit enqueue (ClientPut only) runs ONLY // after on-chain payment verification has SUCCEEDED above, so an unpaid // (but signed) bundle can never enqueue audits or drive pin fetches — // closing the free-amplification path. Fresh client-put bundles only. @@ -1190,7 +1190,7 @@ impl PaymentVerifier { Ok(()) } - /// ADR-0003: enforce that every quoted price lies exactly on the public + /// ADR-0004: enforce that every quoted price lies exactly on the public /// pricing curve. /// /// **Scope** (this slice): canonicality only. The gate proves the price is @@ -1218,17 +1218,17 @@ impl PaymentVerifier { /// ([`Self::validate_quote_arithmetic`]) and all 16 merkle candidates /// ([`Self::validate_merkle_candidate_arithmetic`]) — because the rule /// "every storer re-runs the price-equals-formula-of-count check on every - /// quote in the bundle" (ADR-0003) needs no peer-specific state and depends + /// quote in the bundle" (ADR-0004) needs no peer-specific state and depends /// only on the bundle itself, so every honest storer reaches the same /// verdict with no split-brain risk. /// - /// **Reject-only**, per ADR-0003: no trust evidence is emitted, no audit + /// **Reject-only**, per ADR-0004: no trust evidence is emitted, no audit /// is scheduled. The rejection is the consequence. The gate is /// rollout-gated by /// [`crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED`]; when /// `false`, off-curve quotes are accepted and only telemetered /// ([`Self::log_off_curve_single_node`] / - /// [`Self::log_off_curve_merkle`]), matching ADR-0003's observe-only + /// [`Self::log_off_curve_merkle`]), matching ADR-0004's observe-only /// rollout. Telemetry is invoked **after** ML-DSA-65 signature /// verification so unauthenticated senders cannot poison the rollout /// logs. @@ -1239,14 +1239,14 @@ impl PaymentVerifier { for (encoded_peer_id, quote) in &payment.peer_quotes { if let Some(detail) = Self::quote_arithmetic_violation(quote) { return Err(Error::Payment(format!( - "ADR-0003 off-curve quote rejected for peer {encoded_peer_id:?}: {detail}" + "ADR-0004 off-curve quote rejected for peer {encoded_peer_id:?}: {detail}" ))); } } Ok(()) } - /// The ADR-0003 forced-price rule for a single quote, returning a human + /// The ADR-0004 forced-price rule for a single quote, returning a human /// diagnostic iff the quote violates it. Shared by single-node quotes and /// merkle candidates via [`Self::binding_violation`]. /// @@ -1269,7 +1269,7 @@ impl PaymentVerifier { ) } - /// The shared ADR-0003 binding rule over a `(committed_key_count, + /// The shared ADR-0004 binding rule over a `(committed_key_count, /// commitment_pin, price)` triple, used for both quote types. /// /// Enforces, in order: @@ -1319,7 +1319,7 @@ impl PaymentVerifier { } } - /// Pure ADR-0003 cross-check: compare a quote's claimed `(key_count, pin)` + /// Pure ADR-0004 cross-check: compare a quote's claimed `(key_count, pin)` /// against a resolved signed commitment. /// /// This is the decision core of "peers cross-check the original": given a @@ -1365,7 +1365,7 @@ impl PaymentVerifier { } } - /// ADR-0003 "peers cross-check the original": for each non-baseline quote in + /// ADR-0004 "peers cross-check the original": for each non-baseline quote in /// a client-put bundle, resolve its `commitment_pin` against the gossip /// commitment cache and report a count/pin contradiction. /// @@ -1387,7 +1387,7 @@ impl PaymentVerifier { /// later audit remain the load-bearing checks. /// Resolve a cached peer commitment record to its commitment *only if* it /// was seen within the answerability TTL; a staler entry is treated as - /// unknown (ADR-0003: "a cached commitment older than the answerability TTL + /// unknown (ADR-0004: "a cached commitment older than the answerability TTL /// is treated as unknown"). Pure over `(record, now, ttl)` so the TTL /// boundary is unit-testable without the async cache/network path. fn fresh_cached_commitment( @@ -1428,7 +1428,7 @@ impl PaymentVerifier { .and_then(|rec| Self::fresh_cached_commitment(rec, pin, now, ttl)) } - /// Parse and validate ADR-0003 commitment sidecars into a `(peer, pin) -> + /// Parse and validate ADR-0004 commitment sidecars into a `(peer, pin) -> /// commitment` map. Each blob is deserialized and held to the SAME gates as /// a gossip-ingested or fetched commitment (peer id derived from its own /// `sender_peer_id`, `BLAKE3(pubkey) == sender_peer_id`, valid signature), @@ -1477,7 +1477,7 @@ impl PaymentVerifier { let monetized_pin_tx = self.monetized_pin_tx.read().as_ref().cloned(); let cache = self.commitment_cache.read().as_ref().map(Arc::clone); - // ADR-0003 "the commitment arrived with the quote": parse and FULLY + // ADR-0004 "the commitment arrived with the quote": parse and FULLY // validate the sidecars (peer/pubkey/signature/hash gates, keyed by // `(peer, pin)`), so the cross-check resolves synchronously without a // gossip-cache hit or a post-payment fetch. An invalid sidecar is simply @@ -1494,7 +1494,7 @@ impl PaymentVerifier { }; let peer_id = PeerId::from_bytes(*encoded_peer_id.as_bytes()); - // ADR-0003: this commitment backed a payment — route it for a + // ADR-0004: this commitment backed a payment — route it for a // deterministic first audit (the drainer dedups by pin and respects // the cooldown). Best-effort: a closed channel just means no first // audit is scheduled, never an error on the payment path. @@ -1561,7 +1561,7 @@ impl PaymentVerifier { let mut fetched = 0usize; for (peer_id, pin, quoted_key_count, artifact) in unresolved { if fetched >= crate::replication::config::MAX_PIN_FETCHES_PER_BUNDLE { - debug!("ADR-0003 pin-fetch cap reached for this bundle; leaving rest graced"); + debug!("ADR-0004 pin-fetch cap reached for this bundle; leaving rest graced"); break; } // Skip pins already known-unresolvable for this peer. @@ -1590,7 +1590,7 @@ impl PaymentVerifier { } } - /// Apply the ADR-0003 cross-check verdict for one resolved `(peer, pin, + /// Apply the ADR-0004 cross-check verdict for one resolved `(peer, pin, /// quoted_count)` against `commitment`, emitting a trust failure on a /// genuine mismatch (when enforcing) or logging it (observe-only). Shared by /// the inline cache pass and the background fetch path so both reach the @@ -1618,7 +1618,7 @@ impl PaymentVerifier { // inflater on the disk bytes. if quote_artifact.is_empty() { warn!( - "ADR-0003 quote/commitment mismatch for {peer_id}: dropping evidence, \ + "ADR-0004 quote/commitment mismatch for {peer_id}: dropping evidence, \ quote artifact failed to serialize (graced; the audit still runs)" ); return; @@ -1660,7 +1660,7 @@ impl PaymentVerifier { let enforce = crate::replication::config::QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED; if enforce { warn!( - "ADR-0003 quote/commitment mismatch (enforcing) for {peer}: quote claims \ + "ADR-0004 quote/commitment mismatch (enforcing) for {peer}: quote claims \ {quoted_key_count} keys but pinned commitment attests {committed_key_count}" ); if let Some(p2p) = p2p { @@ -1674,7 +1674,7 @@ impl PaymentVerifier { } } else { warn!( - "ADR-0003 quote/commitment mismatch observed (not enforcing) for {peer}: quote \ + "ADR-0004 quote/commitment mismatch observed (not enforcing) for {peer}: quote \ claims {quoted_key_count} keys but pinned commitment attests {committed_key_count}" ); } @@ -1744,14 +1744,14 @@ impl PaymentVerifier { for (encoded_peer_id, quote) in &payment.peer_quotes { if let Some(detail) = Self::quote_arithmetic_violation(quote) { warn!( - "ADR-0003 off-curve single-node quote observed (not enforcing): \ + "ADR-0004 off-curve single-node quote observed (not enforcing): \ peer {encoded_peer_id:?} {detail}" ); } } } - /// ADR-0003 sister gate for the merkle batch path: every candidate's + /// ADR-0004 sister gate for the merkle batch path: every candidate's /// `price` field must lie on the pricing curve, by exact recomputation. /// See [`Self::validate_quote_arithmetic`] for the rationale; semantics /// (reject-only, rollout-gated, no trust evidence) are identical. @@ -1768,7 +1768,7 @@ impl PaymentVerifier { &candidate.price, ) { return Err(Error::Payment(format!( - "ADR-0003 merkle candidate rejected (reward {}): {detail}", + "ADR-0004 merkle candidate rejected (reward {}): {detail}", candidate.reward_address ))); } @@ -1790,7 +1790,7 @@ impl PaymentVerifier { &candidate.price, ) { warn!( - "ADR-0003 merkle candidate violation observed (not enforcing): \ + "ADR-0004 merkle candidate violation observed (not enforcing): \ reward {} {detail}", candidate.reward_address ); @@ -1891,7 +1891,7 @@ impl PaymentVerifier { /// rejects duplicate peer IDs and runs before this gate on every path — /// so the loop below matches at most one own quote. /// - /// RETIRED (ADR-0003 hard cutover): no longer called on the verification + /// RETIRED (ADR-0004 hard cutover): no longer called on the verification /// path. Forced pricing binds a quote's price exactly to its committed count /// via `validate_quote_arithmetic`, so a valid quote can never be "stale"; /// and the committed *responsible* count legitimately differs from the live @@ -2515,7 +2515,7 @@ impl PaymentVerifier { } } - // ADR-0003: every storer re-runs the price-equals-formula-of-count + // ADR-0004: every storer re-runs the price-equals-formula-of-count // check on every merkle candidate, in every context, before median // reconstruction. Runs AFTER signature verification so observe-only // telemetry cannot be spoofed by unauthenticated senders. Reject-only @@ -2702,7 +2702,7 @@ impl PaymentVerifier { ); } - // ADR-0003: route the merkle-batch candidates through the SAME + // ADR-0004: route the merkle-batch candidates through the SAME // cross-check + first-audit funnel as single-node quotes, AFTER on-chain // verification has succeeded (so an unpaid pool cannot drive audits or // fetches). ClientPut only — a replication receipt's pins have aged out. @@ -2717,7 +2717,7 @@ impl PaymentVerifier { Ok(()) } - /// ADR-0003 cross-check for the merkle-batch path: every candidate carries + /// ADR-0004 cross-check for the merkle-batch path: every candidate carries /// the same signed `(committed_key_count, commitment_pin)` binding as a /// single-node quote, so each non-baseline candidate is resolved against the /// gossip cache (or fetched) and routed into the deterministic first audit, @@ -2734,7 +2734,7 @@ impl PaymentVerifier { let p2p = self.p2p_node.read().as_ref().map(Arc::clone); let monetized_pin_tx = self.monetized_pin_tx.read().as_ref().cloned(); let cache = self.commitment_cache.read().as_ref().map(Arc::clone); - // ADR-0003 "the commitment arrived with the quote" for the merkle path: + // ADR-0004 "the commitment arrived with the quote" for the merkle path: // validate sidecars exactly as the single-node path does. let sidecar_map = Self::index_valid_sidecars(commitment_sidecars); @@ -5238,7 +5238,7 @@ mod tests { } } - // ---------- ADR-0003: quote arithmetic re-check ---------- + // ---------- ADR-0004: quote arithmetic re-check ---------- /// Curve canonicality: any price produced by `calculate_price(n)` is /// on-curve by construction. We exercise a spread of `n` covering the @@ -5287,7 +5287,7 @@ mod tests { ); } - /// ADR-0003 storer-side gate: a bundle in which every quote is on-curve + /// ADR-0004 storer-side gate: a bundle in which every quote is on-curve /// passes the gate **and** the per-quote canonicality predicate. Runs in /// every context (no `ClientPut` split): the rule depends only on the /// bundle, not on per-peer state. The outer `validate_quote_arithmetic` @@ -5489,7 +5489,7 @@ mod tests { ); } - // === ADR-0003 binding-shape + cross-check unit tests === + // === ADR-0004 binding-shape + cross-check unit tests === use crate::payment::pricing::calculate_price as cp; @@ -5606,7 +5606,7 @@ mod tests { ); // Stale: received older than the TTL -> treated as unknown (None), the - // ADR-0003 false-positive guard against an aged cache entry. + // ADR-0004 false-positive guard against an aged cache entry. let stale_at = now .checked_sub(ttl + std::time::Duration::from_secs(1)) .expect("instant in range"); diff --git a/src/replication/commitment.rs b/src/replication/commitment.rs index 0b3aab0d..810c5b0b 100644 --- a/src/replication/commitment.rs +++ b/src/replication/commitment.rs @@ -27,7 +27,7 @@ use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaSecretKey}; use crate::ant_protocol::XorName; -// ADR-0003: the commitment wire type, its pin (`commitment_hash`), its +// ADR-0004: the commitment wire type, its pin (`commitment_hash`), its // signature verification, and the key-count cap are the SINGLE SOURCE OF TRUTH // in `ant-protocol` so the paying client and the node verify identically. // Re-exported here so all existing `crate::replication::commitment::…` callers @@ -404,7 +404,7 @@ pub fn sign_commitment( // `verify_commitment_signature` (embedded-key) is re-exported from // `ant-protocol` above (single source of truth), so the paying client and the // node accept exactly the same commitments. The externally-keyed variant was -// removed in the ADR-0003 move — it had no remaining callers once the embedded- +// removed in the ADR-0004 move — it had no remaining callers once the embedded- // key verify moved to `ant-protocol`. // --------------------------------------------------------------------------- diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs index 550affad..a85d1784 100644 --- a/src/replication/commitment_state.rs +++ b/src/replication/commitment_state.rs @@ -427,7 +427,7 @@ impl ResponderCommitmentState { /// commitment, or `None` if there is no live current (never rotated, or /// retired) — in which case the caller must quote the baseline with no pin. /// - /// ADR-0003 ("quoting is advertising"): issuing a quote that prices against + /// ADR-0004 ("quoting is advertising"): issuing a quote that prices against /// the current commitment must extend that commitment's answerability /// exactly as gossiping it does, so a recently-quoted pin stays resolvable /// for its TTL and a peer auditing it cannot false-fail an honest node. @@ -540,7 +540,7 @@ impl ResponderCommitmentState { } } -/// ADR-0003: the responder commitment state is the quote generator's commitment +/// ADR-0004: the responder commitment state is the quote generator's commitment /// source. `current_binding_for_quote` snapshots the live current commitment's /// `(key_count, pin)` and refreshes its answerability in one atomic step (via /// [`ResponderCommitmentState::current_for_quote`]), so a quote that prices @@ -1193,7 +1193,7 @@ mod tests { ); } - // === ADR-0003: current_for_quote (quote-issuance answerability) === + // === ADR-0004: current_for_quote (quote-issuance answerability) === use crate::payment::quote::CommitmentSource; @@ -1240,7 +1240,7 @@ mod tests { #[test] fn current_for_quote_refreshes_answerability() { // Issuing a quote must refresh the current commitment's answerability, - // exactly like gossiping it (ADR-0003 "quoting is advertising"). + // exactly like gossiping it (ADR-0004 "quoting is advertising"). let (pk, sk) = keypair(); let pk_bytes = pk.to_bytes(); let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); diff --git a/src/replication/config.rs b/src/replication/config.rs index 63b1e83b..18fdbfaf 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -294,16 +294,16 @@ const _: () = assert!( /// sync, and the flip is one line. pub const TIMEOUT_EVICTION_ENABLED: bool = false; -/// Rollout gate for ADR-0003 quote-arithmetic enforcement. +/// Rollout gate for ADR-0004 quote-arithmetic enforcement. /// /// When `true`, the payment verifier rejects any quote whose signed price does /// not lie exactly on the public pricing curve, i.e. there is no integer /// `key_count` for which `calculate_price(key_count) == quote.price`. The check /// is exact recomputation against the curve, never price-inversion (which -/// rounds), per ADR-0003's "never by inverting the price" rule. +/// rounds), per ADR-0004's "never by inverting the price" rule. /// /// When `false`, the check still runs and logs every would-be rejection, but -/// does not reject — matching the ADR-0003 rollout: ship observe-only first, +/// does not reject — matching the ADR-0004 rollout: ship observe-only first, /// enforce only once the fleet has upgraded. Off-curve quotes are honest /// errors, not signs of an old node (every honest implementation derives its /// price from the same public formula), so flipping this to `true` does not @@ -311,14 +311,14 @@ pub const TIMEOUT_EVICTION_ENABLED: bool = false; /// prices off the curve. The flip is independent of [`TIMEOUT_EVICTION_ENABLED`]. /// /// This is a reject-only gate: an off-curve quote produces no trust evidence -/// and no audit, per ADR-0003 ("an off-curve quote is reject-only"). It does +/// and no audit, per ADR-0004 ("an off-curve quote is reject-only"). It does /// NOT gate the quote/commitment mismatch trust report — see /// [`QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED`] for that, kept separate because -/// the two have different ADR-0003 contracts (this one rejects with no trust +/// the two have different ADR-0004 contracts (this one rejects with no trust /// action; that one reports a deterministic contradiction to the trust engine). pub const QUOTE_ARITHMETIC_RECHECK_ENABLED: bool = false; -/// Rollout gate for ADR-0003 quote/commitment **mismatch** trust reporting. +/// Rollout gate for ADR-0004 quote/commitment **mismatch** trust reporting. /// /// When a client-put quote's signed `committed_key_count` contradicts the /// `key_count` of the commitment it pinned (resolved from the gossip cache, a @@ -335,7 +335,7 @@ pub const QUOTE_ARITHMETIC_RECHECK_ENABLED: bool = false; /// silence and not a mere off-curve price, so it deserves its own dial. pub const QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED: bool = false; -/// ADR-0003: max unresolved quote pins to fetch per payment bundle. +/// ADR-0004: max unresolved quote pins to fetch per payment bundle. /// /// A bundle has at most `CLOSE_GROUP_SIZE` quotes; capping fetches per bundle /// bounds the amplification a single malicious upload (many distinct unknown @@ -343,13 +343,13 @@ pub const QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED: bool = false; /// unresolved, i.e. graced — the audit funnel still catches a serving cheater). pub const MAX_PIN_FETCHES_PER_BUNDLE: usize = 3; -/// ADR-0003: capacity of the per-peer negative cache for unresolved pin fetches. +/// ADR-0004: capacity of the per-peer negative cache for unresolved pin fetches. /// /// A pin that a peer answered `NotRetained` (or that timed out) is remembered so /// repeated bundles citing the same unknown pin don't re-fetch. pub const PIN_FETCH_NEGATIVE_CACHE_CAPACITY: usize = 4096; -/// ADR-0003: timeout for a `GetCommitmentByPin` fetch. +/// ADR-0004: timeout for a `GetCommitmentByPin` fetch. /// /// Sized for a single small round-trip plus the responder's bounded in-memory /// lookup; a fetch is off the payment hot path, so this only bounds the @@ -405,7 +405,7 @@ pub const AUDIT_ON_GOSSIP_PROBABILITY: f64 = 0.2; /// seconds. Bounds how often any one peer is audited regardless of gossip rate. pub const AUDIT_ON_GOSSIP_COOLDOWN_SECS: u64 = 30 * 60; -/// ADR-0003: first-audit drainer retry cadence for cooldown-pending pins. +/// ADR-0004: first-audit drainer retry cadence for cooldown-pending pins. /// /// How often the drainer retries pins it kept pending because their peer was on /// cooldown. Finer than the cooldown itself so a monetized commitment is @@ -413,7 +413,7 @@ pub const AUDIT_ON_GOSSIP_COOLDOWN_SECS: u64 = 30 * 60; /// re-checks a small per-peer map, so the tick is cheap. pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_secs(60); -/// ADR-0003: max monetized-pin events the first-audit drainer drains from its +/// ADR-0004: max monetized-pin events the first-audit drainer drains from its /// channel per wake before it must run the audit-launch phase. /// /// Bounds the synchronous `try_recv` batch so a sustained producer flood cannot diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 47e122d1..b7dc670a 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -313,11 +313,11 @@ pub struct ReplicationEngine { /// When present, `start()` spawns a drainer task that calls /// `replicate_fresh` for each event. fresh_write_rx: Option>, - /// ADR-0003: sender the payment verifier clones to surface monetized pins + /// ADR-0004: sender the payment verifier clones to surface monetized pins /// for a deterministic first audit. The matching receiver is drained by /// `start_first_audit_drainer`. monetized_pin_tx: mpsc::UnboundedSender, - /// ADR-0003: receiver half of the monetized-pin channel, taken by + /// ADR-0004: receiver half of the monetized-pin channel, taken by /// `start_first_audit_drainer`. monetized_pin_rx: Option>, /// Shutdown token. @@ -355,7 +355,7 @@ impl ReplicationEngine { let initial_neighbors = NeighborSyncState::new_cycle(Vec::new()); let config = Arc::new(config); - // ADR-0003: monetized-pin channel (verifier -> first-audit drainer). + // ADR-0004: monetized-pin channel (verifier -> first-audit drainer). let (monetized_pin_tx, monetized_pin_rx) = mpsc::unbounded_channel(); Ok(Self { @@ -392,7 +392,7 @@ impl ReplicationEngine { }) } - /// ADR-0003: a sender the payment verifier uses to surface monetized pins + /// ADR-0004: a sender the payment verifier uses to surface monetized pins /// (commitments that backed a payment) for a deterministic first audit. /// Cloneable; the engine drains the matching receiver. #[must_use] @@ -522,7 +522,7 @@ impl ReplicationEngine { self.start_verification_worker(); self.start_bootstrap_sync(dht_events); self.start_fresh_write_drainer(); - // ADR-0003: deterministic first audit of commitments that backed a + // ADR-0004: deterministic first audit of commitments that backed a // payment (surfaced by the verifier cross-check). self.start_first_audit_drainer(); @@ -645,7 +645,7 @@ impl ReplicationEngine { self.task_handles.push(handle); } - /// ADR-0003: drain monetized pins surfaced by the verifier cross-check and + /// ADR-0004: drain monetized pins surfaced by the verifier cross-check and /// run a **deterministic first audit** of each — the same `run_subtree_audit` /// as the gossip path, under the same per-peer cooldown and concurrency /// caps, but with the probability lottery BYPASSED (the lottery governs @@ -2048,7 +2048,7 @@ async fn handle_replication_message( Ok(()) } ReplicationMessageBody::GetCommitmentByPin(ref request) => { - // ADR-0003: answer a commitment-by-pin fetch from the retained set + // ADR-0004: answer a commitment-by-pin fetch from the retained set // only. `lookup_by_hash` is an allocation-light read over the // bounded slot set; it returns the live current commitment or any // still-answerable recently-gossiped/quoted one. A miss is reported @@ -4337,7 +4337,7 @@ struct AuditTarget { key_count: u32, } -/// ADR-0003: a commitment that backed a payment, surfaced by the payment +/// ADR-0004: a commitment that backed a payment, surfaced by the payment /// verifier's cross-check so it can receive a **deterministic first audit**. /// /// Sent from the verifier to the replication engine's first-audit drainer. The diff --git a/src/replication/protocol.rs b/src/replication/protocol.rs index ec3392ad..201476ae 100644 --- a/src/replication/protocol.rs +++ b/src/replication/protocol.rs @@ -126,13 +126,13 @@ pub enum ReplicationMessageBody { /// Response carrying the requested chunks' original bytes (round 2). SubtreeByteResponse(SubtreeByteResponse), - // === Commitment fetch by pin (ADR-0003) === + // === Commitment fetch by pin (ADR-0004) === // APPENDED at the end so postcard variant discriminants of all the // pre-existing variants are unchanged — old nodes keep decoding every // message they already understood; only these two new indices are unknown // to them (and they never receive them, since old nodes never send the // matching request). - /// Fetch a retained commitment by its pin (ADR-0003): used to resolve a + /// Fetch a retained commitment by its pin (ADR-0004): used to resolve a /// quote's `commitment_pin` when the sidecar is absent and the gossip cache /// has no fresh copy. GetCommitmentByPin(GetCommitmentByPin), @@ -304,12 +304,12 @@ pub enum FetchResponse { } // --------------------------------------------------------------------------- -// Commitment fetch by pin (ADR-0003) +// Commitment fetch by pin (ADR-0004) // --------------------------------------------------------------------------- /// Request a retained commitment by its pin (commitment hash). /// -/// ADR-0003: a storer cross-checking a quote whose `commitment_pin` it does not +/// ADR-0004: a storer cross-checking a quote whose `commitment_pin` it does not /// already hold (no sidecar, no fresh gossip copy) fetches the signed /// commitment so it can verify the binding and route the commitment into audit. /// The responder answers only from its retained set, so this never forces a @@ -330,7 +330,7 @@ pub enum GetCommitmentByPinResponse { commitment: crate::replication::commitment::StorageCommitment, }, /// The pin is not among the responder's retained commitments (rotated/aged - /// out, or never held). ADR-0003 treats this as graced, never confirmed: + /// out, or never held). ADR-0004 treats this as graced, never confirmed: /// an unanswerable pin is indistinguishable from an honest crash-restart. NotRetained { /// Echo of the requested pin, for matching. diff --git a/src/replication/types.rs b/src/replication/types.rs index 923311c9..d084d25f 100644 --- a/src/replication/types.rs +++ b/src/replication/types.rs @@ -225,7 +225,7 @@ pub enum FailureEvidence { /// When this peer was first seen. first_seen: Instant, }, - /// ADR-0003: a quote's claimed committed key count contradicts the signed + /// ADR-0004: a quote's claimed committed key count contradicts the signed /// commitment it pinned. The quote and the commitment are both signed by /// the same key, so this is a deterministic, first-occurrence /// contradiction — not bad luck — and lands in the same trust lane as a diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 2bb13eb4..6f0c0304 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -82,7 +82,7 @@ impl AntProtocol { // the invariant automatic for every AntProtocol construction path, // including tests and future startup variants. // - // ADR-0003: the QuoteGenerator no longer prices off `current_chunks()` + // ADR-0004: the QuoteGenerator no longer prices off `current_chunks()` // — its price is bound to the live storage commitment (see // `attach_commitment_source`, wired by the node once the replication // engine exists), or baseline when none — so it is NOT attached to the @@ -141,7 +141,7 @@ impl AntProtocol { Arc::clone(&self.payment_verifier) } - /// ADR-0003: attach the replication engine's commitment state as the quote + /// ADR-0004: attach the replication engine's commitment state as the quote /// generator's commitment source, so quotes force their price from the live /// storage commitment and refresh its answerability on issuance. /// @@ -156,7 +156,7 @@ impl AntProtocol { self.quote_generator.attach_commitment_source(source); } - /// ADR-0003: return the proof with any commitment sidecars stripped, so a + /// ADR-0004: return the proof with any commitment sidecars stripped, so a /// replicated/persisted receipt carries only the pin and count (stored /// proofs do not grow). Handles BOTH proof types — single-node and /// merkle-batch — since both now carry sidecars. An unknown-tagged or @@ -373,7 +373,7 @@ impl AntProtocol { // PUTs have no proof to forward, and the chunk would have // already replicated on the original write that carried one. if let (Some(ref tx), Some(proof)) = (&self.fresh_write_tx, request.payment_proof) { - // ADR-0003: strip any commitment sidecars before forwarding + // ADR-0004: strip any commitment sidecars before forwarding // to replication — the ADR specifies persisted/replicated // receipts "keep only the pin and count, so stored proofs do // not grow." The sidecars were already consumed by this @@ -533,7 +533,7 @@ impl AntProtocol { .create_quote(request.address, data_size_usize, request.data_type) { Ok(quote) => { - // ADR-0003: ship the signed commitment the price was bound to + // ADR-0004: ship the signed commitment the price was bound to // alongside the quote ("the commitment arrived with the quote"), // so the client can verify the binding before paying and forward // it as a sidecar in the PUT bundle. Only a commitment-bound @@ -593,7 +593,7 @@ impl AntProtocol { request.merkle_payment_timestamp, ) { Ok(candidate_node) => { - // ADR-0003: ship the signed commitment this candidate priced + // ADR-0004: ship the signed commitment this candidate priced // against, so the client can verify the binding before paying // and forward it as a sidecar. Baseline candidates ship none. let commitment = candidate_node @@ -1371,7 +1371,7 @@ mod tests { ); } - /// ADR-0003: `strip_commitment_sidecars` removes sidecars from a single-node + /// ADR-0004: `strip_commitment_sidecars` removes sidecars from a single-node /// proof before replication/persistence (stored proofs do not grow), and is /// a no-op on a sidecar-free proof. #[test] From 469158de4f328047393ad3b4d0301d5892006174 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 15:45:52 +0200 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20TEMP=20[patch.crates-io]=20evmlib+?= =?UTF-8?q?ant-protocol=20PR=20branches=20for=20CI=20=E2=80=94=20STRIP=20B?= =?UTF-8?q?EFORE=20MERGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evmlib/ant-protocol ADR-0004 versions are unpublished; git patches let CI build. Remove block and bump pins at release. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index a193eac3..4527861f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -196,3 +196,20 @@ unused_async = "allow" cognitive_complexity = "allow" # Allow non-const functions during initial development (may need runtime features later) missing_const_for_fn = "allow" + +# ─── TEMPORARY: STRIP BEFORE MERGE — ADR-0004 coordinated cutover ─── +# The ADR-0004 versions of evmlib and ant-protocol (carrying the signed quote +# fields `committed_key_count` / `commitment_pin`) are NOT yet published to +# crates.io, so this crate cannot build against the pinned crates.io versions +# above. These git patches point both deps at their PR branches so CI can build +# the coordinated change end-to-end. The branches' package versions still +# satisfy the existing pins, so nothing else needs to change to build. +# +# AT RELEASE (once the ADR-0004 evmlib + ant-protocol versions are published): +# 1. delete this entire [patch.crates-io] block, AND +# 2. bump the pins in [dependencies] above to the published versions: +# # evmlib = "0.9.0" # published ADR-0004 (breaking bump from 0.8.1) +# # ant-protocol = "2.3.0" # published ADR-0004 (bump from 2.2.1) +[patch.crates-io] +evmlib = { git = "https://github.com/WithAutonomi/evmlib", branch = "adr-0003-signed-quote-fields" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "adr-0003-commitment-bound-pricing" } From e3459944ef8be5674530695532c940d9c9e4f52a Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 16:39:21 +0200 Subject: [PATCH 4/6] fix(verifier): drop ADR-retired/superseded validators left dead by the rebase The rebase onto main left four ADR-side validators with zero callers, their work now covered by main's refactor (validate_paid_quote_peer_binding / validate_paid_quote_price_floor) plus the ADR's validate_quote_arithmetic and cross_check_quotes: validate_quote_freshness (the ADR-retired staleness gate, which also referenced a const+method deleted on main), validate_quote_content, validate_peer_bindings, validate_local_recipient. Removed them and the now-dead test_peer_id_override field/init. CI is -D warnings, so dead code is a hard error. Co-Authored-By: Claude Opus 4.8 --- src/payment/verifier.rs | 199 ++++------------------------------------ 1 file changed, 19 insertions(+), 180 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index d71a31e9..118176aa 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -285,12 +285,9 @@ pub struct PaymentVerifier { /// exercise the full verifier path without starting an EVM chain. #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock>, - /// Test-only override for this node's own peer ID, used by - /// `validate_quote_freshness` to pick out the node's own quote from the - /// payment bundle. Production code derives it from the attached - /// [`P2PNode`]; set via [`Self::set_peer_id_for_tests`] so unit tests can - /// drive the freshness logic without wiring a real `P2PNode`. - test_peer_id_override: RwLock>, + // NOTE: the test-only own-peer-id override was removed with the ADR-retired + // quote-freshness/staleness gate (ADR-0004 binds price to the committed + // count instead), so it no longer has any reader. /// ADR-0004 gossip commitment cache, shared with the replication engine /// (`last_commitment_by_peer`). The cross-check resolves a quote's /// `commitment_pin` against the neighbour's most recently gossiped @@ -435,7 +432,6 @@ impl PaymentVerifier { test_paid_quote_k_closest_override: RwLock::new(None), #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock::new(HashMap::new()), - test_peer_id_override: RwLock::new(None), commitment_cache: RwLock::new(None), pin_fetch_negative_cache: Arc::new(Mutex::new(LruCache::new( NonZeroUsize::new(crate::replication::config::PIN_FETCH_NEGATIVE_CACHE_CAPACITY) @@ -1176,20 +1172,6 @@ impl PaymentVerifier { Ok(()) } - /// Verify all quotes target the correct content address. - fn validate_quote_content(payment: &ProofOfPayment, xorname: &XorName) -> Result<()> { - for (encoded_peer_id, quote) in &payment.peer_quotes { - if !verify_quote_content(quote, xorname) { - let expected_hex = hex::encode(xorname); - let actual_hex = hex::encode(quote.content.0); - return Err(Error::Payment(format!( - "Quote content address mismatch for peer {encoded_peer_id:?}: expected {expected_hex}, got {actual_hex}" - ))); - } - } - Ok(()) - } - /// ADR-0004: enforce that every quoted price lies exactly on the public /// pricing curve. /// @@ -1842,150 +1824,6 @@ impl PaymentVerifier { usize::try_from(candidate_count).unwrap_or(usize::MAX) } - /// Verify quote freshness by price staleness, not wall-clock time and not a - /// symmetric record-count delta. - /// - /// The quote price encodes the quoting node's record count via the quadratic - /// pricing formula. We compute the price the node would charge *now* for its - /// current fullness and reject the quote only if the client under-paid that - /// current price by more than [`QUOTE_PRICE_STALENESS_PCT_TOLERANCE`]. This: - /// - /// - removes the platform clock dependency that caused Windows/UTC false - /// rejections (timestamps are deliberately unused); - /// - never rejects an over-payment (the previous symmetric `abs_diff` check - /// rejected quotes where the node had *fewer* records than when it quoted, - /// i.e. the client paid for a fuller, pricier node — nonsensical to - /// reject); and - /// - self-scales with the pricing curve, so benign in-flight churn (a node - /// storing a few replicated records between quoting and verifying) — a - /// negligible price move where the curve is flat — no longer rejects an - /// otherwise-valid payment. On a fresh, rapidly-filling testnet that churn - /// routinely exceeded the old fixed 5-record tolerance and rejected ~100% - /// of uploads via the multiplicative per-chunk effect. - /// - /// The current record count comes from the attached [`LmdbStorage`] via - /// `current_chunks()` — an O(1) B-tree page-header read, authoritative - /// regardless of which path stored the record (client PUT, replication - /// store, repair fetch) or removed it (prune delete). If no storage source - /// is available (mis-configured production startup, or a unit test that - /// didn't set a test override), the gate is skipped entirely rather than - /// rejecting every quote — see [`Self::current_records_stored`]. - /// - /// **Only this node's own quote is gated.** A bundle contains one quote - /// per close-group peer, and fullness across a close group is wildly - /// heterogeneous on a real network (a freshly joined node holds tens of - /// records while an established neighbour holds thousands). Comparing a - /// *neighbour's* quote price against *this node's* record count therefore - /// rejects honest payments whenever the group spans more than the - /// tolerance — on ant-prod-01 a close group spanning 47..=1788 records - /// made the three fullest nodes reject every bundle containing the - /// emptiest node's (perfectly fresh, 10-second-old) quote, failing the - /// PUT after the client had already paid on-chain. The node can only - /// re-derive *its own* price from its own record count, so its own quote - /// is the only one it can legitimately call stale. Replay of another - /// node's old cheap quote is that node's gate to enforce when the PUT - /// reaches it; the on-chain median payment binding is unaffected either - /// way. - /// - /// A bundle holds at most one quote per peer — [`Self::validate_quote_structure`] - /// rejects duplicate peer IDs and runs before this gate on every path — - /// so the loop below matches at most one own quote. - /// - /// RETIRED (ADR-0004 hard cutover): no longer called on the verification - /// path. Forced pricing binds a quote's price exactly to its committed count - /// via `validate_quote_arithmetic`, so a valid quote can never be "stale"; - /// and the committed *responsible* count legitimately differs from the live - /// on-disk `current_chunks()` this gate reads, so running it would - /// false-reject healthy ADR quotes (notably baseline quotes from a node that - /// holds records but has no live commitment yet). Kept, with its unit tests, - /// only to document the legacy gate's exact semantics. - #[allow(dead_code)] - fn validate_quote_freshness(&self, payment: &ProofOfPayment) -> Result<()> { - let Some(current_records) = self.current_records_stored() else { - debug!( - "PaymentVerifier: no record-count source attached; skipping \ - quote price-staleness check" - ); - return Ok(()); - }; - - let Some(self_peer_id) = self.self_peer_id_bytes() else { - debug!( - "PaymentVerifier: no self peer-id source attached; skipping \ - quote price-staleness check" - ); - return Ok(()); - }; - - // The price the node would charge right now for its current fullness, - // and the floor a quote may not drop below (one-directional: paying at - // or above `current_price` is always accepted). - let current_price = calculate_price(usize::try_from(current_records).unwrap_or(usize::MAX)); - let min_acceptable_price = current_price.saturating_mul(Amount::from( - 100u64.saturating_sub(QUOTE_PRICE_STALENESS_PCT_TOLERANCE), - )) / Amount::from(100u64); - - let mut own_quote_seen = false; - for (encoded_peer_id, quote) in &payment.peer_quotes { - if encoded_peer_id.as_bytes() != &self_peer_id { - // A neighbour's quote prices the *neighbour's* fullness; this - // node has no basis to judge it against its own record count. - continue; - } - own_quote_seen = true; - if quote.price < min_acceptable_price { - let quoted_records = derive_records_stored_from_price(quote.price); - return Err(Error::Payment(format!( - "Own quote {encoded_peer_id:?} stale: quoted price encodes \ - {quoted_records} records but node currently holds {current_records} \ - (quoted {}, minimum acceptable {min_acceptable_price} at \ - {QUOTE_PRICE_STALENESS_PCT_TOLERANCE}% under-payment tolerance)", - quote.price - ))); - } - } - - // Two self-identity notions coexist in this verifier and are expected - // to refer to the same node: `validate_local_recipient` matches "us" - // by rewards address, this gate by peer ID. They legitimately diverge - // when a PUT reaches a node whose own quote isn't in the bundle but - // whose rewards address is shared with a quoted sibling (common in - // fleet deployments). The gate fail-opens in that case — leave a - // breadcrumb, because a silent no-op is exactly what makes a - // production incident hard to reconstruct from node logs. - if !own_quote_seen { - let our_rewards_address_quoted = payment - .peer_quotes - .iter() - .any(|(_, quote)| quote.rewards_address == self.config.local_rewards_address); - if our_rewards_address_quoted { - debug!( - "PaymentVerifier: bundle contains our rewards address but no quote \ - under our peer ID; skipping quote price-staleness check" - ); - } - } - Ok(()) - } - - /// Verify each quote's `pub_key` matches the claimed peer ID via BLAKE3. - fn validate_peer_bindings(payment: &ProofOfPayment) -> Result<()> { - for (encoded_peer_id, quote) in &payment.peer_quotes { - let expected_peer_id = peer_id_from_public_key_bytes("e.pub_key) - .map_err(|e| Error::Payment(format!("Invalid ML-DSA public key in quote: {e}")))?; - - if expected_peer_id.as_bytes() != encoded_peer_id.as_bytes() { - let expected_hex = expected_peer_id.to_hex(); - let actual_hex = hex::encode(encoded_peer_id.as_bytes()); - return Err(Error::Payment(format!( - "Quote pub_key does not belong to claimed peer {encoded_peer_id:?}: \ - BLAKE3(pub_key) = {expected_hex}, peer_id = {actual_hex}" - ))); - } - } - Ok(()) - } - /// Minimum number of candidate `pub_keys` (out of 16) whose derived /// `PeerId` must be among the DHT's actual closest peers to the pool /// midpoint address for the pool to be accepted. @@ -2790,21 +2628,6 @@ impl PaymentVerifier { Self::drain_unresolved_pin_fetches(&p2p, &neg_cache, unresolved).await; }); } - - /// Verify this node is among the paid recipients. - fn validate_local_recipient(&self, payment: &ProofOfPayment) -> Result<()> { - let local_addr = &self.config.local_rewards_address; - let is_recipient = payment - .peer_quotes - .iter() - .any(|(_, quote)| quote.rewards_address == *local_addr); - if !is_recipient { - return Err(Error::Payment( - "Payment proof does not include this node as a recipient".to_string(), - )); - } - Ok(()) - } } #[cfg(test)] @@ -2854,6 +2677,8 @@ mod tests { timestamp: SystemTime::now(), price, rewards_address: RewardsAddress::new([rewards_seed; 20]), + committed_key_count: 0, + commitment_pin: None, pub_key: pub_key_bytes, signature: Vec::new(), }; @@ -3865,6 +3690,20 @@ mod tests { } } + /// Helper: create a fake quote priced on-curve at `records` stored records + /// (price = `calculate_price(records)`), reusing [`make_fake_quote`] for the + /// remaining fields. Used by the ADR-0004 arithmetic-gate tests. + fn make_fake_quote_at_records( + xorname: [u8; 32], + timestamp: SystemTime, + rewards_address: RewardsAddress, + records: usize, + ) -> evmlib::PaymentQuote { + let mut quote = make_fake_quote(xorname, timestamp, rewards_address); + quote.price = crate::payment::pricing::calculate_price(records); + quote + } + /// Helper: wrap quotes into a tagged serialized `PaymentProof`. fn serialize_proof(peer_quotes: Vec<(evmlib::EncodedPeerId, evmlib::PaymentQuote)>) -> Vec { use crate::payment::proof::{serialize_single_node_proof, PaymentProof}; From 195981f53a92f1a6522f8bf96c903c2259c50a09 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 16:47:47 +0200 Subject: [PATCH 5/6] docs(adr): move implementation-slices out of the ADR-NNNN namespace It is a companion slicing doc, not a decision record, so the ADR governance check (docs/adr/ADR-*.md) wrongly treated it as a duplicate ADR-0004 and demanded full-ADR sections. Renamed to implementation-slices-adr-0004.md (same precedent as docs/adr/TOOLING.md). No content change; nothing references the old filename. Co-Authored-By: Claude Opus 4.8 --- ...implementation-slices.md => implementation-slices-adr-0004.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/adr/{ADR-0004-implementation-slices.md => implementation-slices-adr-0004.md} (100%) diff --git a/docs/adr/ADR-0004-implementation-slices.md b/docs/adr/implementation-slices-adr-0004.md similarity index 100% rename from docs/adr/ADR-0004-implementation-slices.md rename to docs/adr/implementation-slices-adr-0004.md From 1de1f7c2748b9e6b6730fd809ffdadf2011412db Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 17:43:58 +0200 Subject: [PATCH 6/6] fix(test): make commitment-TTL boundary test robust on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fresh_cached_commitment_honours_ttl_boundary built the stale entry via Instant::now().checked_sub(ttl + 1s); on Windows the monotonic Instant epoch can be younger than the 3h TTL, so checked_sub underflowed to None and the test panicked ('instant in range'). Advance the comparison clock with checked_add from the receipt time instead — equivalent age comparison, no underflow. Linux/macOS already passed; this fixes the Windows-only failure. Co-Authored-By: Claude Opus 4.8 --- src/payment/verifier.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 118176aa..f42580dc 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -5446,12 +5446,19 @@ mod tests { // Stale: received older than the TTL -> treated as unknown (None), the // ADR-0004 false-positive guard against an aged cache entry. - let stale_at = now - .checked_sub(ttl + std::time::Duration::from_secs(1)) + // + // Advance the comparison clock PAST the TTL rather than subtracting the + // TTL from `now`: on Windows the monotonic `Instant` epoch can be + // younger than a multi-hour TTL, so `now.checked_sub(ttl + 1s)` + // underflows to `None` and panics. `checked_add` from the receipt time + // is always in range and is equivalent for the age comparison. + let received_at = now; + let now_after_ttl = received_at + .checked_add(ttl + std::time::Duration::from_secs(1)) .expect("instant in range"); - let stale = PeerCommitmentRecord::from_verified(commitment, stale_at); + let stale = PeerCommitmentRecord::from_verified(commitment, received_at); assert!( - PaymentVerifier::fresh_cached_commitment(&stale, pin, now, ttl).is_none(), + PaymentVerifier::fresh_cached_commitment(&stale, pin, now_after_ttl, ttl).is_none(), "a cache entry older than the answerability TTL must be treated as unknown" ); }