diff --git a/Cargo.toml b/Cargo.toml index ca894682..bc33f1a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,6 +158,14 @@ name = "poc_bootstrap_stall" path = "tests/poc_bootstrap_stall.rs" required-features = ["test-utils"] +# Live-Anvil verification of the ADR-0006 price floor against a real deployed +# vault + real on-chain settlement. Needs the test-utils fail-open for the +# issuer K-closest check so the floor is the isolated load-bearing check. +[[test]] +name = "poc_price_floor_live" +path = "tests/poc_price_floor_live.rs" +required-features = ["test-utils"] + [features] default = ["logging"] # Enable tracing/logging infrastructure. diff --git a/docs/adr/ADR-0006-receiver-side-revenue-floor.md b/docs/adr/ADR-0006-receiver-side-revenue-floor.md new file mode 100644 index 00000000..e66016f6 --- /dev/null +++ b/docs/adr/ADR-0006-receiver-side-revenue-floor.md @@ -0,0 +1,161 @@ +# ADR-0006: Receiver-side revenue floor for single-node store admissions + +- **Status:** Proposed +- **Date:** 2026-07-14 +- **Decision owners:** Anselme (@grumbach) +- **Reviewers:** +- **Supersedes:** none (amends the economic scope of ADR-0004; see Decision) +- **Superseded by:** none +- **Related:** ADR-0004 (commitment-bound quote pricing), ant-node #136 + (flexible 1..=7 proof bundles), ant-node #176 (implementation), + ant-client #150 (one-quote uploads) + +## Context + +ADR-0004 made a quote's price an exact function of the issuer's signed +storage commitment. That is a **price ceiling**: a node cannot charge more +than the storage it can prove. ADR-0004 explicitly noted that "a ceiling is +not a revenue floor" and left the floor to a follow-up policy. Its +implementation also retired the old receiver-side floor +(`validate_paid_quote_price_floor`), which compared quote prices against the +receiver's raw on-disk record count — a count that legitimately diverges from +committed responsible counts (out-of-responsibility records, data awaiting +pruning), so the old floor false-rejected honest quotes. + +Separately, ant-node #136 made the payment verifier accept flexible +single-node proof bundles of 1..=7 quotes, verifying only the median-priced +candidate in full. Each change was sound alone; combined they leave no floor +at all. The verifier cannot see which quotes a client *omitted*, so a +modified client can fetch quotes from the whole neighbourhood, pay only the +cheapest valid K-close issuer 3x its (commitment-bound, possibly baseline) +price, submit a one-quote proof, and every receiver accepts. The effective +network price collapses to "cheapest valid quote in the close group" — +typically the newest, emptiest node's baseline. One-quote clients +(ant-client #150, a liveness fix this ADR deliberately does not block) make +that proof shape routine rather than adversarial-only. + +Quote-count minimums cannot close this: omission is unprovable, and any +count requirement recreates the availability failure #150 exists to fix. +The enforceable invariant is the receiver's own reservation price. + +## Decision Drivers + +- Storers must be able to refuse payments priced below their own proven + storage cost basis; otherwise network revenue is set by the cheapest + neighbour rather than by the market the median-of-7 intended. +- The floor must not resurrect the old apples-to-oranges comparison that + false-rejected honest quotes. +- One-quote liveness must be preserved: uploads may proceed with a single + valid quote. +- A wrongly calibrated floor rejects payments that are already settled and + irrecoverable, so rollout must produce evidence before it produces + rejections. +- Historical receipts must never be repriced against later, higher floors. + +## Considered Options + +1. Require a minimum quote count in the proof — rejected: cannot prove + completeness, and recreates the liveness failure at higher counts. +2. Carry witness/completeness evidence in the proof — rejected: witnessing is + deliberately client-side selection, not proof material. +3. Receiver-side floor from the receiver's committed responsible key count, + shadow-first — chosen. + +## Decision + +On single-node store admissions (direct client PUT and immediate fresh +replication), the receiver additionally requires, alongside the existing +`settled >= 3 x median` check: + +```text +settled_amount >= 3 x tolerance% x calculate_price(receiver's current + committed responsible key count) +``` + +- **Pricing basis.** The floor reads the SAME live commitment the receiver's + own `QuoteGenerator` prices from, via a non-mutating snapshot + (`CommitmentSource::current_binding_snapshot`). Never the on-disk record + count; never the answerability-refreshing quote path. +- **Settled amount, not quote price.** An honest client may overpay a cheap + quote to clear stricter receivers. +- **No commitment = baseline.** A fresh, retired, or restarting receiver + prices the floor at `calculate_price(0)`. Every on-curve honest quote clears + a baseline floor at a 3x settlement, so this is not an availability + regression. (Only an off-curve sub-baseline quote — which the ADR-0004 + arithmetic gate rejects once enforced — could fall below it, an economic + decline rather than an outage.) +- **Scope.** Applies to `ClientPut` and `FreshReplication` (a below-floor + proof must not fan out through one cheap accepting node). Never applies to + paid-list admission, later repair, cache hits, or merkle-batch proofs: no + historical receipt is repriced. This makes the floor an *ingress* rule by + design. +- **Economic decision only.** A floor rejection is a payment decline. It + never feeds trust or misbehaviour scoring: honest heterogeneity and churn + can look like underpricing (ADR-0004's own warning). +- **Rollout.** Shadow mode by default: the floor is computed and logged + (target `ant_node::payment::price_floor`) on every evaluated admission, + never enforced. Enforcement is a per-node opt-in via + `ANT_PRICE_FLOOR_ENFORCE=1`, tolerance via + `ANT_PRICE_FLOOR_TOLERANCE_PERCENT` (default 50%). Canary nodes enforce + first; clients need 4-of-20 successful stores, so sparse enforcement + cannot fail honest uploads while telemetry calibrates the tolerance. + Unsetting the variable is the kill switch. + +This amends ADR-0004's stated contract "a node may always charge less" to: +a quote may always charge less, but a receiver may refuse the resulting +payment when it settles below the receiver's own commitment-priced floor. + +## Consequences + +### Positive + +- Restores an economic floor without sacrificing one-quote liveness, and + with the false-rejection cause of the old floor removed by construction. +- The cheapest-of-K omission strategy stops working against enforcing + receivers: the sole quote no longer sets the whole price. +- Telemetry quantifies the honest settled/floor ratio distribution before a + single payment is rejected. + +### Negative / Trade-offs + +- Committed counts still legitimately diverge across a close group + (rotation windows, churned responsibility sets, baseline-priced fresh + nodes), so the tolerance is a real dial: too tight rejects honest + settlements that are already paid and irrecoverable. Hence shadow-first + and the loose 50% starting point. +- As an ingress-only rule, a below-floor chunk accepted elsewhere can still + reach an enforcing node later via repair. Accepted: repair replays no + fresh economic decision. +- Enforcement is per-node, so mixed fleets decide differently by design; + clients already tolerate per-receiver acceptance divergence. + +### Neutral / Operational + +- `VerificationContext::FreshReplication` now exists, verified identically + to `ClientPut` everywhere; it labels floor telemetry and keeps fan-out + policy independently tunable. +- Client proof reuse (PUT fallback across the 20-peer target set, + partial-upload recovery) is evaluated against the receiver's *current* + floor; the tolerance must absorb honest quote-to-PUT drift, which + telemetry measures directly. + +## Validation + +- Unit gates (ant-node #176): a 3x-baseline settlement against a fuller + receiver is rejected under enforcement on both admission contexts; + exactly-at-floor passes; shadow mode never rejects; no-commitment + receivers stay vacuous; paid-list admission is never repriced. +- Shadow telemetry: distribution of `settled / (3 x tolerance% x + local_price)` on honest traffic; projected rejection rate must be ~0 at + the chosen tolerance before any canary enforces. +- Canary enforcement: zero honest-upload failures at 4-of-20 store quorum + while enforcing nodes reject synthetic cheapest-of-K proofs. +- Review trigger: enabling enforcement fleet-wide, changing the default + tolerance, or extending the floor to any currently exempt path requires + revisiting this ADR. + +## 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/src/devnet.rs b/src/devnet.rs index 83f71f90..82c078dc 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -7,7 +7,7 @@ use crate::ant_protocol::CHUNK_PROTOCOL_ID; use crate::config::{default_root_dir, NODES_SUBDIR, NODE_IDENTITY_FILENAME}; use crate::logging::{debug, info, warn}; use crate::payment::{ - EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, + EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, PriceFloorConfig, QuoteGenerator, QuotingMetricsTracker, }; use crate::replication::config::ReplicationConfig; @@ -557,6 +557,7 @@ impl Devnet { cache_capacity: DEVNET_PAYMENT_CACHE_CAPACITY, close_group_size: replication_config.close_group_size, local_rewards_address: rewards_address, + price_floor: PriceFloorConfig::default(), }; let payment_verifier = PaymentVerifier::new(payment_config); let metrics_tracker = QuotingMetricsTracker::new(DEVNET_INITIAL_RECORDS); diff --git a/src/node.rs b/src/node.rs index fcd1cb27..089d2f2c 100644 --- a/src/node.rs +++ b/src/node.rs @@ -9,7 +9,9 @@ use crate::event::{create_event_channel, NodeEvent, NodeEventsChannel, NodeEvent use crate::logging::{debug, error, info, warn}; use crate::payment::metrics::QuotingMetricsTracker; use crate::payment::wallet::parse_rewards_address; -use crate::payment::{EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator}; +use crate::payment::{ + EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, PriceFloorConfig, QuoteGenerator, +}; use crate::replication::config::ReplicationConfig; use crate::replication::ReplicationEngine; use crate::storage::lmdb::MIB; @@ -418,7 +420,16 @@ impl NodeBuilder { cache_capacity: config.payment.cache_capacity, close_group_size, local_rewards_address: rewards_address, + // Shadow mode by default; enforcement is a per-node canary opt-in + // via ANT_PRICE_FLOOR_ENFORCE (see PriceFloorConfig). + price_floor: PriceFloorConfig::from_env(), }; + if payment_config.price_floor.enforce { + info!( + "Price floor ENFORCEMENT enabled (tolerance {}%)", + payment_config.price_floor.tolerance_percent + ); + } let payment_verifier = PaymentVerifier::new(payment_config); let metrics_tracker = QuotingMetricsTracker::new(0); let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 7315e438..d876a06a 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -62,7 +62,8 @@ pub use ant_protocol::payment::verify::{ }; pub use single_node::SingleNodePayment; pub use verifier::{ - EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig, VerificationContext, - MAX_PAYMENT_PROOF_SIZE_BYTES, MIN_PAYMENT_PROOF_SIZE_BYTES, + EvmVerifierConfig, PaymentStatus, PaymentVerifier, PaymentVerifierConfig, PriceFloorConfig, + VerificationContext, MAX_PAYMENT_PROOF_SIZE_BYTES, MIN_PAYMENT_PROOF_SIZE_BYTES, + PRICE_FLOOR_ENFORCE_ENV, PRICE_FLOOR_TOLERANCE_ENV, }; pub use wallet::{is_valid_address, parse_rewards_address, WalletConfig}; diff --git a/src/payment/quote.rs b/src/payment/quote.rs index 74f18333..66617f95 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -54,6 +54,14 @@ pub trait CommitmentSource: Send + Sync { /// atomically. `None` if there is no live current commitment. fn current_binding_for_quote(&self) -> Option; + /// Non-mutating snapshot of the current commitment's binding: the same + /// value [`Self::current_binding_for_quote`] prices from, WITHOUT + /// refreshing answerability. For read-only consumers — the payment + /// verifier's price-floor policy reads the local commitment price here — + /// that must not extend retention: "quoting is advertising" applies to + /// issued quotes only, not to a verifier consulting its own price. + fn current_binding_snapshot(&self) -> Option; + /// 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 @@ -486,6 +494,10 @@ mod tests { }) } + fn current_binding_snapshot(&self) -> Option { + self.current_binding_for_quote() + } + 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. diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index a48da2a3..41aa5e33 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -60,6 +60,111 @@ const PAYMENT_VERIFY_SLOW_LOG_MS: u128 = 500; /// This is the Kademlia K width, intentionally wider than `CLOSE_GROUP_SIZE`. const PAID_QUOTE_ISSUER_CLOSENESS_WIDTH: usize = K_BUCKET_SIZE; +/// Default tolerance for the receiver-side price floor, as a percentage of +/// this node's own commitment-bound price. 50% is deliberately loose: it +/// blocks the egregious cheapest-of-K underpayment while leaving wide headroom +/// for honest commitment divergence across a close group (rotation windows, +/// churned responsibility sets, baseline-priced fresh nodes). Tighten only +/// from observed shadow telemetry, never speculatively. +const PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT: u64 = 50; + +/// Environment variable enabling price-floor ENFORCEMENT (`1`/`true`). +/// +/// Absent or any other value means shadow mode: the floor is computed and +/// logged, never enforced. Per-node so enforcement can roll out canary-first; +/// unsetting it (and restarting) is the kill switch. +pub const PRICE_FLOOR_ENFORCE_ENV: &str = "ANT_PRICE_FLOOR_ENFORCE"; + +/// Environment variable overriding the price-floor tolerance percentage +/// (`0..=100`). Invalid or absent values use the default. +pub const PRICE_FLOOR_TOLERANCE_ENV: &str = "ANT_PRICE_FLOOR_TOLERANCE_PERCENT"; + +/// Receiver-side price floor for single-node store admissions. +/// +/// ADR-0004 gives every quote a price CEILING (`price == +/// calculate_price(committed_key_count)`), but a ceiling is not a revenue +/// floor: a modified client can fetch quotes from the whole neighbourhood and +/// settle only the cheapest valid one in a 1-quote proof. This policy is the +/// floor half: the settled amount must also clear this receiver's own +/// commitment-bound price, scaled by a tolerance. +/// +/// The floor is computed from the SAME live commitment the local +/// `QuoteGenerator` prices from (the committed responsible key count), never +/// from the raw on-disk record count — comparing unlike counts is what made +/// the pre-ADR-0004 floor false-reject honest quotes. +/// +/// Rollout is shadow-first: with `enforce == false` (the default) the floor +/// is evaluated and logged on every single-node store admission but never +/// rejects. Enforcement is enabled per node via [`PRICE_FLOOR_ENFORCE_ENV`] +/// for canary rollout. A floor rejection is an economic admission decision +/// only — it must never feed trust/misbehaviour scoring. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PriceFloorConfig { + /// When true, a below-floor settled payment is rejected. When false + /// (default), the verifier only logs would-reject telemetry. + pub enforce: bool, + /// Floor as a percentage of the local commitment-bound price (`0..=100`). + pub tolerance_percent: u64, +} + +impl Default for PriceFloorConfig { + fn default() -> Self { + Self { + enforce: false, + tolerance_percent: PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT, + } + } +} + +impl PriceFloorConfig { + /// Build from the environment (see [`PRICE_FLOOR_ENFORCE_ENV`] and + /// [`PRICE_FLOOR_TOLERANCE_ENV`]). Never panics. + /// + /// An unset tolerance uses the default. A tolerance that is *present but + /// invalid* (unparseable or `> 100`) is an operator error: rather than + /// silently enforce at the default, this **fails closed to shadow mode** + /// (`enforce = false`) and logs a prominent error, so a fat-fingered + /// tolerance can never enforce an unintended floor against real payments. + #[must_use] + pub fn from_env() -> Self { + let enforce_requested = std::env::var(PRICE_FLOOR_ENFORCE_ENV) + .is_ok_and(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "True")); + + let tolerance_raw = std::env::var(PRICE_FLOOR_TOLERANCE_ENV).ok(); + let valid_tolerance = tolerance_raw + .as_deref() + .map(str::trim) + .and_then(|s| s.parse::().ok()) + .filter(|percent| *percent <= 100); + // A tolerance that is set but does not parse to `0..=100` is an operator + // error. Fail CLOSED: never enforce a tolerance the operator did not + // actually specify. + let tolerance_present_but_invalid = tolerance_raw.is_some() && valid_tolerance.is_none(); + + if tolerance_present_but_invalid { + if enforce_requested { + crate::logging::error!( + "{PRICE_FLOOR_TOLERANCE_ENV}={tolerance_raw:?} is not an integer in 0..=100; \ + refusing to enforce the price floor with an unspecified tolerance. \ + Price-floor enforcement DISABLED (shadow mode). Fix the value and restart \ + to enable enforcement." + ); + } else { + crate::logging::warn!( + "{PRICE_FLOOR_TOLERANCE_ENV}={tolerance_raw:?} is not an integer in 0..=100; \ + using the default {PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT}% for shadow-mode \ + telemetry." + ); + } + } + + Self { + enforce: enforce_requested && !tolerance_present_but_invalid, + tolerance_percent: valid_tolerance.unwrap_or(PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT), + } + } +} + #[derive(Clone, Copy)] struct LegacyMedianCandidate<'a> { encoded_peer_id: &'a evmlib::EncodedPeerId, @@ -116,15 +221,28 @@ pub struct PaymentVerifierConfig { /// Kept in the verifier config for payment policies that bind receipts to /// this node's payout address. pub local_rewards_address: RewardsAddress, + /// Receiver-side price floor policy (shadow mode by default). + pub price_floor: PriceFloorConfig, } /// The fresh admission path a payment proof is being verified for. /// -/// - **`ClientPut`** — the node is admitting a chunk store. The verifier -/// applies store-strength cache semantics and live payment checks. +/// - **`ClientPut`** — the node is admitting a chunk store from a direct +/// client PUT. The verifier applies store-strength cache semantics and live +/// payment checks. +/// - **`FreshReplication`** — the node is admitting a chunk store via the +/// immediate fresh-write fan-out. The receiver is about to store the newly +/// written chunk as if the client PUT it there directly, so this context is +/// verified EXACTLY like `ClientPut` (store-strength cache semantics, same +/// live checks, same price-floor policy). It exists as a separate variant so +/// price-floor telemetry can distinguish direct ingress from fan-out — the +/// two paths can legitimately diverge during commitment rotation, and the +/// floor policy for fan-out must be tunable from observed data without +/// touching direct-PUT behaviour. /// - **`PaidListAdmission`** — the node is admitting fresh paid-list metadata. -/// It runs the same live payment checks as `ClientPut`, but writes a weaker -/// cache entry that does not authorize future chunk stores. +/// It runs the same live payment checks, but writes a weaker cache entry +/// that does not authorize future chunk stores. The price floor never +/// applies here: paid-list records reprice no fresh economic decision. /// /// The caller must check local receiver/admission membership before invoking /// the verifier for replication admission: fresh chunk replication requires @@ -134,23 +252,35 @@ pub struct PaymentVerifierConfig { /// payment proof validity and that the paid quote's issuer is in the K closest /// peers for the quoted chunk address. /// -/// Immediate fresh chunk replication is different: the receiver is about to -/// store the newly written chunk as if the client PUT it there directly, so -/// that call site deliberately uses `ClientPut`. -/// /// Later neighbour-sync repair does not include proof-of-payment bytes and /// does not call this verifier. It authorizes repair from network evidence: /// majority storage among the configured close group, or majority paid-list /// membership among the closest K. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VerificationContext { - /// The node is admitting a chunk store with store-strength cache semantics. + /// The node is admitting a chunk store from a direct client PUT, with + /// store-strength cache semantics. ClientPut, + /// The node is admitting a chunk store via immediate fresh replication — + /// verified identically to `ClientPut`, split out for price-floor + /// telemetry and policy. + FreshReplication, /// The node is admitting fresh paid-list metadata with paid-list-strength /// cache semantics. PaidListAdmission, } +impl VerificationContext { + /// True for contexts that admit chunk BYTES with store-strength cache + /// semantics: direct client PUT and immediate fresh replication. These two + /// are verified identically everywhere — the only policy that reads the + /// variant itself (rather than this predicate) is price-floor telemetry. + #[must_use] + pub fn is_store_admission(self) -> bool { + matches!(self, Self::ClientPut | Self::FreshReplication) + } +} + /// Status returned by payment verification. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaymentStatus { @@ -248,11 +378,15 @@ pub struct PaymentVerifier { /// midpoint in the live DHT. `None` in unit tests that don't exercise /// live-DHT checks; production startup MUST call [`attach_p2p_node`]. p2p_node: RwLock>>, - /// LMDB storage handle, attached post-construction so the paid-quote - /// price-floor check can read the authoritative on-disk record count without - /// depending on a side counter that may drift from replication/repair/prune - /// paths. `None` in unit tests that pre-set [`Self::test_records_override`]; - /// production startup MUST call [`attach_storage`]. + /// LMDB storage handle, attached post-construction. Retained for + /// store-backed verifier checks that need the authoritative on-disk record + /// count without depending on a side counter that may drift from + /// replication/repair/prune paths. NOTE: the ADR-0006 price floor does NOT + /// read this — it is bound to the live storage commitment via + /// [`Self::local_commitment_source`], not the on-disk count (the old floor + /// compared unlike counts and false-rejected honest quotes). `None` in unit + /// tests that don't exercise store-backed checks; production wires it via + /// [`Self::attach_storage`]. storage: RwLock>>, /// Test-only override for the paid-quote issuer K-closest check. /// @@ -290,6 +424,18 @@ pub struct PaymentVerifier { /// pre-replication startup), in which case no first audit is scheduled. monetized_pin_tx: RwLock>>, + /// Price-floor input: the SAME live commitment source the local + /// `QuoteGenerator` prices from, read via the non-mutating snapshot so the + /// floor never extends commitment answerability. `None` until + /// [`Self::attach_local_commitment_source`] (unit tests, or storage + /// disabled / pre-replication startup) — the floor then evaluates against + /// the baseline price, which makes it vacuous rather than outage-inducing. + local_commitment_source: RwLock>>, + /// Live price-floor policy, initialized from + /// [`PaymentVerifierConfig::price_floor`]. Behind a lock so tests (and any + /// future ops surface) can flip enforcement without rebuilding the + /// verifier. + price_floor: RwLock, /// Configuration. config: PaymentVerifierConfig, } @@ -417,6 +563,8 @@ impl PaymentVerifier { .unwrap_or(NonZeroUsize::MIN), ))), monetized_pin_tx: RwLock::new(None), + local_commitment_source: RwLock::new(None), + price_floor: RwLock::new(config.price_floor), config, } } @@ -466,18 +614,47 @@ impl PaymentVerifier { self.config.close_group_size } - /// Attach the node's [`LmdbStorage`] handle so paid-quote price-floor - /// checks can query the authoritative on-disk record count. + /// Attach the node's [`LmdbStorage`] handle for store-backed verifier + /// checks that read the authoritative on-disk record count. /// - /// Production startup MUST call this once the storage exists; otherwise - /// client PUTs using paid-quote verification are rejected because - /// the local economic floor cannot be checked. Idempotent: calling twice - /// replaces the handle. + /// NOTE: the ADR-0006 price floor does NOT depend on this handle — it is + /// priced from the live storage commitment ([`Self::attach_local_commitment_source`]), + /// and a missing commitment prices the floor at baseline rather than + /// rejecting. So a node without storage attached still admits PUTs; this + /// attachment only feeds any current/future store-count-backed checks. + /// Idempotent: calling twice replaces the handle. pub fn attach_storage(&self, storage: Arc) { *self.storage.write() = Some(storage); debug!("PaymentVerifier: LmdbStorage attached for paid-quote price-floor checks"); } + /// Attach the live commitment source for the price floor: the SAME + /// `ResponderCommitmentState` the local `QuoteGenerator` prices from, so + /// the floor and this node's own quotes read one pricing basis by + /// construction. Read via the non-mutating snapshot only. Idempotent. + /// Absent (unit tests, storage disabled, pre-replication startup), the + /// floor evaluates against the baseline price — vacuous, never an outage. + pub fn attach_local_commitment_source( + &self, + source: Arc, + ) { + *self.local_commitment_source.write() = Some(source); + debug!("PaymentVerifier: local commitment source attached for price-floor checks"); + } + + /// The live price-floor policy this verifier applies. + #[must_use] + pub fn price_floor_config(&self) -> PriceFloorConfig { + *self.price_floor.read() + } + + /// Test-only setter for the price-floor policy, so enforcement and + /// tolerance can be exercised without environment variables. + #[cfg(any(test, feature = "test-utils"))] + pub fn set_price_floor_for_tests(&self, config: PriceFloorConfig) { + *self.price_floor.write() = config; + } + /// Test-only setter for local closest peers used by the paid-quote /// issuer K-closest check. #[cfg(any(test, feature = "test-utils"))] @@ -514,9 +691,10 @@ impl PaymentVerifier { /// 1. Check LRU cache (fast path) /// 2. If not cached, payment is required /// - /// The fast path is context-aware. A `ClientPut` lookup is satisfied only - /// by a close-group store verification. A `PaidListAdmission` lookup is - /// satisfied by either a paid-list or client-PUT verification. + /// The fast path is context-aware. A store-admission lookup (`ClientPut` / + /// `FreshReplication`) is satisfied only by a close-group store + /// verification. A `PaidListAdmission` lookup is satisfied by either a + /// paid-list or client-PUT verification. /// /// # Arguments /// @@ -533,11 +711,10 @@ impl PaymentVerifier { context: VerificationContext, ) -> PaymentStatus { // Check LRU cache (fast path) - let cached = match context { - VerificationContext::ClientPut => self.cache.contains_client_put_verified(xorname), - VerificationContext::PaidListAdmission => { - self.cache.contains_paid_list_verified(xorname) - } + let cached = if context.is_store_admission() { + self.cache.contains_client_put_verified(xorname) + } else { + self.cache.contains_paid_list_verified(xorname) }; if cached { if crate::logging::enabled!(crate::logging::Level::DEBUG) { @@ -695,11 +872,10 @@ impl PaymentVerifier { // Cache the verified xorname at the context's verification // strength. Stronger entries satisfy weaker future lookups, // but not the reverse. - match context { - VerificationContext::ClientPut => self.cache.insert(*xorname), - VerificationContext::PaidListAdmission => { - self.cache.insert_paid_list_verified(*xorname); - } + if context.is_store_admission() { + self.cache.insert(*xorname); + } else { + self.cache.insert_paid_list_verified(*xorname); } Ok(PaymentStatus::PaymentVerified) @@ -796,22 +972,25 @@ impl PaymentVerifier { 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; + // The paid (median) price and settled on-chain amount of the winning + // candidate, kept for the receiver-side price-floor policy below. + let mut verified_paid_quote: Option<(Amount, Amount)> = None; for candidate in candidates { + let paid_price = candidate.quote.price; match self .verify_legacy_median_candidate(xorname, candidate) .await { - Ok(()) => { - verified_paid_quote = true; + Ok(settled_amount) => { + verified_paid_quote = Some((paid_price, settled_amount)); break; } Err(err) => failures.push(err.to_string()), } } - if !verified_paid_quote { + let Some((paid_price, settled_amount)) = verified_paid_quote else { let xorname_hex = hex::encode(xorname); let details = if failures.is_empty() { "no median-priced candidates were available".to_string() @@ -821,7 +1000,14 @@ impl PaymentVerifier { return Err(Error::Payment(format!( "Median quote payment verification failed for {xorname_hex}: {details}" ))); - } + }; + + // Receiver-side price floor (single-node store admissions only). Runs + // AFTER the paid quote's signature and settlement verified above, so + // unauthenticated bundles can never poison floor telemetry. Shadow + // mode logs; enforcement rejects — an economic admission decision + // only, never trust/misbehaviour evidence. + self.enforce_price_floor(xorname, paid_price, settled_amount, context)?; // ADR-0004 observe-only telemetry: log off-curve quotes only AFTER the // paid (median) quote's ML-DSA-65 signature has verified above, so @@ -829,11 +1015,13 @@ impl PaymentVerifier { // `validate_quote_arithmetic` already rejected; this is a no-op there. Self::log_off_curve_single_node(payment); - // 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. - if context == VerificationContext::ClientPut { + // ADR-0004 cross-check + first-audit enqueue (store admissions only — + // direct client PUT and immediate fresh replication, exactly the paths + // that previously verified under `ClientPut`) 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. + if context.is_store_admission() { self.cross_check_quotes(payment, commitment_sidecars).await; } @@ -887,11 +1075,16 @@ impl PaymentVerifier { .collect()) } + /// Fully validate one median-priced candidate. On success returns the + /// settled on-chain amount for the candidate's quote hash, which the + /// price-floor policy compares against the receiver's own commitment + /// price (the settled amount, not the quoted price, so an honest client + /// may overpay a cheap quote to clear stricter receivers). async fn verify_legacy_median_candidate( &self, xorname: &XorName, candidate: LegacyMedianCandidate<'_>, - ) -> Result<()> { + ) -> Result { Self::validate_paid_quote_content(xorname, candidate)?; let issuer_peer_id = Self::validate_paid_quote_peer_binding(candidate.encoded_peer_id, candidate.quote)?; @@ -905,7 +1098,7 @@ impl PaymentVerifier { .completed_payment_amount(candidate.quote.hash()) .await?; if on_chain_amount >= candidate.expected_amount { - return Ok(()); + return Ok(on_chain_amount); } Err(Error::Payment(format!( @@ -914,6 +1107,79 @@ impl PaymentVerifier { ))) } + /// Receiver-side price floor for single-node store admissions. + /// + /// Requires the settled on-chain amount to also clear + /// `PAID_QUOTE_PAYMENT_MULTIPLIER × tolerance% × calculate_price(local + /// committed responsible key count)` — the same pricing basis this node's + /// own quotes use, never the raw on-disk record count (whose divergence + /// from commitment counts is what false-rejected honest quotes under the + /// pre-ADR-0004 floor). Together with the per-quote check above, the + /// effective rule is `settled >= 3 × max(paid_price, tolerated_floor)`. + /// + /// No live current commitment — fresh node, retired commitment, restart + /// window, or nothing attached — prices the floor at baseline + /// (`calculate_price(0)`). For any on-curve quote (`price == + /// calculate_price(n)`, `n >= 0`) an honest 3× settlement then clears the + /// baseline floor, so a no-commitment receiver never rejects honest + /// traffic. (A settlement priced *below* baseline could still be rejected + /// against it — but that only arises from an off-curve quote, which the + /// ADR-0004 arithmetic gate rejects outright once enforced; economically it + /// is a decline, not an availability regression.) + /// + /// Always emits one telemetry line per evaluated admission (target + /// `ant_node::payment::price_floor`) so shadow mode measures the exact + /// rejections enforcement would cause. Rejects only when + /// [`PriceFloorConfig::enforce`] is set. + fn enforce_price_floor( + &self, + xorname: &XorName, + paid_price: Amount, + settled_amount: Amount, + context: VerificationContext, + ) -> Result<()> { + if !context.is_store_admission() { + return Ok(()); + } + + let floor = *self.price_floor.read(); + let source = self.local_commitment_source.read().as_ref().map(Arc::clone); + let local_key_count = source + .and_then(|s| s.current_binding_snapshot()) + .map_or(0u32, |binding| binding.key_count); + let local_price = calculate_price(usize::try_from(local_key_count).unwrap_or(usize::MAX)); + + // Amount is a U256: local_price is bounded by the saturating pricing + // curve and the multipliers are <= 300, so this arithmetic cannot + // overflow; saturating keeps that a proof instead of an assumption. + let tolerance_percent = floor.tolerance_percent.min(100); + let tolerated_price = + local_price.saturating_mul(Amount::from(tolerance_percent)) / Amount::from(100u64); + let required_amount = + tolerated_price.saturating_mul(Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER)); + let below_floor = settled_amount < required_amount; + + info!( + target: "ant_node::payment::price_floor", + "Price floor: context={context:?}, xorname={}, paid_price={paid_price}, \ + settled={settled_amount}, local_key_count={local_key_count}, \ + local_price={local_price}, tolerance_percent={tolerance_percent}, \ + required={required_amount}, below_floor={below_floor}, enforce={}", + hex::encode(xorname), + floor.enforce, + ); + + if below_floor && floor.enforce { + return Err(Error::Payment(format!( + "Settled payment {settled_amount} is below this node's price floor \ + {required_amount} ({PAID_QUOTE_PAYMENT_MULTIPLIER}x {tolerance_percent}% of \ + local commitment price {local_price}, committed key count {local_key_count})" + ))); + } + + Ok(()) + } + fn validate_paid_quote_content( xorname: &XorName, candidate: LegacyMedianCandidate<'_>, @@ -2461,8 +2727,10 @@ impl PaymentVerifier { // 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. - if context == VerificationContext::ClientPut { + // fetches). Store admissions only (direct PUT + immediate fresh + // replication, the paths that previously verified under `ClientPut`) — + // a paid-list receipt's pins have aged out. + if context.is_store_admission() { self.cross_check_merkle_candidates( &merkle_proof.winner_pool, &merkle_proof.commitment_sidecars, @@ -2572,6 +2840,7 @@ mod tests { cache_capacity: 100, close_group_size: CLOSE_GROUP_SIZE, local_rewards_address: RewardsAddress::new([1u8; 20]), + price_floor: PriceFloorConfig::default(), }; PaymentVerifier::new(config) } @@ -2997,6 +3266,243 @@ mod tests { ); } + /// Fixed-count [`crate::payment::quote::CommitmentSource`] standing in for + /// the receiver's live commitment state in price-floor tests. + struct FixedFloorSource { + key_count: u32, + } + + impl crate::payment::quote::CommitmentSource for FixedFloorSource { + fn current_binding_for_quote(&self) -> Option { + self.current_binding_snapshot() + } + + fn current_binding_snapshot(&self) -> Option { + Some(crate::payment::quote::QuoteBinding { + key_count: self.key_count, + pin: [0u8; 32], + }) + } + + fn commitment_blob_for_pin(&self, _pin: [u8; 32]) -> Option> { + None + } + } + + /// Receiver commitment count for floor tests, chosen so the local price is + /// comfortably more than double the baseline (the 50% floor then rejects a + /// baseline-priced settlement). Asserted in each test so a pricing-curve + /// change fails loudly instead of silently weakening the tests. + const FLOOR_TEST_LOCAL_KEY_COUNT: u32 = 1_000_000; + + fn floor_test_verifier(enforce: bool) -> PaymentVerifier { + let verifier = create_test_verifier(); + verifier.attach_local_commitment_source(Arc::new(FixedFloorSource { + key_count: FLOOR_TEST_LOCAL_KEY_COUNT, + })); + verifier.set_price_floor_for_tests(PriceFloorConfig { + enforce, + tolerance_percent: PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT, + }); + let local_price = price_at_records(FLOOR_TEST_LOCAL_KEY_COUNT as usize); + assert!( + local_price > price_at_records(0) * Amount::from(2u64), + "floor tests need a local price above twice baseline, got {local_price}" + ); + verifier + } + + /// The settled amount the floor requires: 3x the tolerated fraction of the + /// receiver's own commitment-bound price. + fn floor_required_amount() -> Amount { + let tolerated = price_at_records(FLOOR_TEST_LOCAL_KEY_COUNT as usize) + * Amount::from(PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT) + / Amount::from(100u64); + tolerated * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER) + } + + /// One cheap (baseline-priced) quote paid `settled`: the cheapest-of-K + /// omission shape a modified client can always produce. + fn cheap_single_quote_proof( + verifier: &PaymentVerifier, + xorname: XorName, + settled: Amount, + ) -> Vec { + let (peer_id, quote) = make_signed_quote(xorname, price_at_records(0), 1); + let peer_quotes = vec![(peer_id, quote.clone())]; + mark_k_closest_paid_candidates(verifier, &peer_quotes); + mark_candidate_paid(verifier, "e, settled); + serialize_proof(peer_quotes) + } + + #[test] + fn store_admission_contexts_cover_puts_and_fresh_replication_only() { + assert!(VerificationContext::ClientPut.is_store_admission()); + assert!(VerificationContext::FreshReplication.is_store_admission()); + assert!(!VerificationContext::PaidListAdmission.is_store_admission()); + } + + #[tokio::test] + async fn test_price_floor_shadow_mode_accepts_below_floor_settlement() { + let verifier = floor_test_verifier(false); + let xorname = [0xC1u8; 32]; + let settled = price_at_records(0) * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER); + assert!(settled < floor_required_amount()); + let proof_bytes = cheap_single_quote_proof(&verifier, xorname, settled); + + let result = verifier + .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) + .await; + + assert_eq!( + result.expect("shadow mode must never reject on the floor"), + PaymentStatus::PaymentVerified + ); + } + + #[tokio::test] + async fn test_price_floor_enforced_rejects_cheapest_of_k_settlement() { + let verifier = floor_test_verifier(true); + let xorname = [0xC2u8; 32]; + let settled = price_at_records(0) * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER); + let proof_bytes = cheap_single_quote_proof(&verifier, xorname, settled); + + let err = verifier + .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) + .await + .expect_err("a 3x-baseline settlement must not clear a fuller receiver's floor"); + + assert!( + format!("{err}").contains("below this node's price floor"), + "Error should name the price floor: {err}" + ); + } + + #[tokio::test] + async fn test_price_floor_enforced_accepts_exactly_at_floor_overpayment() { + // An honest client may OVERPAY a cheap quote to clear stricter + // receivers: the floor compares the settled amount, not the quote + // price, and exactly-at-floor must pass. + let verifier = floor_test_verifier(true); + let xorname = [0xC3u8; 32]; + let proof_bytes = cheap_single_quote_proof(&verifier, xorname, floor_required_amount()); + + let result = verifier + .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) + .await; + + assert_eq!( + result.expect("an exactly-at-floor settlement must pass"), + PaymentStatus::PaymentVerified + ); + } + + #[tokio::test] + async fn test_price_floor_enforced_applies_to_fresh_replication() { + // Fresh replication is the same fresh economic event as a direct PUT; + // exempting it would let one cheap accepting node fan the proof out + // around every other storer's floor. + let verifier = floor_test_verifier(true); + let xorname = [0xC4u8; 32]; + let settled = price_at_records(0) * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER); + let proof_bytes = cheap_single_quote_proof(&verifier, xorname, settled); + + let err = verifier + .verify_payment( + &xorname, + Some(&proof_bytes), + VerificationContext::FreshReplication, + ) + .await + .expect_err("fresh replication must enforce the same floor as direct PUTs"); + + assert!( + format!("{err}").contains("below this node's price floor"), + "Error should name the price floor: {err}" + ); + } + + #[tokio::test] + async fn test_price_floor_never_reprices_paid_list_admission() { + let verifier = floor_test_verifier(true); + let xorname = [0xC5u8; 32]; + let settled = price_at_records(0) * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER); + let proof_bytes = cheap_single_quote_proof(&verifier, xorname, settled); + + let result = verifier + .verify_payment( + &xorname, + Some(&proof_bytes), + VerificationContext::PaidListAdmission, + ) + .await; + + assert_eq!( + result.expect("paid-list admission reprices no fresh economic decision"), + PaymentStatus::PaymentVerified + ); + } + + #[tokio::test] + async fn test_price_floor_enforced_without_commitment_prices_baseline() { + // No live commitment (fresh node, retired, restart window, or nothing + // attached) must price the floor at baseline — a vacuous floor, never + // an outage: an honest 3x-baseline settlement always clears it. + let verifier = create_test_verifier(); + verifier.set_price_floor_for_tests(PriceFloorConfig { + enforce: true, + tolerance_percent: PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT, + }); + let xorname = [0xC6u8; 32]; + let settled = price_at_records(0) * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER); + let proof_bytes = cheap_single_quote_proof(&verifier, xorname, settled); + + let result = verifier + .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) + .await; + + assert_eq!( + result.expect("a baseline floor must accept an honest baseline settlement"), + PaymentStatus::PaymentVerified + ); + } + + /// `from_env` must fail CLOSED: a present-but-invalid tolerance can never + /// leave enforcement on with a tolerance the operator did not specify. + /// (This test owns the `PRICE_FLOOR_*` env vars — no other test reads them.) + #[test] + fn price_floor_from_env_fails_closed_on_invalid_tolerance() { + // Defaults with nothing set. + std::env::remove_var(PRICE_FLOOR_ENFORCE_ENV); + std::env::remove_var(PRICE_FLOOR_TOLERANCE_ENV); + let cfg = PriceFloorConfig::from_env(); + assert!(!cfg.enforce); + assert_eq!(cfg.tolerance_percent, PRICE_FLOOR_DEFAULT_TOLERANCE_PERCENT); + + // Enforce on, valid tolerance: honoured. + std::env::set_var(PRICE_FLOOR_ENFORCE_ENV, "1"); + std::env::set_var(PRICE_FLOOR_TOLERANCE_ENV, "80"); + let cfg = PriceFloorConfig::from_env(); + assert!(cfg.enforce); + assert_eq!(cfg.tolerance_percent, 80); + + // Enforce on, out-of-range tolerance: enforcement DISABLED (fail closed). + std::env::set_var(PRICE_FLOOR_TOLERANCE_ENV, "150"); + let cfg = PriceFloorConfig::from_env(); + assert!( + !cfg.enforce, + "an out-of-range tolerance must not silently enforce a default" + ); + + // Enforce on, unparseable tolerance: also disabled. + std::env::set_var(PRICE_FLOOR_TOLERANCE_ENV, "loose"); + let cfg = PriceFloorConfig::from_env(); + assert!(!cfg.enforce); + + std::env::remove_var(PRICE_FLOOR_ENFORCE_ENV); + std::env::remove_var(PRICE_FLOOR_TOLERANCE_ENV); + } + #[tokio::test] async fn test_legacy_single_quote_proof_requires_three_x_payment() { let verifier = create_test_verifier(); @@ -3758,6 +4264,7 @@ mod tests { for context in [ VerificationContext::ClientPut, + VerificationContext::FreshReplication, VerificationContext::PaidListAdmission, ] { let err = verifier @@ -3797,6 +4304,7 @@ mod tests { for context in [ VerificationContext::ClientPut, + VerificationContext::FreshReplication, VerificationContext::PaidListAdmission, ] { let err = verifier @@ -4316,6 +4824,7 @@ mod tests { cache_capacity: 100, close_group_size: CLOSE_GROUP_SIZE, local_rewards_address: RewardsAddress::new([1u8; 20]), + price_floor: PriceFloorConfig::default(), }; let verifier = PaymentVerifier::new(config); diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs index 2fdda12b..027edc9a 100644 --- a/src/replication/commitment_state.rs +++ b/src/replication/commitment_state.rs @@ -771,6 +771,17 @@ impl crate::payment::quote::CommitmentSource for ResponderCommitmentState { }) } + fn current_binding_snapshot(&self) -> Option { + // Read-only sibling of `current_binding_for_quote`: same live current + // commitment (via `current()`), but no gossip stamp — the price-floor + // consumer reads pricing state without extending answerability. + self.current() + .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::`), diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 6495dc11..a183d5bd 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -172,7 +172,7 @@ fn queue_first_audit_event( const RR_PREFIX: &str = "/rr/"; fn fresh_offer_payment_context() -> VerificationContext { - VerificationContext::ClientPut + VerificationContext::FreshReplication } fn paid_notify_payment_context() -> VerificationContext { @@ -2675,10 +2675,11 @@ async fn handle_fresh_offer( // Gap 1: Validate PoP via PaymentVerifier. Fresh replication is still // part of the immediate write fan-out: this receiver is about to store the // record as if the client had PUT it here directly. Storage admission - // was checked above before proof work. ClientPut verification applies - // store-strength cache semantics, paid-quote issuer K-closeness checks - // for single-node proofs, and merkle candidate closeness for merkle - // proofs. + // was checked above before proof work. FreshReplication verification is + // identical to ClientPut — store-strength cache semantics, paid-quote + // issuer K-closeness checks for single-node proofs, merkle candidate + // closeness for merkle proofs, and the same price-floor policy — the + // distinct context only labels price-floor telemetry. match payment_verifier .verify_payment( &offer.key, @@ -5422,11 +5423,13 @@ mod tests { } #[test] - fn fresh_offer_runs_client_put_payment_checks() { - assert_eq!( - fresh_offer_payment_context(), - VerificationContext::ClientPut - ); + fn fresh_offer_runs_store_admission_payment_checks() { + let context = fresh_offer_payment_context(); + assert_eq!(context, VerificationContext::FreshReplication); + // Fresh replication must keep verifying exactly like a direct client + // PUT (store-strength cache, same live checks); the variant only + // exists so price-floor telemetry can tell the two paths apart. + assert!(context.is_store_admission()); } #[test] diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 9e5e7d0c..08a40754 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -170,11 +170,17 @@ impl AntProtocol { /// (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. + /// + /// The same source is forwarded to the payment verifier's price-floor + /// policy (read-only snapshot), so this node's quotes and its floor are + /// priced from one commitment by construction. pub fn attach_commitment_source( &self, source: Arc, ) { - self.quote_generator.attach_commitment_source(source); + self.quote_generator + .attach_commitment_source(Arc::clone(&source)); + self.payment_verifier.attach_local_commitment_source(source); } /// ADR-0004: return the proof with any commitment sidecars stripped, so a @@ -401,11 +407,15 @@ impl AntProtocol { Ok(_) => { let content_len = request.content.len(); info!("Stored chunk {addr_hex} ({content_len} bytes)"); - // Bump the in-memory fallback counter. Both pricing and the - // paid-quote floor now read LmdbStorage::current_chunks() directly, - // so this counter only matters when no storage is attached - // (unit tests / mis-configured startup). Kept warm so that - // fallback path stays roughly accurate. + // Bump the in-memory fallback record counter. Under ADR-0004 + // neither pricing nor the receiver-side price floor reads this + // counter: both are bound to the live storage commitment + // (committed responsible key count), pricing via the quote + // generator's commitment source and the floor via the + // verifier's non-mutating snapshot of the same commitment. The + // counter only matters as a warm fallback surface when no + // commitment source is attached (unit tests / pre-replication + // startup). self.quote_generator.record_store(); // 7. Notify replication engine for fresh fan-out. @@ -753,6 +763,7 @@ mod tests { cache_capacity: 100_000, close_group_size: crate::ant_protocol::CLOSE_GROUP_SIZE, local_rewards_address: rewards_address, + price_floor: crate::payment::PriceFloorConfig::default(), }; let payment_verifier = Arc::new(PaymentVerifier::new(payment_config)); let metrics_tracker = QuotingMetricsTracker::new(100); diff --git a/tests/e2e/data_types/chunk.rs b/tests/e2e/data_types/chunk.rs index d66a4278..09729b93 100644 --- a/tests/e2e/data_types/chunk.rs +++ b/tests/e2e/data_types/chunk.rs @@ -64,8 +64,8 @@ mod tests { use crate::{TestHarness, TestNetwork}; use ant_node::payment::{ - EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, - QuotingMetricsTracker, + EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, PriceFloorConfig, + QuoteGenerator, QuotingMetricsTracker, }; use ant_node::storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; use ant_node::ReplicationConfig; @@ -445,6 +445,7 @@ mod tests { cache_capacity: 100, close_group_size: ReplicationConfig::default().close_group_size, local_rewards_address: rewards_address, + price_floor: PriceFloorConfig::default(), }); let metrics_tracker = QuotingMetricsTracker::new(100); let quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 3eb19a8c..551cbbbe 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1141,6 +1141,7 @@ impl TestNetwork { cache_capacity: TEST_PAYMENT_CACHE_CAPACITY, close_group_size: replication_config.close_group_size, local_rewards_address: rewards_address, + price_floor: ant_node::payment::PriceFloorConfig::default(), }; let payment_verifier = PaymentVerifier::new(payment_config); diff --git a/tests/poc_price_floor_live.rs b/tests/poc_price_floor_live.rs new file mode 100644 index 00000000..3ba4d877 --- /dev/null +++ b/tests/poc_price_floor_live.rs @@ -0,0 +1,272 @@ +//! Live-Anvil verification of the receiver-side price floor (ADR-0006). +//! +//! The unit tests in `src/payment/verifier.rs` prove the floor logic against +//! the `completed_payment` test override. This suite closes the gap the +//! override cannot: it exercises the **real** production paths end to end — +//! +//! - a **real deployed payment vault** on a local Anvil chain, paid with a +//! **real on-chain `pay_for_quotes` transaction**, read back through the +//! production `completedPayments` contract call (NOT the test override); and +//! - the **real `ResponderCommitmentState`** as the floor's commitment source, +//! so the local price is derived through the same production +//! `current_binding_snapshot` → `calculate_price(committed_key_count)` path +//! this node's own `QuoteGenerator` prices from. +//! +//! What each test asserts maps directly to a PR claim: +//! - shadow mode never rejects, even when the settlement is below floor; +//! - enforcement rejects the cheapest-of-K one-quote settlement; +//! - enforcement accepts an honest at-floor overpayment; +//! - reading the floor does not mutate commitment answerability. +//! +//! Run: `cargo test --test poc_price_floor_live --features test-utils`. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; +use std::time::SystemTime; + +use ant_node::payment::{ + calculate_price, serialize_single_node_proof, EvmVerifierConfig, PaymentProof, PaymentStatus, + PaymentVerifier, PaymentVerifierConfig, PriceFloorConfig, VerificationContext, +}; +use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; +use ant_node::CLOSE_GROUP_SIZE; +use evmlib::common::Amount; +use evmlib::testnet::Testnet; +use evmlib::wallet::Wallet; +use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; +use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; + +/// Committed key count for the receiver in the floor tests. Chosen large +/// enough that `calculate_price(count)` sits well above twice the baseline, so +/// a baseline-priced (cheapest-of-K) settlement is genuinely below a 50% +/// floor. Asserted in-test so a pricing-curve change fails loudly. +const RECEIVER_COMMITTED_KEYS: u32 = 500_000; + +/// The receiver's own commitment-derived local price, via the real pricing +/// curve — the exact value the production floor computes. +fn receiver_local_price() -> Amount { + calculate_price(RECEIVER_COMMITTED_KEYS as usize) +} + +/// 3x baseline: an honest 3x settlement of the *cheapest* possible quote +/// (committed count 0). This is the cheapest-of-K amount a modified client can +/// settle while still paying an honest 3x of the quote it chose. +fn cheapest_of_k_settlement() -> Amount { + calculate_price(0) * Amount::from(3u64) +} + +/// The settled amount an at-floor honest overpayment must reach under a 50% +/// floor: `3 x 50% x local_price`. +fn at_floor_settlement() -> Amount { + receiver_local_price() * Amount::from(50u64) / Amount::from(100u64) * Amount::from(3u64) +} + +fn keypair() -> (MlDsaPublicKey, MlDsaSecretKey) { + ml_dsa_65().generate_keypair().unwrap() +} + +/// Build the receiver's real commitment source, rotated to a commitment over +/// `RECEIVER_COMMITTED_KEYS` keys so `current_binding_snapshot` reports that +/// count — the same object type production wires into both the quote generator +/// and (via this PR) the verifier's floor. +fn receiver_commitment_source() -> Arc { + let (public_key, secret_key) = keypair(); + let peer_id_bytes = *blake3::hash(&public_key.to_bytes()).as_bytes(); + let entries: Vec<([u8; 32], [u8; 32])> = (0..RECEIVER_COMMITTED_KEYS) + .map(|i| { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_be_bytes()); + (k, *blake3::hash(&k).as_bytes()) + }) + .collect(); + let built = + BuiltCommitment::build(entries, &peer_id_bytes, &secret_key, &public_key.to_bytes()) + .unwrap(); + assert_eq!(built.commitment().key_count, RECEIVER_COMMITTED_KEYS); + let state = ResponderCommitmentState::new(); + state.rotate(built); + Arc::new(state) +} + +/// A real ML-DSA-signed single-node quote for `xorname` at `price`, plus the +/// issuer's encoded peer id (BLAKE3(pubkey), matching production binding). +fn signed_quote( + xorname: [u8; 32], + price: Amount, + rewards_address: RewardsAddress, +) -> (EncodedPeerId, PaymentQuote) { + let (public_key, secret_key) = keypair(); + let pub_key_bytes: Vec = public_key.to_bytes(); + let peer_id = EncodedPeerId::new(*blake3::hash(&pub_key_bytes).as_bytes()); + let mut quote = PaymentQuote { + content: xor_name::XorName(xorname), + timestamp: SystemTime::now(), + price, + rewards_address, + committed_key_count: 0, + commitment_pin: None, + pub_key: pub_key_bytes, + signature: Vec::new(), + }; + quote.signature = ml_dsa_65() + .sign(&secret_key, "e.bytes_for_sig()) + .unwrap() + .to_bytes(); + (peer_id, quote) +} + +fn serialize_proof(peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>) -> Vec { + let proof = PaymentProof { + proof_of_payment: ProofOfPayment { peer_quotes }, + tx_hashes: vec![], + commitment_sidecars: vec![], + }; + serialize_single_node_proof(&proof).unwrap() +} + +/// Build a verifier wired to the live Anvil vault and the real commitment +/// source, with the given floor policy. No `P2PNode` is attached, so the +/// issuer K-closest check fail-opens under `test-utils` — that isolates the +/// on-chain settlement read and the floor as the load-bearing checks. +fn live_verifier(network: evmlib::Network, floor: PriceFloorConfig) -> PaymentVerifier { + let verifier = PaymentVerifier::new(PaymentVerifierConfig { + evm: EvmVerifierConfig { network }, + cache_capacity: 128, + close_group_size: CLOSE_GROUP_SIZE, + local_rewards_address: RewardsAddress::new([0x11; 20]), + price_floor: floor, + }); + verifier.attach_local_commitment_source(receiver_commitment_source()); + verifier +} + +/// Pay `amount` on-chain for `quote` via a real vault transaction, then return +/// the serialized single-node proof for that quote. The quote's price stays the +/// cheap baseline; the *settled* amount is what the floor compares. +async fn pay_and_build_proof( + wallet: &Wallet, + peer_id: EncodedPeerId, + quote: PaymentQuote, + amount: Amount, +) -> Vec { + let quote_payment = (quote.hash(), quote.rewards_address, amount); + wallet + .pay_for_quotes(std::iter::once(quote_payment)) + .await + .expect("on-chain pay_for_quotes should settle on Anvil"); + serialize_proof(vec![(peer_id, quote)]) +} + +/// Shadow mode (the shipping default) must NEVER reject on the floor, even when +/// the real on-chain settlement is genuinely below the receiver's floor. +#[tokio::test(flavor = "multi_thread")] +async fn shadow_mode_accepts_below_floor_real_settlement() { + let testnet = Testnet::new().await.unwrap(); + let network = testnet.to_network(); + let wallet = Wallet::new_from_private_key( + network.clone(), + &testnet.default_wallet_private_key().unwrap(), + ) + .unwrap(); + let verifier = live_verifier(network, PriceFloorConfig::default()); + assert!(!verifier.price_floor_config().enforce, "default is shadow"); + assert!( + cheapest_of_k_settlement() < at_floor_settlement(), + "test fixture must keep the cheap settlement below the floor" + ); + + let xorname = [0xA1u8; 32]; + let (peer_id, quote) = signed_quote(xorname, calculate_price(0), RewardsAddress::new([2; 20])); + let proof = pay_and_build_proof(&wallet, peer_id, quote, cheapest_of_k_settlement()).await; + + let status = verifier + .verify_payment(&xorname, Some(&proof), VerificationContext::ClientPut) + .await + .expect("shadow mode must accept a real below-floor settlement"); + assert_eq!(status, PaymentStatus::PaymentVerified); +} + +/// Enforcement rejects the cheapest-of-K one-quote settlement: a real 3x-baseline +/// on-chain payment against a fuller receiver, read from the real vault. +#[tokio::test(flavor = "multi_thread")] +async fn enforced_floor_rejects_cheapest_of_k_real_settlement() { + let testnet = Testnet::new().await.unwrap(); + let network = testnet.to_network(); + let wallet = Wallet::new_from_private_key( + network.clone(), + &testnet.default_wallet_private_key().unwrap(), + ) + .unwrap(); + let verifier = live_verifier( + network, + PriceFloorConfig { + enforce: true, + tolerance_percent: 50, + }, + ); + + let xorname = [0xA2u8; 32]; + let (peer_id, quote) = signed_quote(xorname, calculate_price(0), RewardsAddress::new([3; 20])); + let proof = pay_and_build_proof(&wallet, peer_id, quote, cheapest_of_k_settlement()).await; + + let err = verifier + .verify_payment(&xorname, Some(&proof), VerificationContext::ClientPut) + .await + .expect_err("a real 3x-baseline settlement must not clear a fuller receiver's floor"); + assert!( + err.to_string().contains("below this node's price floor"), + "unexpected rejection reason: {err}" + ); +} + +/// Enforcement accepts an honest at-floor overpayment: the client overpays the +/// cheap quote to `3 x 50% x local_price`, settled for real on-chain. +#[tokio::test(flavor = "multi_thread")] +async fn enforced_floor_accepts_at_floor_real_overpayment() { + let testnet = Testnet::new().await.unwrap(); + let network = testnet.to_network(); + let wallet = Wallet::new_from_private_key( + network.clone(), + &testnet.default_wallet_private_key().unwrap(), + ) + .unwrap(); + let source = receiver_commitment_source(); + // Snapshot the commitment hash BEFORE verification so we can prove the + // floor read did not rotate/refresh/age retention (claim: non-mutating). + let hash_before = source.current().map(|c| c.hash()); + let verifier = PaymentVerifier::new(PaymentVerifierConfig { + evm: EvmVerifierConfig { + network: network.clone(), + }, + cache_capacity: 128, + close_group_size: CLOSE_GROUP_SIZE, + local_rewards_address: RewardsAddress::new([0x11; 20]), + price_floor: PriceFloorConfig { + enforce: true, + tolerance_percent: 50, + }, + }); + let concrete = Arc::clone(&source); + let dyn_source: Arc = concrete; + verifier.attach_local_commitment_source(dyn_source); + + let xorname = [0xA3u8; 32]; + let (peer_id, quote) = signed_quote(xorname, calculate_price(0), RewardsAddress::new([4; 20])); + let proof = pay_and_build_proof(&wallet, peer_id, quote, at_floor_settlement()).await; + + let status = verifier + .verify_payment(&xorname, Some(&proof), VerificationContext::ClientPut) + .await + .expect("an exactly-at-floor real settlement must pass under enforcement"); + assert_eq!(status, PaymentStatus::PaymentVerified); + + // The floor read the commitment via the non-mutating snapshot: the current + // commitment (and thus retention) is unchanged after verification. + let hash_after = source.current().map(|c| c.hash()); + assert_eq!( + hash_before, hash_after, + "reading the floor must not mutate commitment state" + ); + assert_eq!(source.retained_slot_count(), 1); +}