From e139fc009aee065ca73cca307cfe29689336b179 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 17 Jun 2026 17:55:08 +0900 Subject: [PATCH 1/3] feat(payment): add commitment binding fields to quote types (ADR-0003) Add `committed_key_count` and `commitment_pin` to `PaymentQuote` and `MerklePaymentCandidateNode`, and `commitment_sidecars` to `MerklePaymentProof`. The two fields bind a quote's price to the node's audited storage commitment: they are covered by the quote signature and the quote hash, so the price a node may charge is tied to how much data it can prove it holds. Both signing payloads are extended to cover the new fields (single-node `bytes_for_signing` and the merkle candidate `bytes_to_sign` 5-field form), with the pin tagged (0 = none, 1 = present) so a baseline quote cannot collide with one pinning an all-zero hash. New fields are tail- placed with serde defaults. This is the shared wire-type half of ADR-0003 (commitment-bound quote pricing); the node-side pricing/audit and the client-side resolve-before- pay enforcement live in ant-node and ant-client respectively. --- src/data_payments.rs | 47 ++++++++++++++++++++++ src/merkle_payments/merkle_payment.rs | 56 ++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/data_payments.rs b/src/data_payments.rs index f55b837..c801455 100644 --- a/src/data_payments.rs +++ b/src/data_payments.rs @@ -85,6 +85,33 @@ pub struct PaymentQuote { pub pub_key: Vec, /// The node's signature for the quote (ML-DSA-65) pub signature: Vec, + /// ADR-0003: the number of keys in the storage commitment this price was + /// derived from. `0` for a baseline (no-commitment) quote. Covered by the + /// signature and the quote hash, so it cannot be altered after signing. + /// + /// Placed at the struct TAIL with `#[serde(default)]` deliberately: rmp's + /// default tuple-struct encoding only supplies defaults for MISSING TRAILING + /// fields, so appending here is what actually lets an old-format quote + /// (which lacks these two fields entirely) decode — `0` / `None` — + /// rather than misaligning onto `pub_key`/`signature`. + /// + /// This is **decode-only compatibility, NOT mixed-fleet acceptance**: an + /// old-format quote still decodes, but it then verifies against the new + /// 6-field signed payload and so its signature/hash no longer validate. + /// ADR-0003 is a hard cutover — the whole fleet and clients upgrade + /// together; nothing accepts an old-format signature. The tail-default only + /// keeps deserialization total (no panic on short input), not interoperable. + #[serde(default)] + pub committed_key_count: u32, + /// ADR-0003: the pin (commitment hash) of the storage commitment this price + /// was derived from. `None` for a baseline (no-commitment) quote; `Some` + /// whenever `committed_key_count > 0`. Covered by the signature and the + /// quote hash. A verifier resolves this pin to the signed commitment + /// (carried as a sidecar, held from gossip, or fetched) to confirm the + /// price matches real, auditable storage. Tail-placed for the same + /// old-wire-decode reason as `committed_key_count`. + #[serde(default)] + pub commitment_pin: Option<[u8; 32]>, } impl fmt::Debug for PaymentQuote { @@ -108,11 +135,21 @@ impl PaymentQuote { } /// Returns the bytes to be signed from the given parameters. + /// + /// ADR-0003 appends the commitment binding (`committed_key_count` and + /// `commitment_pin`) after the original fields. The pin is encoded with a + /// one-byte tag (`0` = none, `1` = present) so a baseline quote with no pin + /// can never collide with a quote pinning an all-zero hash. Appending keeps + /// the original prefix byte-for-byte identical, which matters only for + /// reasoning about the format's evolution — the resulting signature and + /// quote hash still change, exactly the breaking change ADR-0003 plans for. pub fn bytes_for_signing( xorname: XorName, timestamp: SystemTime, price: &Amount, rewards_address: &RewardsAddress, + committed_key_count: u32, + commitment_pin: &Option<[u8; 32]>, ) -> Vec { let mut bytes = xorname.to_vec(); let secs = timestamp @@ -122,6 +159,14 @@ impl PaymentQuote { bytes.extend_from_slice(&secs.to_le_bytes()); bytes.extend_from_slice(&price.to_le_bytes::<32>()); bytes.extend_from_slice(rewards_address.as_slice()); + bytes.extend_from_slice(&committed_key_count.to_le_bytes()); + match commitment_pin { + Some(pin) => { + bytes.push(1u8); + bytes.extend_from_slice(pin); + } + None => bytes.push(0u8), + } bytes } @@ -132,6 +177,8 @@ impl PaymentQuote { self.timestamp, &self.price, &self.rewards_address, + self.committed_key_count, + &self.commitment_pin, ) } } diff --git a/src/merkle_payments/merkle_payment.rs b/src/merkle_payments/merkle_payment.rs index 492a120..3d8e57c 100644 --- a/src/merkle_payments/merkle_payment.rs +++ b/src/merkle_payments/merkle_payment.rs @@ -71,31 +71,74 @@ pub struct MerklePaymentCandidateNode { /// Quote timestamp (provided by the client) pub merkle_payment_timestamp: u64, - /// Signature over hash(price || reward_address || timestamp) + /// Signature over `bytes_to_sign` pub signature: Vec, + + /// ADR-0003: the number of keys in the storage commitment this price was + /// derived from. `0` for a baseline (no-commitment) quote. Tail-placed with + /// `#[serde(default)]` so an old-format candidate (lacking these fields) + /// decodes as `0`/`None` rather than misaligning onto `signature`. + #[serde(default)] + pub committed_key_count: u32, + + /// ADR-0003: the pin (commitment hash) of the storage commitment this price + /// was derived from. `None` for a baseline quote. + #[serde(default)] + pub commitment_pin: Option<[u8; 32]>, } impl MerklePaymentCandidateNode { /// Get the bytes to sign. + /// + /// ADR-0003: the commitment binding (`committed_key_count`, `commitment_pin`) + /// is appended to the signed payload so the per-node ML-DSA-65 signature + /// covers it — making a count/pin mismatch genuine "two artifacts signed by + /// the same key" evidence. The pin is tagged (`0` = none, `1` = present) so a + /// baseline candidate can never collide with one pinning an all-zero hash. + /// This is a coordinated breaking change: `ant-protocol` must verify the same + /// 5-field message (its `verify_merkle_candidate_signature` reconstructs this + /// exact payload). pub fn bytes_to_sign( price: &Amount, reward_address: &RewardsAddress, timestamp: u64, + committed_key_count: u32, + commitment_pin: &Option<[u8; 32]>, ) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(&price.to_le_bytes::<32>()); bytes.extend_from_slice(reward_address.as_slice()); bytes.extend_from_slice(×tamp.to_le_bytes()); + bytes.extend_from_slice(&committed_key_count.to_le_bytes()); + match commitment_pin { + Some(pin) => { + bytes.push(1u8); + bytes.extend_from_slice(pin); + } + None => bytes.push(0u8), + } bytes } /// Convert to deterministic byte representation for hashing. + /// + /// ADR-0003 fields are included so the commitment binding is covered by the + /// pool hash (and therefore the on-chain commitment), not only the + /// per-node signature. pub(crate) fn to_bytes(&self) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(&self.pub_key); bytes.extend_from_slice(&self.price.to_le_bytes::<32>()); bytes.extend_from_slice(self.reward_address.as_slice()); bytes.extend_from_slice(&self.merkle_payment_timestamp.to_le_bytes()); + bytes.extend_from_slice(&self.committed_key_count.to_le_bytes()); + match &self.commitment_pin { + Some(pin) => { + bytes.push(1u8); + bytes.extend_from_slice(pin); + } + None => bytes.push(0u8), + } bytes.extend_from_slice(&self.signature); bytes } @@ -197,6 +240,16 @@ pub struct MerklePaymentProof { /// The winner pool selected by the smart contract pub winner_pool: MerklePaymentCandidatePool, + + /// ADR-0003 commitment sidecars: the signed storage commitment each winner + /// candidate pinned, as opaque serialized blobs, so a storer can cross-check + /// a candidate's claimed count against the original commitment synchronously + /// ("the commitment arrived with the quote"). `evmlib` stays agnostic of the + /// commitment type; the node deserializes and validates each. Tail-placed, + /// `serde(default)`: an old proof simply carries none and the node falls + /// back to gossip/fetch. + #[serde(default)] + pub commitment_sidecars: Vec>, } impl MerklePaymentProof { @@ -210,6 +263,7 @@ impl MerklePaymentProof { address, data_proof, winner_pool, + commitment_sidecars: Vec::new(), } } From 4adfdb5615271f716e4233681fb4cf640a0a34d0 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 30 Jun 2026 13:48:34 +0200 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20renumber=20ADR-0003=20=E2=86=92=20A?= =?UTF-8?q?DR-0004=20(avoid=20collision=20with=20full-node-shunning=20ADR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commitment-bound quote pricing ADR clashed with Mick's full-node shunning ADR, which also took numbers 0002/0003. Renumber the pricing ADR's references to 0004; no functional change (doc comments only). Co-Authored-By: Claude Opus 4.8 --- src/data_payments.rs | 10 +++++----- src/merkle_payments/merkle_payment.rs | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/data_payments.rs b/src/data_payments.rs index c801455..cd76fc9 100644 --- a/src/data_payments.rs +++ b/src/data_payments.rs @@ -85,7 +85,7 @@ pub struct PaymentQuote { pub pub_key: Vec, /// The node's signature for the quote (ML-DSA-65) pub signature: Vec, - /// ADR-0003: the number of keys in the storage commitment this price was + /// ADR-0004: the number of keys in the storage commitment this price was /// derived from. `0` for a baseline (no-commitment) quote. Covered by the /// signature and the quote hash, so it cannot be altered after signing. /// @@ -98,12 +98,12 @@ pub struct PaymentQuote { /// This is **decode-only compatibility, NOT mixed-fleet acceptance**: an /// old-format quote still decodes, but it then verifies against the new /// 6-field signed payload and so its signature/hash no longer validate. - /// ADR-0003 is a hard cutover — the whole fleet and clients upgrade + /// ADR-0004 is a hard cutover — the whole fleet and clients upgrade /// together; nothing accepts an old-format signature. The tail-default only /// keeps deserialization total (no panic on short input), not interoperable. #[serde(default)] pub committed_key_count: u32, - /// ADR-0003: the pin (commitment hash) of the storage commitment this price + /// ADR-0004: the pin (commitment hash) of the storage commitment this price /// was derived from. `None` for a baseline (no-commitment) quote; `Some` /// whenever `committed_key_count > 0`. Covered by the signature and the /// quote hash. A verifier resolves this pin to the signed commitment @@ -136,13 +136,13 @@ impl PaymentQuote { /// Returns the bytes to be signed from the given parameters. /// - /// ADR-0003 appends the commitment binding (`committed_key_count` and + /// ADR-0004 appends the commitment binding (`committed_key_count` and /// `commitment_pin`) after the original fields. The pin is encoded with a /// one-byte tag (`0` = none, `1` = present) so a baseline quote with no pin /// can never collide with a quote pinning an all-zero hash. Appending keeps /// the original prefix byte-for-byte identical, which matters only for /// reasoning about the format's evolution — the resulting signature and - /// quote hash still change, exactly the breaking change ADR-0003 plans for. + /// quote hash still change, exactly the breaking change ADR-0004 plans for. pub fn bytes_for_signing( xorname: XorName, timestamp: SystemTime, diff --git a/src/merkle_payments/merkle_payment.rs b/src/merkle_payments/merkle_payment.rs index 3d8e57c..2454be6 100644 --- a/src/merkle_payments/merkle_payment.rs +++ b/src/merkle_payments/merkle_payment.rs @@ -74,14 +74,14 @@ pub struct MerklePaymentCandidateNode { /// Signature over `bytes_to_sign` pub signature: Vec, - /// ADR-0003: the number of keys in the storage commitment this price was + /// ADR-0004: the number of keys in the storage commitment this price was /// derived from. `0` for a baseline (no-commitment) quote. Tail-placed with /// `#[serde(default)]` so an old-format candidate (lacking these fields) /// decodes as `0`/`None` rather than misaligning onto `signature`. #[serde(default)] pub committed_key_count: u32, - /// ADR-0003: the pin (commitment hash) of the storage commitment this price + /// ADR-0004: the pin (commitment hash) of the storage commitment this price /// was derived from. `None` for a baseline quote. #[serde(default)] pub commitment_pin: Option<[u8; 32]>, @@ -90,7 +90,7 @@ pub struct MerklePaymentCandidateNode { impl MerklePaymentCandidateNode { /// Get the bytes to sign. /// - /// ADR-0003: the commitment binding (`committed_key_count`, `commitment_pin`) + /// ADR-0004: the commitment binding (`committed_key_count`, `commitment_pin`) /// is appended to the signed payload so the per-node ML-DSA-65 signature /// covers it — making a count/pin mismatch genuine "two artifacts signed by /// the same key" evidence. The pin is tagged (`0` = none, `1` = present) so a @@ -122,7 +122,7 @@ impl MerklePaymentCandidateNode { /// Convert to deterministic byte representation for hashing. /// - /// ADR-0003 fields are included so the commitment binding is covered by the + /// ADR-0004 fields are included so the commitment binding is covered by the /// pool hash (and therefore the on-chain commitment), not only the /// per-node signature. pub(crate) fn to_bytes(&self) -> Vec { @@ -241,7 +241,7 @@ pub struct MerklePaymentProof { /// The winner pool selected by the smart contract pub winner_pool: MerklePaymentCandidatePool, - /// ADR-0003 commitment sidecars: the signed storage commitment each winner + /// ADR-0004 commitment sidecars: the signed storage commitment each winner /// candidate pinned, as opaque serialized blobs, so a storer can cross-check /// a candidate's claimed count against the original commitment synchronously /// ("the commitment arrived with the quote"). `evmlib` stays agnostic of the From a7ac7e2620cb6a50151dd0c619fd75e03b02f05c Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 1 Jul 2026 12:34:10 +0200 Subject: [PATCH 3/3] chore(release): bump evmlib 0.8.1 -> 0.9.0 (ADR-0004 breaking quote fields) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 +++++++++++ Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2fc4e46 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.9.0 + +- **ADR-0004 (commitment-bound quote pricing):** add signed commitment-binding + fields — `committed_key_count: u32` and `commitment_pin: Option<[u8; 32]>` — to + `PaymentQuote` and `MerklePaymentCandidateNode`. The fields are covered by + `bytes_for_signing` (single-node) / `bytes_to_sign` (merkle) and therefore by + the quote hash. **Breaking wire/signature change** (a v0.8.x quote's signature + no longer verifies on v0.9.0 and vice-versa); the fleet and clients must + upgrade together. diff --git a/Cargo.toml b/Cargo.toml index d2f1315..2a86ae6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ homepage = "https://maidsafe.net" license = "GPL-3.0" name = "evmlib" repository = "https://github.com/WithAutonomi/evmlib" -version = "0.8.1" +version = "0.9.0" [features] external-signer = []