Skip to main content

evmlib/merkle_payments/
merkle_payment.rs

1// Copyright 2025 MaidSafe.net limited.
2//
3// This Autonomi Software is licensed under the MIT license <LICENSE-MIT or
4// https://opensource.org/licenses/MIT> or the Apache License, Version 2.0
5// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, at your
6// option. This file may not be copied, modified, or distributed except
7// according to those terms.
8
9use crate::common::{Address as RewardsAddress, Amount};
10use crate::merkle_batch_payment::{CANDIDATES_PER_POOL, CandidateNode, PoolCommitment, PoolHash};
11use serde::{Deserialize, Serialize};
12use std::collections::HashSet;
13use thiserror::Error;
14use tiny_keccak::{Hasher, Sha3};
15use xor_name::XorName;
16
17use super::merkle_tree::MerkleBranch;
18use super::merkle_tree::MidpointProof;
19
20/// Errors that can occur during merkle payment verification
21#[derive(Debug, Error, Clone, PartialEq, Eq)]
22pub enum MerklePaymentVerificationError {
23    #[error("Invalid signature for node with address {address}")]
24    InvalidNodeSignature { address: RewardsAddress },
25    #[error("Timestamp mismatch for node {address}: expected {expected}, got {got}")]
26    TimestampMismatch {
27        address: RewardsAddress,
28        expected: u64,
29        got: u64,
30    },
31    #[error("Data type mismatch for node {address}: expected {expected}, got {got}")]
32    DataTypeMismatch {
33        address: RewardsAddress,
34        expected: u32,
35        got: u32,
36    },
37    #[error("Commitment does not match pool")]
38    CommitmentDoesNotMatchPool,
39    #[error("Paid node index {index} out of bounds (pool size: {pool_size})")]
40    PaidNodeIndexOutOfBounds { index: usize, pool_size: usize },
41    #[error("Paid address mismatch at index {index}: expected {expected}, got {got}")]
42    PaidAddressMismatch {
43        index: usize,
44        expected: RewardsAddress,
45        got: RewardsAddress,
46    },
47    #[error("Winner pool hash not found in on-chain commitments")]
48    WinnerPoolNotInCommitments,
49    #[error(
50        "Price mismatch at index {index}: on_chain={on_chain_price}, expected={expected_price}"
51    )]
52    PriceMismatch {
53        index: usize,
54        on_chain_price: String,
55        expected_price: String,
56    },
57}
58
59/// A node's signed quote for potential reward eligibility.
60///
61/// Nodes create this in response to a client's quote request. The `pub_key`
62/// field stores the raw ML-DSA-65 public key bytes, and `signature` stores
63/// the ML-DSA-65 signature over `bytes_to_sign()`.
64#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
65pub struct MerklePaymentCandidateNode {
66    /// Node's public key bytes (ML-DSA-65)
67    pub pub_key: Vec<u8>,
68
69    /// Node-calculated price for storing data
70    pub price: Amount,
71
72    /// Node's Ethereum address for payment
73    pub reward_address: RewardsAddress,
74
75    /// Quote timestamp (provided by the client)
76    pub merkle_payment_timestamp: u64,
77
78    /// Signature over `bytes_to_sign`
79    pub signature: Vec<u8>,
80
81    /// ADR-0004: the number of keys in the storage commitment this price was
82    /// derived from. `0` for a baseline (no-commitment) quote. Tail-placed with
83    /// `#[serde(default)]` so an old-format candidate (lacking these fields)
84    /// decodes as `0`/`None` rather than misaligning onto `signature`.
85    #[serde(default)]
86    pub committed_key_count: u32,
87
88    /// ADR-0004: the pin (commitment hash) of the storage commitment this price
89    /// was derived from. `None` for a baseline quote.
90    #[serde(default)]
91    pub commitment_pin: Option<[u8; 32]>,
92}
93
94impl MerklePaymentCandidateNode {
95    /// Get the bytes to sign.
96    ///
97    /// ADR-0004: the commitment binding (`committed_key_count`, `commitment_pin`)
98    /// is appended to the signed payload so the per-node ML-DSA-65 signature
99    /// covers it — making a count/pin mismatch genuine "two artifacts signed by
100    /// the same key" evidence. The pin is tagged (`0` = none, `1` = present) so a
101    /// baseline candidate can never collide with one pinning an all-zero hash.
102    /// This is a coordinated breaking change: `ant-protocol` must verify the same
103    /// 5-field message (its `verify_merkle_candidate_signature` reconstructs this
104    /// exact payload).
105    pub fn bytes_to_sign(
106        price: &Amount,
107        reward_address: &RewardsAddress,
108        timestamp: u64,
109        committed_key_count: u32,
110        commitment_pin: &Option<[u8; 32]>,
111    ) -> Vec<u8> {
112        let mut bytes = Vec::new();
113        bytes.extend_from_slice(&price.to_le_bytes::<32>());
114        bytes.extend_from_slice(reward_address.as_slice());
115        bytes.extend_from_slice(&timestamp.to_le_bytes());
116        bytes.extend_from_slice(&committed_key_count.to_le_bytes());
117        match commitment_pin {
118            Some(pin) => {
119                bytes.push(1u8);
120                bytes.extend_from_slice(pin);
121            }
122            None => bytes.push(0u8),
123        }
124        bytes
125    }
126
127    /// Convert to deterministic byte representation for hashing.
128    ///
129    /// ADR-0004 fields are included so the commitment binding is covered by the
130    /// pool hash (and therefore the on-chain commitment), not only the
131    /// per-node signature.
132    pub(crate) fn to_bytes(&self) -> Vec<u8> {
133        let mut bytes = Vec::new();
134        bytes.extend_from_slice(&self.pub_key);
135        bytes.extend_from_slice(&self.price.to_le_bytes::<32>());
136        bytes.extend_from_slice(self.reward_address.as_slice());
137        bytes.extend_from_slice(&self.merkle_payment_timestamp.to_le_bytes());
138        bytes.extend_from_slice(&self.committed_key_count.to_le_bytes());
139        match &self.commitment_pin {
140            Some(pin) => {
141                bytes.push(1u8);
142                bytes.extend_from_slice(pin);
143            }
144            None => bytes.push(0u8),
145        }
146        bytes.extend_from_slice(&self.signature);
147        bytes
148    }
149}
150
151/// One candidate pool: midpoint proof + nodes who could store addresses.
152#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
153pub struct MerklePaymentCandidatePool {
154    /// The midpoint proof from the merkle tree
155    pub midpoint_proof: MidpointProof,
156
157    /// Candidate nodes for this pool (fixed size for determinism)
158    pub candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL],
159}
160
161/// Compute SHA3-256 hash of input bytes.
162pub(crate) fn sha3_256(input: &[u8]) -> [u8; 32] {
163    let mut sha3 = Sha3::v256();
164    let mut output = [0u8; 32];
165    sha3.update(input);
166    sha3.finalize(&mut output);
167    output
168}
169
170impl MerklePaymentCandidatePool {
171    /// Compute deterministic hash for on-chain storage key.
172    pub fn hash(&self) -> PoolHash {
173        let mut bytes = Vec::new();
174        bytes.extend_from_slice(&self.midpoint_proof.hash());
175        bytes.extend_from_slice(&(self.candidate_nodes.len() as u32).to_le_bytes());
176        for node in &self.candidate_nodes {
177            bytes.extend_from_slice(&node.to_bytes());
178        }
179        sha3_256(&bytes)
180    }
181
182    /// Convert to minimal commitment for smart contract submission.
183    pub fn to_commitment(&self) -> PoolCommitment {
184        let candidates: [CandidateNode; CANDIDATES_PER_POOL] =
185            self.candidate_nodes.clone().map(|node| CandidateNode {
186                rewards_address: node.reward_address,
187                price: node.price,
188            });
189
190        PoolCommitment {
191            pool_hash: self.hash(),
192            candidates,
193        }
194    }
195
196    /// Verify that on-chain prices match what the signed nodes report.
197    pub fn verify_prices(
198        &self,
199        on_chain_commitments: &[PoolCommitment],
200        winner_pool_hash: &PoolHash,
201    ) -> Result<(), MerklePaymentVerificationError> {
202        let on_chain_winner = on_chain_commitments
203            .iter()
204            .find(|pc| pc.pool_hash == *winner_pool_hash)
205            .ok_or(MerklePaymentVerificationError::WinnerPoolNotInCommitments)?;
206
207        for (i, (on_chain_candidate, signed_node)) in on_chain_winner
208            .candidates
209            .iter()
210            .zip(self.candidate_nodes.iter())
211            .enumerate()
212        {
213            if on_chain_candidate.price != signed_node.price {
214                return Err(MerklePaymentVerificationError::PriceMismatch {
215                    index: i,
216                    on_chain_price: on_chain_candidate.price.to_string(),
217                    expected_price: signed_node.price.to_string(),
218                });
219            }
220        }
221
222        Ok(())
223    }
224
225    /// Get the reward addresses of all candidate nodes.
226    pub fn candidate_nodes_addresses(&self) -> HashSet<RewardsAddress> {
227        self.candidate_nodes
228            .iter()
229            .map(|node| node.reward_address)
230            .collect()
231    }
232}
233
234/// Data package for merkle payment verification.
235///
236/// Contains everything a node needs to verify a merkle batch payment.
237#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
238pub struct MerklePaymentProof {
239    /// The data's XorName
240    pub address: XorName,
241
242    /// Merkle proof that this data belongs to the paid tree
243    pub data_proof: MerkleBranch,
244
245    /// The winner pool selected by the smart contract
246    pub winner_pool: MerklePaymentCandidatePool,
247
248    /// ADR-0004 commitment sidecars: the signed storage commitment each winner
249    /// candidate pinned, as opaque serialized blobs, so a storer can cross-check
250    /// a candidate's claimed count against the original commitment synchronously
251    /// ("the commitment arrived with the quote"). `evmlib` stays agnostic of the
252    /// commitment type; the node deserializes and validates each. Tail-placed,
253    /// `serde(default)`: an old proof simply carries none and the node falls
254    /// back to gossip/fetch.
255    #[serde(default)]
256    pub commitment_sidecars: Vec<Vec<u8>>,
257}
258
259impl MerklePaymentProof {
260    /// Create a new Merkle payment proof.
261    pub fn new(
262        address: XorName,
263        data_proof: MerkleBranch,
264        winner_pool: MerklePaymentCandidatePool,
265    ) -> Self {
266        Self {
267            address,
268            data_proof,
269            winner_pool,
270            commitment_sidecars: Vec::new(),
271        }
272    }
273
274    /// Get the hash of the winner pool (used to query smart contract for payment info).
275    pub fn winner_pool_hash(&self) -> PoolHash {
276        self.winner_pool.hash()
277    }
278}