From e139fc009aee065ca73cca307cfe29689336b179 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 17 Jun 2026 17:55:08 +0900 Subject: [PATCH] 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(), } }