Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/data_payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ pub struct PaymentQuote {
pub pub_key: Vec<u8>,
/// The node's signature for the quote (ML-DSA-65)
pub signature: Vec<u8>,
/// 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 {
Expand All @@ -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<u8> {
let mut bytes = xorname.to_vec();
let secs = timestamp
Expand All @@ -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
}

Expand All @@ -132,6 +177,8 @@ impl PaymentQuote {
self.timestamp,
&self.price,
&self.rewards_address,
self.committed_key_count,
&self.commitment_pin,
)
}
}
Expand Down
56 changes: 55 additions & 1 deletion src/merkle_payments/merkle_payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,

/// 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<u8> {
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(&timestamp.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<u8> {
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
}
Expand Down Expand Up @@ -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<Vec<u8>>,
}

impl MerklePaymentProof {
Expand All @@ -210,6 +263,7 @@ impl MerklePaymentProof {
address,
data_proof,
winner_pool,
commitment_sidecars: Vec::new(),
}
}

Expand Down
Loading