feat: commitment-bound quote pricing (ADR-0004)#149
Conversation
There was a problem hiding this comment.
Pull request overview
Implements ADR-0003 commitment-bound quote pricing on the node side by binding quote price to the node’s signed storage commitment (pin + committed key count), shipping commitment sidecars with quotes, and extending replication/audit machinery to ingest commitments, fetch by pin, and audit/credit holders accordingly.
Changes:
- Bind quote pricing/signatures to
(committed_key_count, commitment_pin)and ship signed commitment sidecars with quote responses; strip sidecars before persisting/replicating receipts. - Extend replication protocol/types for commitment gossip, subtree audits (ADR-0002), pin-based commitment fetch, pruning retention veto, and holder-credit gating.
- Add extensive PoC + e2e tests and wire them into CI; update node startup to connect replication commitment state into quoting/verifier paths.
Reviewed changes
Copilot reviewed 33 out of 34 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/poc_bootstrap_stall.rs | Adds PoC regression test documenting bootstrap stall attack behavior. |
| tests/poc_audit_handler_live.rs | Adds live LMDB responder integration tests for subtree audit handler branches. |
| tests/e2e/testnet.rs | Adds retry-on-create for flaky transports; ensures replication engine only starts with identity. |
| tests/e2e/subtree_audit_testnet.rs | Adds end-to-end local testnet tests for subtree audit pass/fail scenarios. |
| tests/e2e/security_attacks.rs | Updates proof fixture to include new commitment_sidecars field. |
| tests/e2e/replication.rs | Updates replication tests for new protocol fields and adds prune retention-veto test. |
| tests/e2e/mod.rs | Registers new subtree-audit testnet module. |
| tests/e2e/merkle_payment.rs | Updates merkle candidate signing to include commitment-bound fields. |
| src/storage/handler.rs | Ships commitment sidecars with quotes; strips sidecars from proofs before replication; adds quote-metric resync + tests. |
| src/replication/types.rs | Adds FailureEvidence::QuoteCommitmentMismatch portable evidence variant. |
| src/replication/recent_provers.rs | Introduces bounded TTL recent-prover cache for holder-credit gating. |
| src/replication/quorum.rs | Adds holder-credit predicate variant to quorum evaluation and tests. |
| src/replication/pruning.rs | Adds commitment-based prune veto + TOCTOU guard; adjusts prune audit timeout semantics. |
| src/replication/protocol.rs | Extends replication wire types: subtree audit messages + commitment fetch by pin; piggybacks commitments in neighbor sync. |
| src/replication/neighbor_sync.rs | Threads optional commitment through sync request/response construction paths. |
| src/replication/config.rs | Adds v12 audit/backpressure constants; bumps replication protocol id to v2; revises audit timeout sizing. |
| src/replication/commitment.rs | Adds node-side Merkle tree + signing utilities; re-exports commitment types/pin/verify from ant-protocol. |
| src/replication/bootstrap.rs | Minor comment update. |
| src/payment/quote.rs | Implements commitment-bound quote pricing + commitment-source abstraction; updates signing bytes for both quote types. |
| src/payment/proof.rs | Re-exports added proof deserializer for single-node proofs. |
| src/payment/pricing.rs | Re-exports pricing formula from ant-protocol as single source of truth. |
| src/payment/metrics.rs | Adds authoritative metric overwrite (set_records) for quoting metrics. |
| src/node.rs | Wires replication commitment state into quote generator + verifier; refactors production rewards-address validation. |
| docs/adr/ADR-0003-implementation-slices.md | Adds implementation slicing notes and rollout/cutover clarifications. |
| docs/adr/ADR-0003-commitment-bound-quote-pricing.md | Adds ADR-0003 specification document. |
| docs/adr/ADR-0002-gossip-triggered-contiguous-subtree-audit.md | Adds ADR-0002 specification document. |
| Cargo.toml | Registers new PoC test binaries for explicit CI invocation. |
| .github/workflows/ci.yml | Runs new PoC suites and live handler tests in CI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// The quote price is driven by `records_stored()`. A monotonic store | ||
| /// counter would let a node delete chunks it was paid to hold yet keep | ||
| /// quoting as if it still held everything. Callers pass the authoritative | ||
| /// count of records the node ACTUALLY HOLDS (from the storage layer) so the | ||
| /// price reflects current holdings, including deletions and pruning. |
| /// Test-only: the record count the quote generator currently prices on. | ||
| /// Used to assert that quote-time resync tracks records actually held. | ||
| #[cfg(test)] | ||
| #[must_use] | ||
| pub(crate) fn priced_records_stored(&self) -> usize { |
| /// The quote price is driven by `QuoteGenerator::records_stored()`. Reading | ||
| /// the live LMDB entry count (an O(1) B-tree page-header read) right before | ||
| /// pricing makes the metric deletion-aware: any chunk removed by | ||
| /// [`LmdbStorage::delete`] or by the replication prune pass is reflected | ||
| /// immediately, with no risk of missing a delete path. | ||
| /// |
| // Price on records ACTUALLY HELD, not a monotonic store counter. | ||
| self.resync_quote_metric(); |
| // Price on records ACTUALLY HELD, not a monotonic store counter. | ||
| self.resync_quote_metric(); |
| // Drive a real quote; the priced count must equal records held (10), | ||
| // and the price must equal calculate_price(10) — the externally | ||
| // observable contract. | ||
| assert_eq!(priced_records_after_quote(&protocol), 10); | ||
| let price_full = calculate_price(10); | ||
|
|
||
| // Delete 8 of 10 held chunks. | ||
| for addr in addresses.iter().take(8) { | ||
| assert!(protocol.storage().delete(addr).await.expect("delete")); | ||
| } | ||
| // The next quote must price on 2 records, and the price must be the | ||
| // calculate_price(2) value — strictly different from the 10-record | ||
| // price (price is monotonic non-decreasing in records_stored). | ||
| assert_eq!(priced_records_after_quote(&protocol), 2); | ||
| let price_after = calculate_price(2); | ||
| assert!( | ||
| price_after < price_full, | ||
| "deleting data must lower the observable quote price \ | ||
| (full={price_full:?}, after={price_after:?})" | ||
| ); |
chore(release): promote rc-2026.6.3
…de-shunning-node # Conflicts: # Cargo.lock # Cargo.toml
rustdoc's redundant_explicit_links lint (promoted to an error by RUSTDOCFLAGS="-D warnings" in the Documentation CI job) flagged the module-doc link to AuditChallenge, whose explicit crate path duplicates what the intra-doc shortcut resolves to. Use the shortcut form, matching the other AuditChallenge link in this file.
…shunning-node feat: full-node shunning — node implementation (ADR-0003)
8e6fdcf to
e345994
Compare
| let msg = ReplicationMessage { | ||
| request_id: 0, | ||
| body: ReplicationMessageBody::GetCommitmentByPin(GetCommitmentByPin { pin }), | ||
| }; |
| [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" } |
| **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). |
| /// 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); |
chore(release): promote rc-2026.6.4
1de1f7c to
afb1b68
Compare
| let msg = ReplicationMessage { | ||
| request_id: 0, | ||
| body: ReplicationMessageBody::GetCommitmentByPin(GetCommitmentByPin { pin }), | ||
| }; |
| 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 |
| # ─── 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" } |
| # ADR-0004: Commitment-bound quote pricing | ||
|
|
||
| - **Status:** Proposed | ||
| - **Date:** 2026-06-12 | ||
| - **Decision owners:** Anselme (@grumbach) | ||
| - **Reviewers:** <pending> | ||
| - **Supersedes:** none | ||
| - **Superseded by:** none | ||
| - **Related:** ADR-0002 (gossip-triggered contiguous-subtree storage audit) |
| // 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-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 | ||
| // store here. | ||
| payment_verifier.attach_storage(Arc::clone(&storage)); |
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.
…— STRIP BEFORE MERGE evmlib/ant-protocol ADR-0004 versions are unpublished; git patches let CI build. Remove block and bump pins at release.
…e 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.
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.
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.
The ADR-0004 PR branches now carry evmlib 0.9.0 and ant-protocol 2.3.0 (the signed committed_key_count / commitment_pin quote fields). Bump the [dependencies] pins to match so the TEMP [patch.crates-io] git branches satisfy the version requirement, and refresh Cargo.lock to the resolved branch revisions. Corrected the patch-block comment accordingly (pins are now already at the to-be-published versions; at release just delete the patch block).
afb1b68 to
ff7a547
Compare
The local per-quote price-floor heuristic (validate_paid_quote_price_floor and its PAID_QUOTE_PRICE_FLOOR_TOLERANCE_PCT / price_floor / current_records_stored / test_records_override scaffolding) is superseded by ADR-0004's exact on-curve arithmetic recheck (validate_quote_arithmetic): an honest price must lie exactly on the public pricing curve, so a tolerant floor is both weaker and redundant. Remove the gate, its now-dead PERCENT_DENOMINATOR constant, the test-only records override, the three floor-specific unit tests, and update the now-stale price-floor mentions in comments (verifier / replication / storage handler). Also renames the adr0003_* unit tests to adr0004_* to match the ADR renumber.
… grading Addresses the implementation review: - Panic-free time math: quote_ts()/confirmation_deadline() use checked_add and return Option; overflow (e.g. corrupt quote_ts_unix) is treated as corrupt, not a panic (crate denies panic). - Persistence returns Result / LoadOutcome: to_bytes() -> Result (caller must not overwrite the durable ledger on serialize error); load() -> LoadOutcome::Corrupt on decode failure so the caller can fail-open LOCALLY (never a remote grace). - Reload revalidates pins: the persisted form is a keyless list of records; each pin is recomputed on load, so a tampered blob cannot bind a pin to the wrong record. - Grading is evaluation-time independent: the two-sided freshness/future check moves to is_admissible() (ingestion); grade_definitive_miss() depends only on the signed quote_ts + challenge time. - ensure_answerability_bound() startup guard for rule 1. - MSRV: replaced is_none_or (1.82) with map_or (crate MSRV 1.75). 10 module tests (incl corrupt-timestamp-no-panic, corrupt-blob->Corrupt, reload-rekeys-by-pin, evaluation-time-independent grading); 686 lib tests green, clippy/fmt/doc clean.
…-restart proof - is_admissible: fail-closed on the future bound (is_some_and) and require a computable confirmation_deadline at admission. - Capstone test pay_then_shed_across_restart_is_confirmed_but_honest_holder_is_not: pay -> persist -> restart (reload) -> shedder cannot answer -> in-window definitive miss is CONFIRMED; an honest holder that kept the data answers and is not convicted. The exact vector the amendment closes. 11 module tests + full lib suite green; clippy/fmt/doc clean. Review: APPROVE (no remaining blocker/major).
…ep 1)
Persist the responder's commitment retention so answerability survives a
restart — the foundation for removing audit grace safely (an unanswerable
in-window pin becomes provable misbehaviour, not an honest crash-restart, which
is indistinguishable today because ML-DSA signatures are randomized so a rebuilt
commitment gets a new pin).
- BuiltCommitment::from_persisted / leaf_keys + MerkleTree::leaf_keys: rebuild a
commitment's tree from its (content-addressed) key set and verify it against
the signed root WITHOUT re-signing, so the exact pin is preserved.
- PersistedRetention (postcard) + ResponderCommitmentState::snapshot/restore:
capture current+retained signed commitments, key sets, and wall-clock gossip
stamps; reload them, converting stamps back to the monotonic clock and dropping
corrupt/expired slots.
- Engine wiring: load {root_dir}/commitment_retention.bin before the first
rebuild (no-op rebuild then preserves the reloaded current pin); persist
atomically (temp+fsync+rename) after the initial build and each rotation tick.
Tests: retention_survives_restart_via_snapshot_reload (exact pin + held keys
survive), corrupt_retention_snapshot_is_rejected. Full lib suite green;
clippy/fmt clean.
Addresses all 6 findings from the step-1 review: - BLOCKER: gossip-stamp refreshes could be lost between hourly persists. Replace the rotation-tick persist with a dedicated write-on-change persist loop (RETENTION_PERSIST_INTERVAL_SECS=30) that captures every stamp refresh + on shutdown; load moved into ReplicationEngine::new so no task races an empty snapshot over the good file. - BLOCKER: Instant reconstruction underflowed after an OS reboot (uptime < wall age), dropping in-window roots. GossipedAt now stores expires_at (a deadline); restore computes expires_at = now + remaining (addition, no underflow) from an absolute persisted wall-clock expiry, so downtime correctly counts against TTL. - BLOCKER: count-cap (2) could evict an in-window root after a range change on restart. Retention is now TTL-based; the cap (MAX_RETAINED_GOSSIPED_SLOTS=16) is a pure memory backstop that never drops an unexpired root. - MAJOR: has_current only honoured if the first persisted slot actually restored (else a later slot would be wrongly promoted to current). - MAJOR: fsync the parent dir after rename so the durable-commit point survives. - MINOR: from_persisted now verifies the embedded-key ML-DSA signature, rejecting a corrupted/forged persisted commitment. Retention tests reframed from count-based aging (the flagged bug) to TTL-based: gossiped roots stay answerable across rotations within TTL; time-based age-out is covered by the synthetic-clock prune_slots tests. 689 lib + 19 poc tests green; clippy/fmt clean.
…version - BLOCKER (persist cadence): a gossip-stamp refresh in the last persist window could be lost on unclean restart, early-pruning a rotated-away in-window root. Add RESTART_STAMP_GRACE (5m) applied ONLY on reload, so an honest node never under-retains across a restart (it may over-retain by the margin, which is harmless — the responder just answers a little longer; a data-deleter still fails round-2). Hot-path-free (no gossip-path I/O). - MAJOR (format migration): PersistedRetention now carries a RETENTION_FORMAT_VERSION; from_bytes rejects a version mismatch (→ empty retention, self-heals via re-gossip) instead of silently misinterpreting an old-layout blob. - MINOR: dir-fsync target falls back to "." for an empty (relative) parent. Full lib + poc suites green; clippy/fmt clean.
…ew-3) The grace was added after the remaining==0 drop, so a slot whose *stale* persisted deadline had already passed — the exact lost-refresh case — was dropped before grace could help. wall_expiry_to_instant now treats `persisted_expiry + RESTART_STAMP_GRACE` as the effective deadline and only drops a slot that expired MORE than the grace ago (genuine expiry; downtime still counts, minus the grace). Tests: restore_grace_retains_slightly_stale_deadline_but_drops_expired (a 60s-past deadline stays answerable, a 30min-past one is dropped); wrong_format_version_is_rejected. Full lib suite green; clippy/fmt clean.
…ed failure (steps 2+3) ADR-0004 A1: audit grace is removed now that answerability is restart-durable (step 1) and the auditor only pins in-window roots. - Delete RejectKind::is_graced(); rewrite its doc + the reject-response docs to the grace-removed semantics. - Auditor grading (both rounds) matches on kind: UnknownCommitment/Protocol → confirmed failure (AuditFailureReason::Rejected / ByteRound::Rejected); Transient → the non-response/timeout lane (Timeout + holder-credit revocation). Rename ByteRound::GracedReject → TransientReject to reflect that grace is gone. - Responder retries a transient chunk-read error (get_raw_retrying: AUDIT_READ_RETRY_ATTEMPTS=3 × AUDIT_READ_RETRY_BACKOFF=200ms) before emitting RejectKind::Transient, so a momentary disk blip clears while a persistent read failure routes to the timeout lane. Ok(None) (real loss) is not retried. A responsive node that rotated/deleted past a pinned root can no longer escape via UnknownCommitment; a Transient-spammer gains no credit and accrues timeout strikes (deterministic network-wide non-response attribution is deliberately out of scope). 691 lib + 19 poc tests green; clippy/fmt clean.
Review (APPROVE, MINOR): extract the responsive-rejection
grading into a pure `grade_reject(RejectKind) -> RejectGrade{Confirmed|TimeoutLane}`
used by both audit rounds, and unit-test it (UnknownCommitment/Protocol → Confirmed;
Transient → TimeoutLane). Locks down the security-sensitive grace-removal flip
without P2P test infra. Also tighten the retry-latency comment ((attempts-1)×backoff).
ADR-0004 A1 guardrail A: with grace removed, a stale client-forwarded quote must not trigger a first audit of a pin the responder may have legitimately aged out (which would false-convict). MonetizedPinEvent now carries the accused's signed quote_ts (filled from quote.timestamp / merkle_payment_timestamp); the first-audit drainer skips a pin whose quote is older than GOSSIP_ANSWERABILITY_TTL - MONETIZED_AUDIT_SKEW_MARGIN (10m skew margin). Legit first-audits fire moments after payment (well inside the window); a skipped pin can still be gossip-lottery audited if the responder re-gossips it. The gossip-lottery and downgrade paths already pin in-window by construction. 692 lib + 19 poc tests green; clippy/fmt clean.
…review) Review fixes (2 MAJOR + 2 MINOR): - MAJOR: a far-future / ahead-clock quote_ts bypassed the screen (elapsed() Err → not skipped). Now fail-closed on BOTH ends: skip if quote_ts is more than the margin in the future OR older than the window. Only audit if quote_ts ∈ [now-(TTL-margin), now+margin]. - MAJOR: the margin is now documented as the max tolerated auditor↔responder clock skew (nodes NTP-synced within it); raised to 30 min (dwarfs realistic honest skew, wide audit window). The gossip-lottery path is the clock-skew-immune backstop for the keep-gossiping deletion variant. - MINOR: all comparisons use duration_since (no Duration-addition overflow). - MINOR: merkle timestamp still fail-closed to UNIX_EPOCH (→ skipped) on overflow. 692 lib tests green; clippy/fmt clean.
…aid_pin_audit (steps 5+6) - Rewrite Amendment 1 to the final design: audit grace is REMOVED entirely (not just for paid pins); made safe by restart-durable retention + in-window-only auditing; a responsive UnknownCommitment is a confirmed failure, Transient routes to the timeout lane, only silence stays the ADR-0002 timeout lane. - Delete the orphan src/replication/paid_pin_audit.rs (its persistence was generalized into commitment_state::PersistedRetention; its provenance grading was superseded by removing is_graced) and the stale ADR-0004-amendment1-implementation-notes.md — no unwired dead code. 681 lib tests green; clippy/fmt clean.
Extract the first-audit in-window decision into a pure quote_within_audit_window() and unit-test it (fresh/small-skew → audit; far-future or older-than-window → skip). Completes the Step-7 test coverage that proves grace removal is safe: - grade_reject_removes_grace_for_unknown_commitment: responsive UnknownCommitment/ Protocol → confirmed; Transient → timeout lane. - retention_survives_restart_via_snapshot_reload + restore_grace_* + wrong_format_ version + corrupt_retention_snapshot: an honest node stays answerable across restart (never under-retains), rejects corrupt/incompatible snapshots. - gossiped_commitment_stays_answerable_across_rotations & the synthetic-clock prune_slots tests: honest rotation-within-TTL is not convicted; age-out is time-based. - monetized_quote_audit_window_fails_closed_both_ends: a stale/skewed quote can't trigger a first audit. 682 lib + 35 poc integration tests green; clippy/fmt clean.
58f2358 to
5e94226
Compare
Resolve Cargo.toml / Cargo.lock conflicts: keep the ADR-0004 ant-protocol 2.3.0 pin and the evmlib/ant-protocol [patch.crates-io] block (main dropped its now-merged saorsa-core patch and bumped ant-protocol to 2.2.2); reconcile the lockfile. Merged code: 682 lib tests green.
…onest test name Grace removal + TTL-based retention made several doc comments stale: - audit responder no longer 'graces' a responsive in-window miss (confirmed) - retention is TTL/in-window, not a fixed last-2-gossiped count - cross_check_quotes doc moved onto the fn + resolution order corrected (sidecar -> gossip -> fetch, all shipping) instead of 'gossip only' - resync_records is accounting-only (no longer a pricing input) Also: remove dead PersistedRetention::is_empty(); rename the assertion-free emit_mismatch_evidence test to reflect it is a no-panic degrade-path guard.
a2a0d0c to
4613ca0
Compare
| /// **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 |
| /// Maximum tolerated auditor↔responder wall-clock skew for the first-audit | ||
| /// in-window screen (ADR-0004 A1 guardrail A). The screen accepts a monetized pin | ||
| /// for first audit only if its SIGNED `quote_ts` lands in | ||
| /// `[now - (GOSSIP_ANSWERABILITY_TTL - MONETIZED_AUDIT_SKEW_MARGIN), now + MONETIZED_AUDIT_SKEW_MARGIN]` | ||
| /// — fail-closed on BOTH ends: a quote dated too far in the future (a |
| /// `RETAINED_GOSSIPED_COMMITMENTS = 2` this is `(2 + 1) ×` the 1 h rotation | ||
| /// interval = 3 h. |
| // 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 { |
| /// 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 | ||
| /// 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. |
The 256 KB payment-proof cap predates ADR-0004's commitment sidecars. A merkle proof shipping all 16 candidate sidecars serializes to ~342 KB, so the moment nodes rotated their first storage commitment every merkle chunk PUT was size-rejected after the client had already paid on-chain (DEV-01, 2026-07-06). The rejection was invisible in Elasticsearch because fast verification failures log at debug. Raise the cap to 512 KB with the accounting re-derived per proof shape (single-node with sidecars ~150 KB, merkle without sidecars ~130 KB, sidecar-bearing merkle from pre-fix clients ~342 KB) and pin the legacy shape with a regression test: a 342,171-byte proof must fail on content, never on size. Fix the oversized-proof attack e2e to actually cross the cap (it sent 200 KB, under even the old cap) and assert the rejection names the size gate. Amend ADR-0004: merkle client-put bundles carry no sidecars (the client resolve-before-pay gate already forces every candidate to serve its commitment before it can enter a paid pool); single-node bundles still forward theirs.
| [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" } |
| let too_old = now | ||
| .duration_since(quote_ts) | ||
| .is_ok_and(|age| age >= audit_cutoff); |
| let Some(bytes) = state.snapshot().to_bytes() else { | ||
| warn!("Commitment retention: serialization failed; keeping previous snapshot"); | ||
| return; | ||
| }; | ||
| if last.as_deref() == Some(bytes.as_slice()) { |
| /// **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. | ||
| /// |
| 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()); |
Testnet analysis — DEV-01 (5h20m, 58 nodes ×15, 30% NAT) vs release 0.14.2 baselineRan this branch (paired with ant-client #126) on a staging testnet and compared against a Clean result — 0 upload failures (2,977 uploads) and 0 download failures (662). Payment path healthy: 876k Worth noting before release:
No blockers found; the panic and memory footprint are the two things to keep an eye on. |
ADR-0004: commitment-bound quote pricing — node side. Builds on the ADR-0002 storage-audit work (#128, merged).
Binds 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.
What
GetCommitmentByPinfetch), emitting portable mismatch evidence on a contradiction.UnknownCommitment/Transientgrace loophole.Enforcement ships behind observe-only flags for a hard cutover, per the ADR's rollout. The ADR document and implementation-slices notes are included.
Release ordering
Part of the coordinated ADR-0004 cutover — publish order evmlib 0.9.0 → ant-protocol 2.3.0 → ant-node / ant-client. evmlib's change is merged (WithAutonomi/evmlib#11) pending a
0.9.0tag/publish; the shared layer is WithAutonomi/ant-protocol#15. This branch builds today via a committed, temporary[patch.crates-io]git-branch dep block (self-labeled strip before merge); at cutover that block is deleted and the[dependencies]pins (alreadyevmlib 0.9.0/ant-protocol 2.3.0) resolve from the published crates.Companion: ant-client resolve-before-pay (the client half).