diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..114ce6dd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to the `ant-node` crate will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.11.0] — Unreleased + +### Changed + +The wire-protocol surface previously owned by `ant-node` has moved to +the new [`ant-protocol`] crate. All previously-exported paths continue +to resolve via re-exports, so existing downstream imports keep working +unchanged. + +[`ant-protocol`]: https://crates.io/crates/ant-protocol + +- `ant_node::ant_protocol` — now re-exports from `ant_protocol::chunk`. + Both `ant_node::ant_protocol::ChunkMessage` and + `ant_node::ant_protocol::chunk::ChunkMessage` resolve. +- `ant_node::client` — `compute_address`, `peer_id_to_xor_name`, + `xor_distance`, `DataChunk`, `ChunkStats`, `XorName`, and + `send_and_await_chunk_response` now re-export from + `ant_protocol::{data_types, chunk_protocol}`. + `hex_node_id_to_encoded_peer_id` stays as node-owned code. +- `ant_node::payment::proof`, `ant_node::payment::single_node` — now + re-export from `ant_protocol::payment::{proof, single_node}`. +- `ant_node::payment::{verify_quote_content, verify_quote_signature, + verify_merkle_candidate_signature}` — now re-export from + `ant_protocol::payment::verify`. +- `ant_node::devnet::{DevnetManifest, DevnetEvmInfo}` — now re-export + from `ant_protocol::devnet_manifest`. JSON format unchanged. + +### Security + +- `ant_protocol::SingleNodePayment::verify` (used via + `ant_node::payment::PaymentVerifier`) now rejects proofs whose median + quote has zero price or zero paid amount. Previously a malicious + client could have submitted a zero-priced median, and the on-chain + `completedPayments >= 0` check would have trivially succeeded. +- `ant_node::payment::PaymentVerifier` now rejects unknown + `ProofType` tag bytes (including future variants added on an + `ant-protocol` minor bump) instead of silently accepting them. + +### Added + +- Re-export of the `chunk` submodule from `ant-protocol` so + `ant_node::ant_protocol::chunk::` paths keep resolving for + downstream callers that used the longer path. + +### Deprecation notice + +`ant_node::ant_protocol`, `ant_node::client` (except for the node-only +`hex_node_id_to_encoded_peer_id`), `ant_node::payment::{proof, +single_node}`, and `ant_node::payment::verify_*` will be removed in a +future 0.x release once the wider ecosystem has migrated to +`ant_protocol::*` directly. No timeline yet. diff --git a/Cargo.lock b/Cargo.lock index 93e18431..59ca5140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,10 +836,11 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.10.0" +version = "0.11.0" dependencies = [ "aes-gcm-siv", "alloy", + "ant-protocol", "blake3", "bytes", "chrono", @@ -886,6 +887,24 @@ dependencies = [ "zip", ] +[[package]] +name = "ant-protocol" +version = "2.0.0" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=597dbdb1b680a43d80a082d77076ff2080444079#597dbdb1b680a43d80a082d77076ff2080444079" +dependencies = [ + "blake3", + "bytes", + "evmlib", + "hex", + "postcard", + "rmp-serde", + "saorsa-core", + "saorsa-pqc 0.5.1", + "serde", + "tokio", + "tracing", +] + [[package]] name = "anyhow" version = "1.0.102" diff --git a/Cargo.toml b/Cargo.toml index 7ab2eef5..cebf1954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ant-node" -version = "0.10.0" +version = "0.11.0" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Autonomi decentralized network" @@ -23,6 +23,19 @@ name = "ant-devnet" path = "src/bin/ant-devnet/main.rs" [dependencies] +# Wire protocol — the single version-pin shared with ant-client. +# Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples +# through here automatically; we keep a direct saorsa-core dep for +# node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which +# Cargo unifies with ant-protocol's version constraint. +# +# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. +# The git ref is the tagged `main` commit at the time of this PR and +# stays byte-for-byte identical to what will be published. +# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. +# The pinned commit matches the current `WithAutonomi/ant-protocol` main. +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "597dbdb1b680a43d80a082d77076ff2080444079" } + # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.23.0" saorsa-pqc = "0.5" diff --git a/src/ant_protocol/chunk.rs b/src/ant_protocol/chunk.rs deleted file mode 100644 index d8c0840a..00000000 --- a/src/ant_protocol/chunk.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! Chunk message types for the ANT protocol. -//! -//! Chunks are immutable, content-addressed data blocks where the address -//! is the BLAKE3 hash of the content. Maximum size is 4MB. -//! -//! This module defines the wire protocol messages for chunk operations -//! using postcard serialization for compact, fast encoding. - -use serde::{Deserialize, Serialize}; - -/// Protocol identifier for chunk operations. -pub const CHUNK_PROTOCOL_ID: &str = "autonomi.ant.chunk.v1"; - -/// Current protocol version. -pub const PROTOCOL_VERSION: u16 = 1; - -/// Maximum chunk size in bytes (4MB). -pub const MAX_CHUNK_SIZE: usize = 4 * 1024 * 1024; - -/// Maximum wire message size in bytes (5MB). -/// -/// Limits the input buffer accepted by [`ChunkMessage::decode`] to prevent -/// unbounded allocation from malicious or corrupted payloads. Set slightly -/// above [`MAX_CHUNK_SIZE`] to accommodate message envelope overhead. -pub const MAX_WIRE_MESSAGE_SIZE: usize = 5 * 1024 * 1024; - -/// Data type identifier for chunks. -pub const DATA_TYPE_CHUNK: u32 = 0; - -/// Content-addressed identifier (32 bytes). -pub type XorName = [u8; 32]; - -/// Byte length of an [`XorName`]. -pub const XORNAME_LEN: usize = std::mem::size_of::(); - -/// Enum of all chunk protocol message types. -/// -/// Uses a single-byte discriminant for efficient wire encoding. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkMessageBody { - /// Request to store a chunk. - PutRequest(ChunkPutRequest), - /// Response to a PUT request. - PutResponse(ChunkPutResponse), - /// Request to retrieve a chunk. - GetRequest(ChunkGetRequest), - /// Response to a GET request. - GetResponse(ChunkGetResponse), - /// Request a storage quote. - QuoteRequest(ChunkQuoteRequest), - /// Response with a storage quote. - QuoteResponse(ChunkQuoteResponse), - /// Request a merkle candidate quote for batch payments. - MerkleCandidateQuoteRequest(MerkleCandidateQuoteRequest), - /// Response with a merkle candidate quote. - MerkleCandidateQuoteResponse(MerkleCandidateQuoteResponse), -} - -/// Wire-format wrapper that pairs a sender-assigned `request_id` with -/// a [`ChunkMessageBody`]. -/// -/// The sender picks a unique `request_id`; the handler echoes it back -/// in the response so callers can correlate replies by ID rather than -/// by source peer. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkMessage { - /// Sender-assigned identifier, echoed back in the response. - pub request_id: u64, - /// The protocol message body. - pub body: ChunkMessageBody, -} - -impl ChunkMessage { - /// Encode the message to bytes using postcard. - /// - /// # Errors - /// - /// Returns an error if serialization fails. - pub fn encode(&self) -> Result, ProtocolError> { - postcard::to_stdvec(self).map_err(|e| ProtocolError::SerializationFailed(e.to_string())) - } - - /// Decode a message from bytes using postcard. - /// - /// Rejects payloads larger than [`MAX_WIRE_MESSAGE_SIZE`] before - /// attempting deserialization. - /// - /// # Errors - /// - /// Returns [`ProtocolError::MessageTooLarge`] if the input exceeds the - /// size limit, or [`ProtocolError::DeserializationFailed`] if postcard - /// cannot parse the data. - pub fn decode(data: &[u8]) -> Result { - if data.len() > MAX_WIRE_MESSAGE_SIZE { - return Err(ProtocolError::MessageTooLarge { - size: data.len(), - max_size: MAX_WIRE_MESSAGE_SIZE, - }); - } - postcard::from_bytes(data).map_err(|e| ProtocolError::DeserializationFailed(e.to_string())) - } -} - -// ============================================================================= -// PUT Request/Response -// ============================================================================= - -/// Request to store a chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkPutRequest { - /// The content-addressed identifier (BLAKE3 of content). - pub address: XorName, - /// The chunk data. - pub content: Vec, - /// Optional payment proof (serialized `ProofOfPayment`). - /// Required for new chunks unless already verified. - pub payment_proof: Option>, -} - -impl ChunkPutRequest { - /// Create a new PUT request. - #[must_use] - pub fn new(address: XorName, content: Vec) -> Self { - Self { - address, - content, - payment_proof: None, - } - } - - /// Create a new PUT request with payment proof. - #[must_use] - pub fn with_payment(address: XorName, content: Vec, payment_proof: Vec) -> Self { - Self { - address, - content, - payment_proof: Some(payment_proof), - } - } -} - -/// Response to a PUT request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkPutResponse { - /// Chunk stored successfully. - Success { - /// The address where the chunk was stored. - address: XorName, - }, - /// Chunk already exists (idempotent success). - AlreadyExists { - /// The existing chunk address. - address: XorName, - }, - /// Payment is required to store this chunk. - PaymentRequired { - /// Error message. - message: String, - }, - /// An error occurred. - Error(ProtocolError), -} - -// ============================================================================= -// GET Request/Response -// ============================================================================= - -/// Request to retrieve a chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkGetRequest { - /// The content-addressed identifier to retrieve. - pub address: XorName, -} - -impl ChunkGetRequest { - /// Create a new GET request. - #[must_use] - pub fn new(address: XorName) -> Self { - Self { address } - } -} - -/// Response to a GET request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkGetResponse { - /// Chunk found and returned. - Success { - /// The chunk address. - address: XorName, - /// The chunk data. - content: Vec, - }, - /// Chunk not found. - NotFound { - /// The requested address. - address: XorName, - }, - /// An error occurred. - Error(ProtocolError), -} - -// ============================================================================= -// Quote Request/Response -// ============================================================================= - -/// Request a storage quote for a chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkQuoteRequest { - /// The content address of the data to store. - pub address: XorName, - /// Size of the data in bytes. - pub data_size: u64, - /// Data type identifier (0 for chunks). - pub data_type: u32, -} - -impl ChunkQuoteRequest { - /// Create a new quote request. - #[must_use] - pub fn new(address: XorName, data_size: u64) -> Self { - Self { - address, - data_size, - data_type: DATA_TYPE_CHUNK, - } - } -} - -/// Response with a storage quote. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkQuoteResponse { - /// Quote generated successfully. - /// - /// When `already_stored` is `true` the node already holds this chunk and no - /// payment is required — the client should skip the pay-then-PUT cycle for - /// this address. The quote is still included for informational purposes. - Success { - /// Serialized `PaymentQuote`. - quote: Vec, - /// `true` when the chunk already exists on this node (skip payment). - already_stored: bool, - }, - /// Quote generation failed. - Error(ProtocolError), -} - -// ============================================================================= -// Merkle Candidate Quote Request/Response -// ============================================================================= - -/// Request a merkle candidate quote for batch payments. -/// -/// Part of the merkle batch payment system where clients collect -/// signed candidate quotes from 16 closest peers per pool. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MerkleCandidateQuoteRequest { - /// The candidate pool address (hash of midpoint || root || timestamp). - pub address: XorName, - /// Data type identifier (0 for chunks). - pub data_type: u32, - /// Size of the data in bytes. - pub data_size: u64, - /// Client-provided merkle payment timestamp (unix seconds). - pub merkle_payment_timestamp: u64, -} - -/// Response with a merkle candidate quote. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MerkleCandidateQuoteResponse { - /// Candidate quote generated successfully. - /// Contains the serialized `MerklePaymentCandidateNode`. - Success { - /// Serialized `MerklePaymentCandidateNode`. - candidate_node: Vec, - }, - /// Quote generation failed. - Error(ProtocolError), -} - -// ============================================================================= -// Payment Proof Type Tags -// ============================================================================= - -/// Version byte prefix for payment proof serialization. -/// Allows the verifier to detect proof type before deserialization. -pub const PROOF_TAG_SINGLE_NODE: u8 = 0x01; -/// Version byte prefix for merkle payment proofs. -pub const PROOF_TAG_MERKLE: u8 = 0x02; - -// ============================================================================= -// Protocol Errors -// ============================================================================= - -/// Errors that can occur during protocol operations. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ProtocolError { - /// Message serialization failed. - SerializationFailed(String), - /// Message deserialization failed. - DeserializationFailed(String), - /// Wire message exceeds the maximum allowed size. - MessageTooLarge { - /// Actual size of the message in bytes. - size: usize, - /// Maximum allowed size. - max_size: usize, - }, - /// Chunk exceeds maximum size. - ChunkTooLarge { - /// Size of the chunk in bytes. - size: usize, - /// Maximum allowed size. - max_size: usize, - }, - /// Content address mismatch (hash(content) != address). - AddressMismatch { - /// Expected address. - expected: XorName, - /// Actual address computed from content. - actual: XorName, - }, - /// Storage operation failed. - StorageFailed(String), - /// Payment verification failed. - PaymentFailed(String), - /// Quote generation failed. - QuoteFailed(String), - /// Internal error. - Internal(String), -} - -impl std::fmt::Display for ProtocolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SerializationFailed(msg) => write!(f, "serialization failed: {msg}"), - Self::DeserializationFailed(msg) => write!(f, "deserialization failed: {msg}"), - Self::MessageTooLarge { size, max_size } => { - write!(f, "message size {size} exceeds maximum {max_size}") - } - Self::ChunkTooLarge { size, max_size } => { - write!(f, "chunk size {size} exceeds maximum {max_size}") - } - Self::AddressMismatch { expected, actual } => { - write!( - f, - "address mismatch: expected {}, got {}", - hex::encode(expected), - hex::encode(actual) - ) - } - Self::StorageFailed(msg) => write!(f, "storage failed: {msg}"), - Self::PaymentFailed(msg) => write!(f, "payment failed: {msg}"), - Self::QuoteFailed(msg) => write!(f, "quote failed: {msg}"), - Self::Internal(msg) => write!(f, "internal error: {msg}"), - } - } -} - -impl std::error::Error for ProtocolError {} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - - #[test] - fn test_put_request_encode_decode() { - let address = [0xAB; 32]; - let content = vec![1, 2, 3, 4, 5]; - let request = ChunkPutRequest::new(address, content.clone()); - let msg = ChunkMessage { - request_id: 42, - body: ChunkMessageBody::PutRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 42); - if let ChunkMessageBody::PutRequest(req) = decoded.body { - assert_eq!(req.address, address); - assert_eq!(req.content, content); - assert!(req.payment_proof.is_none()); - } else { - panic!("expected PutRequest"); - } - } - - #[test] - fn test_put_request_with_payment() { - let address = [0xAB; 32]; - let content = vec![1, 2, 3, 4, 5]; - let payment = vec![10, 20, 30]; - let request = ChunkPutRequest::with_payment(address, content.clone(), payment.clone()); - - assert_eq!(request.address, address); - assert_eq!(request.content, content); - assert_eq!(request.payment_proof, Some(payment)); - } - - #[test] - fn test_get_request_encode_decode() { - let address = [0xCD; 32]; - let request = ChunkGetRequest::new(address); - let msg = ChunkMessage { - request_id: 7, - body: ChunkMessageBody::GetRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 7); - if let ChunkMessageBody::GetRequest(req) = decoded.body { - assert_eq!(req.address, address); - } else { - panic!("expected GetRequest"); - } - } - - #[test] - fn test_put_response_success() { - let address = [0xEF; 32]; - let response = ChunkPutResponse::Success { address }; - let msg = ChunkMessage { - request_id: 99, - body: ChunkMessageBody::PutResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 99); - if let ChunkMessageBody::PutResponse(ChunkPutResponse::Success { address: addr }) = - decoded.body - { - assert_eq!(addr, address); - } else { - panic!("expected PutResponse::Success"); - } - } - - #[test] - fn test_get_response_not_found() { - let address = [0x12; 32]; - let response = ChunkGetResponse::NotFound { address }; - let msg = ChunkMessage { - request_id: 0, - body: ChunkMessageBody::GetResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 0); - if let ChunkMessageBody::GetResponse(ChunkGetResponse::NotFound { address: addr }) = - decoded.body - { - assert_eq!(addr, address); - } else { - panic!("expected GetResponse::NotFound"); - } - } - - #[test] - fn test_quote_request_encode_decode() { - let address = [0x34; 32]; - let request = ChunkQuoteRequest::new(address, 1024); - let msg = ChunkMessage { - request_id: 1, - body: ChunkMessageBody::QuoteRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 1); - if let ChunkMessageBody::QuoteRequest(req) = decoded.body { - assert_eq!(req.address, address); - assert_eq!(req.data_size, 1024); - assert_eq!(req.data_type, DATA_TYPE_CHUNK); - } else { - panic!("expected QuoteRequest"); - } - } - - #[test] - fn test_protocol_error_display() { - let err = ProtocolError::ChunkTooLarge { - size: 5_000_000, - max_size: MAX_CHUNK_SIZE, - }; - assert!(err.to_string().contains("5000000")); - assert!(err.to_string().contains(&MAX_CHUNK_SIZE.to_string())); - - let err = ProtocolError::AddressMismatch { - expected: [0xAA; 32], - actual: [0xBB; 32], - }; - let display = err.to_string(); - assert!(display.contains("address mismatch")); - } - - #[test] - fn test_decode_rejects_oversized_payload() { - let oversized = vec![0u8; MAX_WIRE_MESSAGE_SIZE + 1]; - let result = ChunkMessage::decode(&oversized); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - matches!(err, ProtocolError::MessageTooLarge { .. }), - "expected MessageTooLarge, got {err:?}" - ); - } - - #[test] - fn test_invalid_decode() { - let invalid_data = vec![0xFF, 0xFF, 0xFF]; - let result = ChunkMessage::decode(&invalid_data); - assert!(result.is_err()); - } - - #[test] - fn test_constants() { - assert_eq!(CHUNK_PROTOCOL_ID, "autonomi.ant.chunk.v1"); - assert_eq!(PROTOCOL_VERSION, 1); - assert_eq!(MAX_CHUNK_SIZE, 4 * 1024 * 1024); - assert_eq!(DATA_TYPE_CHUNK, 0); - } - - #[test] - fn test_proof_tag_constants() { - // Tags must be distinct non-zero bytes - assert_ne!(PROOF_TAG_SINGLE_NODE, PROOF_TAG_MERKLE); - assert_ne!(PROOF_TAG_SINGLE_NODE, 0x00); - assert_ne!(PROOF_TAG_MERKLE, 0x00); - assert_eq!(PROOF_TAG_SINGLE_NODE, 0x01); - assert_eq!(PROOF_TAG_MERKLE, 0x02); - } - - #[test] - fn test_merkle_candidate_quote_request_encode_decode() { - let address = [0x56; 32]; - let request = MerkleCandidateQuoteRequest { - address, - data_type: DATA_TYPE_CHUNK, - data_size: 2048, - merkle_payment_timestamp: 1_700_000_000, - }; - let msg = ChunkMessage { - request_id: 500, - body: ChunkMessageBody::MerkleCandidateQuoteRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 500); - if let ChunkMessageBody::MerkleCandidateQuoteRequest(req) = decoded.body { - assert_eq!(req.address, address); - assert_eq!(req.data_type, DATA_TYPE_CHUNK); - assert_eq!(req.data_size, 2048); - assert_eq!(req.merkle_payment_timestamp, 1_700_000_000); - } else { - panic!("expected MerkleCandidateQuoteRequest"); - } - } - - #[test] - fn test_merkle_candidate_quote_response_success_encode_decode() { - let candidate_node_bytes = vec![0xAA, 0xBB, 0xCC, 0xDD]; - let response = MerkleCandidateQuoteResponse::Success { - candidate_node: candidate_node_bytes.clone(), - }; - let msg = ChunkMessage { - request_id: 501, - body: ChunkMessageBody::MerkleCandidateQuoteResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 501); - if let ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Success { candidate_node }, - ) = decoded.body - { - assert_eq!(candidate_node, candidate_node_bytes); - } else { - panic!("expected MerkleCandidateQuoteResponse::Success"); - } - } - - #[test] - fn test_merkle_candidate_quote_response_error_encode_decode() { - let error = ProtocolError::QuoteFailed("no libp2p keypair".to_string()); - let response = MerkleCandidateQuoteResponse::Error(error.clone()); - let msg = ChunkMessage { - request_id: 502, - body: ChunkMessageBody::MerkleCandidateQuoteResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 502); - if let ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Error(err), - ) = decoded.body - { - assert_eq!(err, error); - } else { - panic!("expected MerkleCandidateQuoteResponse::Error"); - } - } -} diff --git a/src/ant_protocol/mod.rs b/src/ant_protocol/mod.rs index 563722d2..ed01cf54 100644 --- a/src/ant_protocol/mod.rs +++ b/src/ant_protocol/mod.rs @@ -1,66 +1,25 @@ -//! ANT protocol implementation for the Autonomi network. -//! -//! This module implements the wire protocol for storing and retrieving -//! data on the Autonomi network. -//! -//! # Data Types -//! -//! The ANT protocol supports a single data type: -//! -//! - **Chunk**: Immutable, content-addressed data (hash == address) -//! -//! # Protocol Overview -//! -//! The protocol uses postcard serialization for compact, fast encoding. -//! Each data type has its own message types for PUT/GET operations. -//! -//! ## Chunk Messages -//! -//! - `ChunkPutRequest` / `ChunkPutResponse` - Store chunks -//! - `ChunkGetRequest` / `ChunkGetResponse` - Retrieve chunks -//! - `ChunkQuoteRequest` / `ChunkQuoteResponse` - Request storage quotes -//! -//! ## Payment Flow -//! -//! 1. Client requests a quote via `ChunkQuoteRequest` -//! 2. Node returns signed `PaymentQuote` in `ChunkQuoteResponse` -//! 3. Client pays on Arbitrum via `PaymentVault.payForQuotes()` -//! 4. Client sends `ChunkPutRequest` with `payment_proof` -//! 5. Node verifies payment and stores chunk -//! -//! # Example -//! -//! ```rust,ignore -//! use ant_node::ant_protocol::{ChunkMessage, ChunkPutRequest, ChunkGetRequest}; -//! -//! // Create a PUT request -//! let address = compute_address(&data); -//! let request = ChunkPutRequest::with_payment(address, data, payment_proof); -//! let message = ChunkMessage::PutRequest(request); -//! let bytes = message.encode()?; -//! -//! // Decode a response -//! let response = ChunkMessage::decode(&response_bytes)?; -//! ``` - -pub mod chunk; - -/// Number of nodes in a Kademlia close group. -/// -/// Clients fetch quotes from the `CLOSE_GROUP_SIZE` closest nodes to a target -/// address and select the median-priced quote for payment. -pub const CLOSE_GROUP_SIZE: usize = 7; +//! Wire protocol re-exports from the [`ant_protocol`] crate. +//! +//! This module existed as first-party ant-node code until version 0.11. +//! The wire contract now lives in the [`ant_protocol`] crate so +//! `ant-client` and `ant-node` can evolve their release cycles +//! independently. Everything this module previously exported is +//! re-exported below verbatim, including the `chunk` submodule path so +//! downstream callers of `ant_node::ant_protocol::chunk::*` keep working. +//! +//! Internal ant-node code can keep using `crate::ant_protocol::…`; the +//! imports resolve to the same types they always did. New code should +//! prefer `ant_protocol::…` directly. -/// Minimum number of close group members that must agree for a decision to be valid. -/// -/// This is a simple majority: `(CLOSE_GROUP_SIZE / 2) + 1`. -pub const CLOSE_GROUP_MAJORITY: usize = (CLOSE_GROUP_SIZE / 2) + 1; +// Re-export the submodule so `ant_node::ant_protocol::chunk::*` keeps +// resolving. Using the fully-qualified path `::ant_protocol::chunk` +// disambiguates from `crate::ant_protocol` (this module). +pub use ::ant_protocol::chunk; -// Re-export chunk types for convenience -pub use chunk::{ +pub use ::ant_protocol::chunk::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, MerkleCandidateQuoteRequest, - MerkleCandidateQuoteResponse, ProtocolError, XorName, CHUNK_PROTOCOL_ID, DATA_TYPE_CHUNK, - MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE, - PROTOCOL_VERSION, XORNAME_LEN, + MerkleCandidateQuoteResponse, ProtocolError, XorName, CHUNK_PROTOCOL_ID, CLOSE_GROUP_MAJORITY, + CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE, + PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, XORNAME_LEN, }; diff --git a/src/client/chunk_protocol.rs b/src/client/chunk_protocol.rs deleted file mode 100644 index ea9e50c4..00000000 --- a/src/client/chunk_protocol.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Shared helper for the chunk protocol request/response pattern. -//! -//! Extracts the duplicated "subscribe → send → poll event loop" into a single -//! generic function used by both [`super::QuantumClient`] and E2E test helpers. - -use crate::ant_protocol::{ChunkMessage, ChunkMessageBody, CHUNK_PROTOCOL_ID}; -use crate::logging::{debug, warn}; -use saorsa_core::identity::PeerId; -use saorsa_core::{MultiAddr, P2PEvent, P2PNode}; -use std::time::Duration; -use tokio::sync::broadcast::error::RecvError; -use tokio::time::Instant; - -/// Send a chunk-protocol message to `target_peer` and await a matching response. -/// -/// The event loop filters by topic (`CHUNK_PROTOCOL_ID`), source peer, decode -/// errors (warn + skip), and `request_id` mismatch (skip). -/// -/// * `response_handler` — inspects the decoded [`ChunkMessageBody`] and returns: -/// - `Some(Ok(T))` to resolve successfully, -/// - `Some(Err(E))` to resolve with an error, -/// - `None` to keep waiting (wrong variant / not our response). -/// * `send_error` — produces the caller's error type when `send_message` fails. -/// * `timeout_error` — produces the caller's error type on deadline expiry. -/// -/// # Errors -/// -/// Returns `Err(E)` if sending fails (via `send_error`), the `response_handler` -/// returns a protocol-level error, or the deadline expires (via `timeout_error`). -#[allow(clippy::too_many_arguments)] -pub async fn send_and_await_chunk_response( - node: &P2PNode, - target_peer: &PeerId, - message_bytes: Vec, - request_id: u64, - timeout: Duration, - peer_addrs: &[MultiAddr], - response_handler: impl Fn(ChunkMessageBody) -> Option>, - send_error: impl FnOnce(String) -> E, - timeout_error: impl FnOnce() -> E, -) -> Result { - // Subscribe before sending so we don't miss the response - let mut events = node.subscribe_events(); - - node.send_message(target_peer, CHUNK_PROTOCOL_ID, message_bytes, peer_addrs) - .await - .map_err(|e| send_error(e.to_string()))?; - - let deadline = Instant::now() + timeout; - - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - match tokio::time::timeout(remaining, events.recv()).await { - Ok(Ok(P2PEvent::Message { - topic, - source: Some(source), - data, - })) if topic == CHUNK_PROTOCOL_ID && source == *target_peer => { - let response = match ChunkMessage::decode(&data) { - Ok(r) => r, - Err(e) => { - warn!("Failed to decode chunk message, skipping: {e}"); - continue; - } - }; - if response.request_id != request_id { - continue; - } - if let Some(result) = response_handler(response.body) { - return result; - } - } - Ok(Ok(_)) => {} - Ok(Err(RecvError::Lagged(skipped))) => { - debug!("Chunk protocol events lagged by {skipped} messages, continuing"); - } - Ok(Err(RecvError::Closed)) | Err(_) => break, - } - } - - Err(timeout_error()) -} diff --git a/src/client/data_types.rs b/src/client/data_types.rs deleted file mode 100644 index 191b3251..00000000 --- a/src/client/data_types.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Data type definitions for chunk storage. -//! -//! This module provides the core data types for content-addressed chunk storage -//! on the Autonomi network. Chunks are immutable, content-addressed blobs where -//! the address is the BLAKE3 hash of the content. - -use bytes::Bytes; -/// Compute the content address (BLAKE3 hash) for the given data. -#[must_use] -pub fn compute_address(content: &[u8]) -> XorName { - *blake3::hash(content).as_bytes() -} - -/// Compute the XOR distance between two 32-byte addresses. -/// -/// Lexicographic comparison of the result gives correct Kademlia distance ordering. -#[must_use] -pub fn xor_distance(a: &XorName, b: &XorName) -> XorName { - std::array::from_fn(|i| a[i] ^ b[i]) -} - -/// Convert a hex-encoded peer ID string to an `XorName`. -/// -/// Returns `None` if the string is not valid hex or is not exactly 32 bytes (64 hex chars). -#[must_use] -pub fn peer_id_to_xor_name(peer_id: &str) -> Option { - let bytes = hex::decode(peer_id).ok()?; - if bytes.len() != 32 { - return None; - } - let mut name = [0u8; 32]; - name.copy_from_slice(&bytes); - Some(name) -} - -/// A content-addressed identifier (32 bytes). -/// -/// The address is computed as BLAKE3(content) for chunks, -/// ensuring content-addressed storage. -pub type XorName = [u8; 32]; - -/// A chunk of data with its content-addressed identifier. -/// -/// Chunks are the fundamental storage unit in Autonomi. They are: -/// - **Immutable**: Content cannot be changed after storage -/// - **Content-addressed**: Address = BLAKE3(content) -/// - **Paid**: Storage requires EVM payment on Arbitrum -#[derive(Debug, Clone)] -pub struct DataChunk { - /// The content-addressed identifier (BLAKE3 of content). - pub address: XorName, - /// The raw data content. - pub content: Bytes, -} - -impl DataChunk { - /// Create a new data chunk. - /// - /// Note: This does NOT verify that address == BLAKE3(content). - /// Use `from_content` for automatic address computation. - #[must_use] - pub fn new(address: XorName, content: Bytes) -> Self { - Self { address, content } - } - - /// Create a chunk from content, computing the address automatically. - #[must_use] - pub fn from_content(content: Bytes) -> Self { - let address = compute_address(&content); - Self { address, content } - } - - /// Get the size of the chunk in bytes. - #[must_use] - pub fn size(&self) -> usize { - self.content.len() - } - - /// Verify that the address matches BLAKE3(content). - #[must_use] - pub fn verify(&self) -> bool { - self.address == compute_address(&self.content) - } -} - -/// Statistics about chunk operations. -#[derive(Debug, Default, Clone)] -pub struct ChunkStats { - /// Number of chunks stored. - pub chunks_stored: u64, - /// Number of chunks retrieved. - pub chunks_retrieved: u64, - /// Number of cache hits. - pub cache_hits: u64, - /// Number of misses (not found). - pub misses: u64, - /// Total bytes stored. - pub bytes_stored: u64, - /// Total bytes retrieved. - pub bytes_retrieved: u64, -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn test_data_chunk_creation() { - let address = [0xAB; 32]; - let content = Bytes::from("test data"); - let chunk = DataChunk::new(address, content.clone()); - - assert_eq!(chunk.address, address); - assert_eq!(chunk.content, content); - assert_eq!(chunk.size(), 9); - } - - #[test] - fn test_chunk_from_content() { - let content = Bytes::from("hello world"); - let chunk = DataChunk::from_content(content.clone()); - - // BLAKE3 of "hello world" - let expected: [u8; 32] = [ - 0xd7, 0x49, 0x81, 0xef, 0xa7, 0x0a, 0x0c, 0x88, 0x0b, 0x8d, 0x8c, 0x19, 0x85, 0xd0, - 0x75, 0xdb, 0xcb, 0xf6, 0x79, 0xb9, 0x9a, 0x5f, 0x99, 0x14, 0xe5, 0xaa, 0xf9, 0x6b, - 0x83, 0x1a, 0x9e, 0x24, - ]; - - assert_eq!(chunk.address, expected); - assert_eq!(chunk.content, content); - assert!(chunk.verify()); - } - - #[test] - fn test_xor_distance_identity() { - let a = [0xAB; 32]; - assert_eq!(xor_distance(&a, &a), [0u8; 32]); - } - - #[test] - fn test_xor_distance_symmetry() { - let a = [0x01; 32]; - let b = [0xFF; 32]; - assert_eq!(xor_distance(&a, &b), xor_distance(&b, &a)); - } - - #[test] - fn test_xor_distance_known_values() { - let a = [0x00; 32]; - let b = [0xFF; 32]; - assert_eq!(xor_distance(&a, &b), [0xFF; 32]); - - let mut c = [0x00; 32]; - c[0] = 0x80; - let mut expected = [0x00; 32]; - expected[0] = 0x80; - assert_eq!(xor_distance(&a, &c), expected); - } - - #[test] - fn test_peer_id_to_xor_name_valid() { - let hex_str = "ab".repeat(32); - let result = peer_id_to_xor_name(&hex_str); - assert_eq!(result, Some([0xAB; 32])); - } - - #[test] - fn test_peer_id_to_xor_name_invalid_hex() { - assert_eq!(peer_id_to_xor_name("not_hex_at_all!"), None); - } - - #[test] - fn test_peer_id_to_xor_name_wrong_length() { - // 16 bytes instead of 32 - let short = "ab".repeat(16); - assert_eq!(peer_id_to_xor_name(&short), None); - - // 33 bytes - let long = "ab".repeat(33); - assert_eq!(peer_id_to_xor_name(&long), None); - } - - #[test] - fn test_chunk_verify() { - // Valid chunk - let content = Bytes::from("test"); - let valid = DataChunk::from_content(content); - assert!(valid.verify()); - - // Invalid chunk (wrong address) - let invalid = DataChunk::new([0; 32], Bytes::from("test")); - assert!(!invalid.verify()); - } -} diff --git a/src/client/mod.rs b/src/client/mod.rs index b67339eb..987d28bb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,49 +1,36 @@ //! Protocol helpers for ant-node client operations. //! -//! This module provides low-level protocol support for client-node communication. -//! For high-level client operations, use the `ant-client` crate instead. +//! This module provides low-level protocol support for client-node +//! communication. For high-level client operations, use the `ant-client` +//! crate instead. //! //! # Architecture //! -//! This module contains: -//! -//! 1. **Protocol message handlers**: Send/await pattern for chunks -//! 2. **Data types**: Common types like `XorName`, `DataChunk`, address computation -//! -//! # Migration Note -//! -//! The `QuantumClient` has been deprecated and consolidated into `ant-client::Client`. -//! Use `ant-client` for all client operations. +//! As of 0.11, the shared protocol types and helpers live in the +//! [`ant_protocol`] crate. This module re-exports them so existing +//! callers of `ant_node::client::*` continue to compile; new code +//! should prefer `ant_protocol::*` directly. //! //! # Example //! //! ```rust,ignore -//! use ant_client::Client; // Use ant-client instead of QuantumClient +//! use ant_client::Client; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // High-level client API //! let client = Client::connect(&bootstrap_peers, Default::default()).await?; -//! -//! // Store data with payment //! let address = client.chunk_put(bytes::Bytes::from("hello world")).await?; -//! -//! // Retrieve data //! let chunk = client.chunk_get(&address).await?; -//! //! Ok(()) //! } //! ``` -mod chunk_protocol; -mod data_types; - -pub use chunk_protocol::send_and_await_chunk_response; -pub use data_types::{ - compute_address, peer_id_to_xor_name, xor_distance, ChunkStats, DataChunk, XorName, +pub use ant_protocol::chunk_protocol::send_and_await_chunk_response; +pub use ant_protocol::data_types::{ + compute_address, peer_id_to_xor_name, xor_distance, ChunkStats, DataChunk, }; +pub use ant_protocol::XorName; -// Re-export hex_node_id_to_encoded_peer_id for payment operations use crate::error::{Error, Result}; use evmlib::EncodedPeerId; diff --git a/src/devnet.rs b/src/devnet.rs index a049be6c..5fe0c3d1 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -18,7 +18,6 @@ use saorsa_core::identity::NodeIdentity; use saorsa_core::{ IPDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, PeerId, }; -use serde::{Deserialize, Serialize}; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -214,36 +213,10 @@ impl DevnetConfig { } } -/// Devnet manifest for client discovery. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DevnetManifest { - /// Base port for nodes. - pub base_port: u16, - /// Node count. - pub node_count: usize, - /// Bootstrap addresses. - pub bootstrap: Vec, - /// Data directory. - pub data_dir: PathBuf, - /// Creation time in RFC3339. - pub created_at: String, - /// EVM configuration (present when EVM payment enforcement is enabled). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evm: Option, -} - -/// EVM configuration info included in the devnet manifest. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DevnetEvmInfo { - /// Anvil RPC URL. - pub rpc_url: String, - /// Funded wallet private key (hex-encoded with 0x prefix). - pub wallet_private_key: String, - /// Payment token contract address. - pub payment_token_address: String, - /// Unified payment vault contract address (handles both single-node and merkle payments). - pub payment_vault_address: String, -} +// The manifest types are shared with ant-client (CLI reads them, devnet +// writes them), so they live in ant-protocol. Re-exported here for +// backwards compatibility. +pub use ant_protocol::devnet_manifest::{DevnetEvmInfo, DevnetManifest}; /// Network state for devnet startup lifecycle. #[derive(Debug, Clone)] diff --git a/src/payment/mod.rs b/src/payment/mod.rs index d6cd0d50..72ee0ff5 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -54,9 +54,11 @@ pub use proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, serialize_single_node_proof, PaymentProof, ProofType, }; -pub use quote::{ - verify_merkle_candidate_signature, verify_quote_content, wire_ml_dsa_signer, QuoteGenerator, - XorName, +pub use quote::{wire_ml_dsa_signer, QuoteGenerator, XorName}; +// Wire-side signature verification lives in ant-protocol. Re-exported here +// so `ant_node::payment::verify_*` keeps working for downstream callers. +pub use ant_protocol::payment::verify::{ + verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature, }; pub use single_node::SingleNodePayment; pub use verifier::{ diff --git a/src/payment/proof.rs b/src/payment/proof.rs index 0db0b5e0..269a4366 100644 --- a/src/payment/proof.rs +++ b/src/payment/proof.rs @@ -1,385 +1,10 @@ -//! Payment proof wrapper that includes transaction hashes. +//! Payment proof re-exports from [`ant_protocol`]. //! -//! `PaymentProof` bundles a `ProofOfPayment` (quotes + peer IDs) with the -//! on-chain transaction hashes returned by the wallet after payment. - -use crate::ant_protocol::{PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE}; -use evmlib::common::TxHash; -use evmlib::merkle_payments::MerklePaymentProof; -use evmlib::ProofOfPayment; -use serde::{Deserialize, Serialize}; - -/// A payment proof that includes both the quote-based proof and on-chain tx hashes. -/// -/// This replaces the bare `ProofOfPayment` in serialized proof bytes, adding -/// the transaction hashes that were previously discarded after `payment.pay()`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaymentProof { - /// The original quote-based proof (peer IDs + quotes with ML-DSA-65 signatures). - pub proof_of_payment: ProofOfPayment, - /// Transaction hashes from the on-chain payment. - /// Typically contains one hash for the median (non-zero) quote. - pub tx_hashes: Vec, -} - -/// The detected type of a payment proof. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProofType { - /// `SingleNode` payment (`CLOSE_GROUP_SIZE` quotes, median-paid). - SingleNode, - /// Merkle batch payment (one tx for many chunks). - Merkle, -} - -/// Detect the proof type from the first byte (version tag). -/// -/// Returns `None` if the tag byte is unrecognized or the slice is empty. -#[must_use] -pub fn detect_proof_type(bytes: &[u8]) -> Option { - match bytes.first() { - Some(&PROOF_TAG_SINGLE_NODE) => Some(ProofType::SingleNode), - Some(&PROOF_TAG_MERKLE) => Some(ProofType::Merkle), - _ => None, - } -} - -/// Serialize a `PaymentProof` (single-node) with the version tag prefix. -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn serialize_single_node_proof( - proof: &PaymentProof, -) -> std::result::Result, rmp_serde::encode::Error> { - let body = rmp_serde::to_vec(proof)?; - let mut tagged = Vec::with_capacity(1 + body.len()); - tagged.push(PROOF_TAG_SINGLE_NODE); - tagged.extend_from_slice(&body); - Ok(tagged) -} - -/// Serialize a `MerklePaymentProof` with the version tag prefix. -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn serialize_merkle_proof( - proof: &MerklePaymentProof, -) -> std::result::Result, rmp_serde::encode::Error> { - let body = rmp_serde::to_vec(proof)?; - let mut tagged = Vec::with_capacity(1 + body.len()); - tagged.push(PROOF_TAG_MERKLE); - tagged.extend_from_slice(&body); - Ok(tagged) -} - -/// Deserialize proof bytes from the `PaymentProof` format (single-node). -/// -/// Expects the first byte to be `PROOF_TAG_SINGLE_NODE`. -/// Returns `(ProofOfPayment, Vec)`. -/// -/// # Errors -/// -/// Returns an error if the tag is missing or the bytes cannot be deserialized. -pub fn deserialize_proof(bytes: &[u8]) -> Result<(ProofOfPayment, Vec), String> { - if bytes.first() != Some(&PROOF_TAG_SINGLE_NODE) { - return Err("Missing single-node proof tag byte".to_string()); - } - let payload = bytes - .get(1..) - .ok_or_else(|| "Single-node proof tag present but no payload".to_string())?; - let proof = rmp_serde::from_slice::(payload) - .map_err(|e| format!("Failed to deserialize single-node proof: {e}"))?; - Ok((proof.proof_of_payment, proof.tx_hashes)) -} - -/// Deserialize proof bytes as a `MerklePaymentProof`. -/// -/// Expects the first byte to be `PROOF_TAG_MERKLE`. -/// -/// # Errors -/// -/// Returns an error if the bytes cannot be deserialized or the tag is wrong. -pub fn deserialize_merkle_proof(bytes: &[u8]) -> std::result::Result { - if bytes.first() != Some(&PROOF_TAG_MERKLE) { - return Err("Missing merkle proof tag byte".to_string()); - } - let payload = bytes - .get(1..) - .ok_or_else(|| "Merkle proof tag present but no payload".to_string())?; - rmp_serde::from_slice::(payload) - .map_err(|e| format!("Failed to deserialize merkle proof: {e}")) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - use alloy::primitives::FixedBytes; - use evmlib::common::Amount; - use evmlib::merkle_payments::{ - MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree, - CANDIDATES_PER_POOL, - }; - use evmlib::EncodedPeerId; - use evmlib::PaymentQuote; - use evmlib::RewardsAddress; - use saorsa_core::MlDsa65; - use saorsa_pqc::pqc::types::MlDsaSecretKey; - use saorsa_pqc::pqc::MlDsaOperations; - use std::time::SystemTime; - use xor_name::XorName; - - fn make_test_quote() -> PaymentQuote { - PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(1u64), - rewards_address: RewardsAddress::new([1u8; 20]), - pub_key: vec![], - signature: vec![], - } - } - - fn make_proof_of_payment() -> ProofOfPayment { - let random_peer = EncodedPeerId::new(rand::random()); - ProofOfPayment { - peer_quotes: vec![(random_peer, make_test_quote())], - } - } - - #[test] - fn test_payment_proof_serialization_roundtrip() { - let tx_hash = FixedBytes::from([0xABu8; 32]); - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![tx_hash], - }; - - let bytes = serialize_single_node_proof(&proof).unwrap(); - let (pop, hashes) = deserialize_proof(&bytes).unwrap(); - - assert_eq!(pop.peer_quotes.len(), 1); - assert_eq!(hashes.len(), 1); - assert_eq!(hashes.first().unwrap(), &tx_hash); - } - - #[test] - fn test_payment_proof_with_empty_tx_hashes() { - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![], - }; - - let bytes = serialize_single_node_proof(&proof).unwrap(); - let (pop, hashes) = deserialize_proof(&bytes).unwrap(); - - assert_eq!(pop.peer_quotes.len(), 1); - assert!(hashes.is_empty()); - } - - #[test] - fn test_deserialize_proof_rejects_garbage() { - let garbage = vec![0xFF, 0x00, 0x01, 0x02]; - let result = deserialize_proof(&garbage); - assert!(result.is_err()); - } - - #[test] - fn test_deserialize_proof_rejects_untagged() { - // Raw msgpack without tag byte must be rejected - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![], - }; - let raw_bytes = rmp_serde::to_vec(&proof).unwrap(); - let result = deserialize_proof(&raw_bytes); - assert!(result.is_err()); - } - - #[test] - fn test_payment_proof_multiple_tx_hashes() { - let tx1 = FixedBytes::from([0x11u8; 32]); - let tx2 = FixedBytes::from([0x22u8; 32]); - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![tx1, tx2], - }; - - let bytes = serialize_single_node_proof(&proof).unwrap(); - let (_, hashes) = deserialize_proof(&bytes).unwrap(); - - assert_eq!(hashes.len(), 2); - assert_eq!(hashes.first().unwrap(), &tx1); - assert_eq!(hashes.get(1).unwrap(), &tx2); - } - - // ========================================================================= - // detect_proof_type tests - // ========================================================================= - - #[test] - fn test_detect_proof_type_single_node() { - let bytes = [PROOF_TAG_SINGLE_NODE, 0x00, 0x01]; - let result = detect_proof_type(&bytes); - assert_eq!(result, Some(ProofType::SingleNode)); - } - - #[test] - fn test_detect_proof_type_merkle() { - let bytes = [PROOF_TAG_MERKLE, 0x00, 0x01]; - let result = detect_proof_type(&bytes); - assert_eq!(result, Some(ProofType::Merkle)); - } - - #[test] - fn test_detect_proof_type_unknown_tag() { - let bytes = [0xFF, 0x00, 0x01]; - let result = detect_proof_type(&bytes); - assert_eq!(result, None); - } - - #[test] - fn test_detect_proof_type_empty_bytes() { - let bytes: &[u8] = &[]; - let result = detect_proof_type(bytes); - assert_eq!(result, None); - } - - // ========================================================================= - // Tagged serialize/deserialize round-trip tests - // ========================================================================= - - #[test] - fn test_serialize_single_node_proof_roundtrip_with_tag() { - let tx_hash = FixedBytes::from([0xCCu8; 32]); - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![tx_hash], - }; - - let tagged_bytes = serialize_single_node_proof(&proof).unwrap(); - - // First byte must be the single-node tag - assert_eq!( - tagged_bytes.first().copied(), - Some(PROOF_TAG_SINGLE_NODE), - "Tagged proof must start with PROOF_TAG_SINGLE_NODE" - ); - - // detect_proof_type should identify it - assert_eq!( - detect_proof_type(&tagged_bytes), - Some(ProofType::SingleNode) - ); - - // deserialize_proof handles the tag transparently - let (pop, hashes) = deserialize_proof(&tagged_bytes).unwrap(); - assert_eq!(pop.peer_quotes.len(), 1); - assert_eq!(hashes.len(), 1); - assert_eq!(hashes.first().unwrap(), &tx_hash); - } - - // ========================================================================= - // Merkle proof serialize/deserialize round-trip tests - // ========================================================================= - - /// Create a minimal valid `MerklePaymentProof` from a small merkle tree. - fn make_test_merkle_proof() -> MerklePaymentProof { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Build a tree with 4 addresses (minimal depth) - let addresses: Vec = (0..4u8) - .map(|i| xor_name::XorName::from_content(&[i])) - .collect(); - let tree = MerkleTree::from_xornames(addresses.clone()).unwrap(); - - // Build candidate nodes with ML-DSA-65 signing (matching production) - let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] = - std::array::from_fn(|i| { - let ml_dsa = MlDsa65::new(); - let (pub_key, secret_key) = ml_dsa.generate_keypair().expect("keygen"); - let price = Amount::from(1024u64); - #[allow(clippy::cast_possible_truncation)] - let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = - MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); - let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); - let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); - - MerklePaymentCandidateNode { - pub_key: pub_key.as_bytes().to_vec(), - price, - reward_address, - merkle_payment_timestamp: timestamp, - signature, - } - }); - - let reward_candidates = tree.reward_candidates(timestamp).unwrap(); - let midpoint_proof = reward_candidates.first().unwrap().clone(); - - let pool = MerklePaymentCandidatePool { - midpoint_proof, - candidate_nodes, - }; - - let first_address = *addresses.first().unwrap(); - let address_proof = tree.generate_address_proof(0, first_address).unwrap(); - - MerklePaymentProof::new(first_address, address_proof, pool) - } - - #[test] - fn test_serialize_merkle_proof_roundtrip() { - let merkle_proof = make_test_merkle_proof(); - - let tagged_bytes = serialize_merkle_proof(&merkle_proof).unwrap(); - - // First byte must be the merkle tag - assert_eq!( - tagged_bytes.first().copied(), - Some(PROOF_TAG_MERKLE), - "Tagged merkle proof must start with PROOF_TAG_MERKLE" - ); - - // detect_proof_type should identify it as merkle - assert_eq!(detect_proof_type(&tagged_bytes), Some(ProofType::Merkle)); - - // deserialize_merkle_proof should recover the original proof - let recovered = deserialize_merkle_proof(&tagged_bytes).unwrap(); - assert_eq!(recovered.address, merkle_proof.address); - assert_eq!( - recovered.winner_pool.candidate_nodes.len(), - CANDIDATES_PER_POOL - ); - } - - #[test] - fn test_deserialize_merkle_proof_rejects_wrong_tag() { - let merkle_proof = make_test_merkle_proof(); - let mut tagged_bytes = serialize_merkle_proof(&merkle_proof).unwrap(); - - // Replace the tag with the single-node tag - if let Some(first) = tagged_bytes.first_mut() { - *first = PROOF_TAG_SINGLE_NODE; - } - - let result = deserialize_merkle_proof(&tagged_bytes); - assert!(result.is_err(), "Should reject wrong tag byte"); - let err_msg = result.unwrap_err(); - assert!( - err_msg.contains("Missing merkle proof tag"), - "Error should mention missing tag: {err_msg}" - ); - } - - #[test] - fn test_deserialize_merkle_proof_rejects_empty() { - let result = deserialize_merkle_proof(&[]); - assert!(result.is_err()); - } -} +//! Extracted to the [`ant_protocol`] crate in 0.11 so `ant-client` and +//! `ant-node` share one version of the serialization format. Internal +//! callers using `crate::payment::proof::…` keep working unchanged. + +pub use ant_protocol::payment::proof::{ + deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, + serialize_single_node_proof, PaymentProof, ProofType, +}; diff --git a/src/payment/quote.rs b/src/payment/quote.rs index d3dd9f64..7052e284 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -15,7 +15,7 @@ use evmlib::merkle_payments::MerklePaymentCandidateNode; use evmlib::PaymentQuote; use evmlib::RewardsAddress; use saorsa_core::MlDsa65; -use saorsa_pqc::pqc::types::{MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature}; +use saorsa_pqc::pqc::types::MlDsaSecretKey; use saorsa_pqc::pqc::MlDsaOperations; use std::time::SystemTime; @@ -245,131 +245,10 @@ impl QuoteGenerator { } } -/// Verify a payment quote's content address and ML-DSA-65 signature. -/// -/// # Arguments -/// -/// * `quote` - The quote to verify -/// * `expected_content` - The expected content `XorName` -/// -/// # Returns -/// -/// `true` if the content matches and the ML-DSA-65 signature is valid. -#[must_use] -pub fn verify_quote_content(quote: &PaymentQuote, expected_content: &XorName) -> bool { - // Check content matches - if quote.content.0 != *expected_content { - if crate::logging::enabled!(crate::logging::Level::DEBUG) { - debug!( - "Quote content mismatch: expected {}, got {}", - hex::encode(expected_content), - hex::encode(quote.content.0) - ); - } - return false; - } - true -} - -/// Verify that a payment quote has a valid ML-DSA-65 signature. -/// -/// This replaces ant-evm's `check_is_signed_by_claimed_peer()` which only -/// handles Ed25519/libp2p signatures. Autonomi uses ML-DSA-65 post-quantum -/// signatures for quote signing. -/// -/// # Arguments -/// -/// * `quote` - The quote to verify -/// -/// # Returns -/// -/// `true` if the ML-DSA-65 signature is valid for the quote's content. -#[must_use] -pub fn verify_quote_signature(quote: &PaymentQuote) -> bool { - // Parse public key from quote - let pub_key = match MlDsaPublicKey::from_bytes("e.pub_key) { - Ok(pk) => pk, - Err(e) => { - debug!("Failed to parse ML-DSA-65 public key from quote: {e}"); - return false; - } - }; - - // Parse signature from quote - let signature = match MlDsaSignature::from_bytes("e.signature) { - Ok(sig) => sig, - Err(e) => { - debug!("Failed to parse ML-DSA-65 signature from quote: {e}"); - return false; - } - }; - - // Get the bytes that were signed - let bytes = quote.bytes_for_sig(); - - // Verify using ML-DSA-65 implementation - let ml_dsa = MlDsa65::new(); - match ml_dsa.verify(&pub_key, &bytes, &signature) { - Ok(valid) => { - if !valid { - debug!("ML-DSA-65 quote signature verification failed"); - } - valid - } - Err(e) => { - debug!("ML-DSA-65 verification error: {e}"); - false - } - } -} - -/// Verify a `MerklePaymentCandidateNode` signature using ML-DSA-65. -/// -/// Autonomi uses ML-DSA-65 post-quantum signatures for merkle candidate signing, -/// rather than the ed25519 signatures used by the upstream `ant-evm` library. -/// The `pub_key` field contains the raw ML-DSA-65 public key bytes, and -/// `signature` contains the ML-DSA-65 signature over `bytes_to_sign()`. -/// -/// This replaces `MerklePaymentCandidateNode::verify_signature()` which -/// expects libp2p ed25519 keys. -#[must_use] -pub fn verify_merkle_candidate_signature(candidate: &MerklePaymentCandidateNode) -> bool { - let pub_key = match MlDsaPublicKey::from_bytes(&candidate.pub_key) { - Ok(pk) => pk, - Err(e) => { - debug!("Failed to parse ML-DSA-65 public key from merkle candidate: {e}"); - return false; - } - }; - - let signature = match MlDsaSignature::from_bytes(&candidate.signature) { - Ok(sig) => sig, - Err(e) => { - debug!("Failed to parse ML-DSA-65 signature from merkle candidate: {e}"); - return false; - } - }; - - let msg = MerklePaymentCandidateNode::bytes_to_sign( - &candidate.price, - &candidate.reward_address, - candidate.merkle_payment_timestamp, - ); - - let ml_dsa = MlDsa65::new(); - match ml_dsa.verify(&pub_key, &msg, &signature) { - Ok(valid) => { - if !valid { - debug!("ML-DSA-65 merkle candidate signature verification failed"); - } - valid - } - Err(e) => { - debug!("ML-DSA-65 merkle candidate verification error: {e}"); - false - } - } -} +// Wire-side signature verification (`verify_quote_content`, +// `verify_quote_signature`, `verify_merkle_candidate_signature`) lives +// in `ant_protocol::payment::verify`. Re-exported from +// `crate::payment` for backwards compatibility. /// Wire ML-DSA-65 signing from a node identity into a `QuoteGenerator`. /// @@ -410,6 +289,13 @@ pub fn wire_ml_dsa_signer( mod tests { use super::*; use crate::payment::metrics::QuotingMetricsTracker; + // Verification helpers live in ant-protocol; import them here so the + // long-standing node-side negative tests (tampered keys, swapped + // pub keys, wrong timestamp, etc.) keep running against the canonical + // wire-side implementation. + use ant_protocol::payment::verify::{ + verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature, + }; use evmlib::common::Amount; use saorsa_pqc::pqc::types::MlDsaSecretKey; diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 32a93c80..9643c331 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -1,703 +1,8 @@ -//! `SingleNode` payment mode implementation for ant-node. +//! `SingleNode` payment re-exports from [`ant_protocol`]. //! -//! This module implements the `SingleNode` payment strategy from autonomi: -//! - Client gets `CLOSE_GROUP_SIZE` quotes from network -//! - Sort by price and select median (index `CLOSE_GROUP_SIZE / 2`) -//! - Pay ONLY the median-priced node with 3x the quoted amount -//! - Other nodes get `Amount::ZERO` -//! - All are submitted for payment and verification -//! -//! Total cost is the same as Standard mode (3x), but with one actual payment. -//! This saves gas fees while maintaining the same total payment amount. - -use crate::ant_protocol::CLOSE_GROUP_SIZE; -use crate::error::{Error, Result}; -use crate::logging::info; -use evmlib::common::{Amount, QuoteHash}; -use evmlib::wallet::Wallet; -use evmlib::Network as EvmNetwork; -use evmlib::PaymentQuote; -use evmlib::RewardsAddress; - -/// Index of the median-priced node after sorting, derived from `CLOSE_GROUP_SIZE`. -const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2; - -/// Single node payment structure for a chunk. -/// -/// Contains exactly `CLOSE_GROUP_SIZE` quotes where only the median-priced one -/// receives payment (3x), and the remaining quotes have `Amount::ZERO`. -/// -/// The fixed-size array ensures compile-time enforcement of the quote count, -/// making the median index always valid. -#[derive(Debug, Clone)] -pub struct SingleNodePayment { - /// All quotes (sorted by price) - fixed size ensures median index is always valid - pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE], -} - -/// Information about a single quote payment -#[derive(Debug, Clone)] -pub struct QuotePaymentInfo { - /// The quote hash - pub quote_hash: QuoteHash, - /// The rewards address - pub rewards_address: RewardsAddress, - /// The amount to pay (3x for median, 0 for others) - pub amount: Amount, - /// The original quoted price (before 3x multiplier) - pub price: Amount, -} - -impl SingleNodePayment { - /// Create a `SingleNode` payment from `CLOSE_GROUP_SIZE` quotes and their prices. - /// - /// The quotes are automatically sorted by price (cheapest first). - /// The median (index `CLOSE_GROUP_SIZE / 2`) gets 3x its quote price. - /// The others get `Amount::ZERO`. - /// - /// # Arguments - /// - /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples (will be sorted internally) - /// - /// # Errors - /// - /// Returns error if not exactly `CLOSE_GROUP_SIZE` quotes are provided. - pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { - let len = quotes_with_prices.len(); - if len != CLOSE_GROUP_SIZE { - return Err(Error::Payment(format!( - "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}" - ))); - } - - // Sort by price (cheapest first) to ensure correct median selection - quotes_with_prices.sort_by_key(|(_, price)| *price); - - // Get median price and calculate 3x - let median_price = quotes_with_prices - .get(MEDIAN_INDEX) - .ok_or_else(|| { - Error::Payment(format!( - "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed" - )) - })? - .1; - let enhanced_price = median_price - .checked_mul(Amount::from(3u64)) - .ok_or_else(|| { - Error::Payment("Price overflow when calculating 3x median".to_string()) - })?; - - // Build quote payment info for all CLOSE_GROUP_SIZE quotes - // Use try_from to convert Vec to fixed-size array - let quotes_vec: Vec = quotes_with_prices - .into_iter() - .enumerate() - .map(|(idx, (quote, price))| QuotePaymentInfo { - quote_hash: quote.hash(), - rewards_address: quote.rewards_address, - amount: if idx == MEDIAN_INDEX { - enhanced_price - } else { - Amount::ZERO - }, - price, - }) - .collect(); - - // Convert Vec to array - we already validated length is CLOSE_GROUP_SIZE - let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec - .try_into() - .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?; - - Ok(Self { quotes }) - } - - /// Get the total payment amount (should be 3x median price) - #[must_use] - pub fn total_amount(&self) -> Amount { - self.quotes.iter().map(|q| q.amount).sum() - } - - /// Get the median quote that receives payment. - /// - /// Returns `None` only if the internal array is somehow shorter than `MEDIAN_INDEX`, - /// which should never happen since the array is fixed-size `[_; CLOSE_GROUP_SIZE]`. - #[must_use] - pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> { - self.quotes.get(MEDIAN_INDEX) - } - - /// Pay for all quotes on-chain using the wallet. - /// - /// Pays 3x to the median quote and 0 to the others. - /// - /// # Errors - /// - /// Returns an error if the payment transaction fails. - pub async fn pay(&self, wallet: &Wallet) -> Result> { - // Build quote payments: (QuoteHash, RewardsAddress, Amount) - let quote_payments: Vec<_> = self - .quotes - .iter() - .map(|q| (q.quote_hash, q.rewards_address, q.amount)) - .collect(); - - info!( - "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto", - CLOSE_GROUP_SIZE, - self.total_amount(), - CLOSE_GROUP_SIZE - 1 - ); - - let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err( - |evmlib::wallet::PayForQuotesError(err, _)| { - Error::Payment(format!("Failed to pay for quotes: {err}")) - }, - )?; - - // Collect transaction hashes only for non-zero amount quotes - // Zero-amount quotes don't generate on-chain transactions - let mut result_hashes = Vec::new(); - for quote_info in &self.quotes { - if quote_info.amount > Amount::ZERO { - let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| { - Error::Payment(format!( - "Missing transaction hash for non-zero quote {}", - quote_info.quote_hash - )) - })?; - result_hashes.push(*tx_hash); - } - } - - info!( - "Payment successful: {} on-chain transactions", - result_hashes.len() - ); - - Ok(result_hashes) - } - - /// Verify that a median-priced quote was paid at least 3× its price on-chain. - /// - /// When multiple quotes share the median price (a tie), the client and - /// verifier may sort them in different order. This method checks all - /// quotes tied at the median price and accepts the payment if any one - /// of them was paid the correct amount. - /// - /// # Returns - /// - /// The on-chain payment amount for the verified quote. - /// - /// # Errors - /// - /// Returns an error if the on-chain lookup fails or none of the - /// median-priced quotes were paid at least 3× the median price. - pub async fn verify(&self, network: &EvmNetwork) -> Result { - let median = &self.quotes[MEDIAN_INDEX]; - let median_price = median.price; - let expected_amount = median.amount; - - // Collect all quotes tied at the median price - let tied_quotes: Vec<&QuotePaymentInfo> = self - .quotes - .iter() - .filter(|q| q.price == median_price) - .collect(); - - info!( - "Verifying median quote payment: expected at least {expected_amount} atto, {} quote(s) tied at median price", - tied_quotes.len() - ); - - let provider = evmlib::utils::http_provider(network.rpc_url().clone()); - let vault_address = *network.payment_vault_address(); - let contract = - evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider); - - // Check each tied quote — accept if any one was paid correctly - for candidate in &tied_quotes { - let result = contract - .completedPayments(candidate.quote_hash) - .call() - .await - .map_err(|e| Error::Payment(format!("completedPayments lookup failed: {e}")))?; - - let on_chain_amount = Amount::from(result.amount); - - if on_chain_amount >= expected_amount { - info!("Payment verified: {on_chain_amount} atto paid for median-priced quote"); - return Ok(on_chain_amount); - } - } - - Err(Error::Payment(format!( - "No median-priced quote was paid enough: expected at least {expected_amount}, checked {} tied quote(s)", - tied_quotes.len() - ))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy::node_bindings::{Anvil, AnvilInstance}; - use evmlib::testnet::{deploy_network_token_contract, deploy_payment_vault_contract, Testnet}; - use evmlib::transaction_config::TransactionConfig; - use evmlib::utils::{dummy_address, dummy_hash}; - use evmlib::wallet::Wallet; - use reqwest::Url; - use serial_test::serial; - use std::time::SystemTime; - use xor_name::XorName; - - fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote { - PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(1u64), - rewards_address: RewardsAddress::new([rewards_addr_seed; 20]), - pub_key: vec![], - signature: vec![], - } - } - - /// Start an Anvil node with increased timeout for CI environments. - /// - /// The default timeout is 10 seconds which can be insufficient in CI. - /// This helper uses a 60-second timeout and random port assignment - /// to handle slower CI environments and parallel test execution. - #[allow(clippy::expect_used, clippy::panic)] - fn start_node_with_timeout() -> (AnvilInstance, Url) { - const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI - - let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string()); - - // Use port 0 to let the OS assign a random available port. - // This prevents port conflicts when running tests in parallel. - let anvil = Anvil::new() - .timeout(ANVIL_TIMEOUT_MS) - .try_spawn() - .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms")); - - let url = Url::parse(&format!("http://{host}:{}", anvil.port())) - .expect("Failed to parse Anvil URL"); - - (anvil, url) - } - - /// Test: Standard `CLOSE_GROUP_SIZE`-quote payment verification (autonomi baseline) - #[tokio::test] - #[serial] - #[allow(clippy::expect_used)] - async fn test_standard_quote_payment() { - // Use autonomi's setup pattern with increased timeout for CI - let (node, rpc_url) = start_node_with_timeout(); - let network_token = deploy_network_token_contract(&rpc_url, &node) - .await - .expect("deploy network token"); - let mut payment_vault = - deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address()) - .await - .expect("deploy data payments"); - - let transaction_config = TransactionConfig::default(); - - // Create CLOSE_GROUP_SIZE random quote payments (autonomi pattern) - let mut quote_payments = vec![]; - for _ in 0..CLOSE_GROUP_SIZE { - let quote_hash = dummy_hash(); - let reward_address = dummy_address(); - let amount = Amount::from(1u64); - quote_payments.push((quote_hash, reward_address, amount)); - } - - // Approve tokens - network_token - .approve( - *payment_vault.contract.address(), - evmlib::common::U256::MAX, - &transaction_config, - ) - .await - .expect("Failed to approve"); - - println!("✓ Approved tokens"); - - // CRITICAL: Set provider to same as network token - payment_vault.set_provider(network_token.contract.provider().clone()); - - // Pay for quotes - let result = payment_vault - .pay_for_quotes(quote_payments.clone(), &transaction_config) - .await; - - assert!(result.is_ok(), "Payment failed: {:?}", result.err()); - println!("✓ Paid for {} quotes", quote_payments.len()); - - // Verify payments via completedPayments mapping - for (quote_hash, _reward_address, amount) in "e_payments { - let result = payment_vault - .contract - .completedPayments(*quote_hash) - .call() - .await - .expect("completedPayments lookup failed"); - - let on_chain_amount = result.amount; - assert!( - on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"), - "On-chain amount should be >= paid amount" - ); - } - - println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully"); - println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!"); - } - - /// Test: `SingleNode` payment strategy (1 real + N-1 dummy payments) - #[tokio::test] - #[serial] - #[allow(clippy::expect_used)] - async fn test_single_node_payment_strategy() { - let (node, rpc_url) = start_node_with_timeout(); - let network_token = deploy_network_token_contract(&rpc_url, &node) - .await - .expect("deploy network token"); - let mut payment_vault = - deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address()) - .await - .expect("deploy data payments"); - - let transaction_config = TransactionConfig::default(); - - // Create CLOSE_GROUP_SIZE payments: 1 real (3x) + rest dummy (0x) - let real_quote_hash = dummy_hash(); - let real_reward_address = dummy_address(); - let real_amount = Amount::from(3u64); // 3x amount - - let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)]; - - // Add dummy payments with 0 amount for remaining close group members - for _ in 0..CLOSE_GROUP_SIZE - 1 { - let dummy_quote_hash = dummy_hash(); - let dummy_reward_address = dummy_address(); - let dummy_amount = Amount::from(0u64); // 0 amount - quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount)); - } - - // Approve tokens - network_token - .approve( - *payment_vault.contract.address(), - evmlib::common::U256::MAX, - &transaction_config, - ) - .await - .expect("Failed to approve"); - - println!("✓ Approved tokens"); - - // Set provider - payment_vault.set_provider(network_token.contract.provider().clone()); - - // Pay (1 real payment of 3 atto + N-1 dummy payments of 0 atto) - let result = payment_vault - .pay_for_quotes(quote_payments.clone(), &transaction_config) - .await; - - assert!(result.is_ok(), "Payment failed: {:?}", result.err()); - println!( - "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)", - CLOSE_GROUP_SIZE - 1 - ); - - // Verify via completedPayments mapping - - // Check that real payment is recorded on-chain - let real_result = payment_vault - .contract - .completedPayments(real_quote_hash) - .call() - .await - .expect("completedPayments lookup failed"); - - assert!( - real_result.amount > 0, - "Real payment should have non-zero amount on-chain" - ); - println!("✓ Real payment verified (3 atto)"); - - // Check dummy payments (should have 0 amount) - for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() { - let result = payment_vault - .contract - .completedPayments(*hash) - .call() - .await - .expect("completedPayments lookup failed"); - - println!(" Dummy payment {}: amount={}", i + 1, result.amount); - } - - println!("\n✅ SingleNode payment strategy works!"); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_from_quotes_median_selection() { - let prices: Vec = vec![50, 30, 10, 40, 20, 60, 70]; - let mut quotes_with_prices = Vec::new(); - - for price in &prices { - let quote = PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(*price), - rewards_address: RewardsAddress::new([1u8; 20]), - pub_key: vec![], - signature: vec![], - }; - quotes_with_prices.push((quote, Amount::from(*price))); - } - - let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap(); - - // After sorting by price: 10, 20, 30, 40, 50, 60, 70 - // Median (index 3) = 40, paid amount = 3 * 40 = 120 - let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap(); - assert_eq!(median_quote.amount, Amount::from(120u64)); - - // Other 6 quotes should have Amount::ZERO - for (i, q) in payment.quotes.iter().enumerate() { - if i != MEDIAN_INDEX { - assert_eq!(q.amount, Amount::ZERO); - } - } - - // Total should be 3 * median price = 120 - assert_eq!(payment.total_amount(), Amount::from(120u64)); - } - - #[test] - fn test_from_quotes_wrong_count() { - let quotes: Vec<_> = (0..3) - .map(|_| (make_test_quote(1), Amount::from(10u64))) - .collect(); - let result = SingleNodePayment::from_quotes(quotes); - assert!(result.is_err()); - } - - #[test] - #[allow(clippy::expect_used)] - fn test_from_quotes_zero_quotes() { - let result = SingleNodePayment::from_quotes(vec![]); - assert!(result.is_err()); - let err_msg = format!("{}", result.expect_err("should fail")); - assert!(err_msg.contains("exactly 7")); - } - - #[test] - fn test_from_quotes_one_quote() { - let result = - SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]); - assert!(result.is_err()); - } - - #[test] - #[allow(clippy::expect_used)] - fn test_from_quotes_wrong_count_six() { - let quotes: Vec<_> = (0..6) - .map(|_| (make_test_quote(1), Amount::from(10u64))) - .collect(); - let result = SingleNodePayment::from_quotes(quotes); - assert!(result.is_err()); - let err_msg = format!("{}", result.expect_err("should fail")); - assert!(err_msg.contains("exactly 7")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_paid_quote_returns_median() { - let quotes: Vec<_> = (1u8..) - .take(CLOSE_GROUP_SIZE) - .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10))) - .collect(); - - let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - let paid = payment.paid_quote().unwrap(); - - // The paid quote should have a non-zero amount - assert!(paid.amount > Amount::ZERO); - - // Total amount should equal the paid quote's amount - assert_eq!(payment.total_amount(), paid.amount); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_all_quotes_have_distinct_addresses() { - let quotes: Vec<_> = (1u8..) - .take(CLOSE_GROUP_SIZE) - .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10))) - .collect(); - - let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - - // Verify all quotes are present (sorting doesn't lose data) - let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect(); - addresses.sort(); - addresses.dedup(); - assert_eq!(addresses.len(), CLOSE_GROUP_SIZE); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_tied_median_prices_all_share_median_price() { - // Prices: 10, 20, 30, 30, 30, 40, 50 — three quotes tied at median price 30 - let prices = [10u64, 20, 30, 30, 30, 40, 50]; - let mut quotes_with_prices = Vec::new(); - - for (i, price) in prices.iter().enumerate() { - let quote = PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(*price), - #[allow(clippy::cast_possible_truncation)] // i is always < 7 - rewards_address: RewardsAddress::new([i as u8 + 1; 20]), - pub_key: vec![], - signature: vec![], - }; - quotes_with_prices.push((quote, Amount::from(*price))); - } - - let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap(); - - // All three tied quotes should have price == 30 - let tied_count = payment - .quotes - .iter() - .filter(|q| q.price == Amount::from(30u64)) - .count(); - assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price"); - - // Only the median index gets the 3x amount - assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64)); - assert_eq!(payment.total_amount(), Amount::from(90u64)); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_total_amount_equals_3x_median() { - let prices = [100u64, 200, 300, 400, 500, 600, 700]; - let quotes: Vec<_> = prices - .iter() - .map(|price| (make_test_quote(1), Amount::from(*price))) - .collect(); - - let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - // Sorted: 100, 200, 300, 400, 500, 600, 700 — median = 400, total = 3 * 400 = 1200 - assert_eq!(payment.total_amount(), Amount::from(1200u64)); - } - - /// Test: Complete `SingleNode` flow with real contract prices - #[tokio::test] - #[serial] - async fn test_single_node_with_real_prices() -> Result<()> { - // Setup testnet - let testnet = Testnet::new() - .await - .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?; - let network = testnet.to_network(); - let wallet_key = testnet - .default_wallet_private_key() - .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?; - let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key) - .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?; - - println!("✓ Started Anvil testnet"); - - // Approve tokens - wallet - .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX) - .await - .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?; - - println!("✓ Approved tokens"); - - // Create CLOSE_GROUP_SIZE quotes with prices calculated from record counts - let chunk_xor = XorName::random(&mut rand::thread_rng()); - - let mut quotes_with_prices = Vec::new(); - for i in 0..CLOSE_GROUP_SIZE { - let records_stored = 10 + i; - let price = crate::payment::pricing::calculate_price(records_stored); - - let quote = PaymentQuote { - content: chunk_xor, - timestamp: SystemTime::now(), - price, - rewards_address: wallet.address(), - pub_key: vec![], - signature: vec![], - }; - - quotes_with_prices.push((quote, price)); - } - - println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices"); - - // Create SingleNode payment (will sort internally and select median) - let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; - - let median_price = payment - .paid_quote() - .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))? - .amount - .checked_div(Amount::from(3u64)) - .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?; - println!("✓ Sorted and selected median price: {median_price} atto"); - - assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE); - let median_amount = payment - .quotes - .get(MEDIAN_INDEX) - .ok_or_else(|| { - Error::Payment(format!( - "Index out of bounds: tried to access median index {} but quotes array has {} elements", - MEDIAN_INDEX, - payment.quotes.len() - )) - })? - .amount; - assert_eq!( - payment.total_amount(), - median_amount, - "Only median should have non-zero amount" - ); - - println!( - "✓ Created SingleNode payment: {} atto total (3x median)", - payment.total_amount() - ); - - // Pay on-chain - let tx_hashes = payment.pay(&wallet).await?; - println!("✓ Payment successful: {} transactions", tx_hashes.len()); - - // Verify median quote payment — all nodes run this same check - let verified_amount = payment.verify(&network).await?; - let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount; - - assert_eq!( - verified_amount, expected_median_amount, - "Verified amount should match median payment" - ); - - println!("✓ Payment verified: {verified_amount} atto"); - println!("\n✅ Complete SingleNode flow with real prices works!"); +//! Extracted to the [`ant_protocol`] crate in 0.11 so `pay` and +//! `verify` stay co-located in a single crate that both the client and +//! node test against end to end. Internal callers using +//! `crate::payment::single_node::…` keep working unchanged. - Ok(()) - } -} +pub use ant_protocol::payment::single_node::{QuotePaymentInfo, SingleNodePayment}; diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 8a8263d0..a2214e19 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -10,8 +10,8 @@ use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; -use crate::payment::quote::{verify_quote_content, verify_quote_signature}; use crate::payment::single_node::SingleNodePayment; +use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature}; use evmlib::common::Amount; use evmlib::contract::payment_vault; use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; @@ -247,6 +247,15 @@ impl PaymentVerifier { "Unknown payment proof type tag: 0x{tag:02x}" ))); } + // ant-protocol marks `ProofType` as `#[non_exhaustive]`. + // A future proof variant that this node does not yet + // understand must be rejected, not silently accepted. + Some(_) => { + let tag = proof.first().copied().unwrap_or(0); + return Err(Error::Payment(format!( + "Unsupported payment proof type tag: 0x{tag:02x} (this node's protocol version does not handle it — upgrade ant-node)" + ))); + } } // Cache the verified xorname diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 5d6604fe..ab40c396 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -138,14 +138,18 @@ impl AntProtocol { self.handle_merkle_candidate_quote(req), ) } - // Response messages are handled by client subscribers - // (e.g. send_and_await_chunk_response), not by the protocol - // handler. Returning None prevents the caller from sending a - // reply, which would create an infinite ping-pong loop. - ChunkMessageBody::PutResponse(_) - | ChunkMessageBody::GetResponse(_) - | ChunkMessageBody::QuoteResponse(_) - | ChunkMessageBody::MerkleCandidateQuoteResponse(_) => return Ok(None), + // Anything else — response messages are handled by client + // subscribers (e.g. send_and_await_chunk_response), not by the + // protocol handler. Returning None prevents the caller from + // sending a reply, which would create an infinite ping-pong + // loop. + // + // `ChunkMessageBody` is `#[non_exhaustive]` in ant-protocol, so + // a future wire variant added on a protocol minor bump also + // lands here and is dropped. The CHUNK_PROTOCOL_ID multistream- + // select handshake version-gates peers, so this arm should + // only be reached by a misconfigured peer. + _ => return Ok(None), }; let response = ChunkMessage { @@ -824,7 +828,7 @@ mod tests { #[tokio::test] async fn test_merkle_candidate_quote_request() { - use crate::payment::quote::verify_merkle_candidate_signature; + use ant_protocol::payment::verify::verify_merkle_candidate_signature; use evmlib::merkle_payments::MerklePaymentCandidateNode; // create_test_protocol already wires ML-DSA-65 signing diff --git a/src/storage/lmdb.rs b/src/storage/lmdb.rs index 3b21d66a..5e2c31d7 100644 --- a/src/storage/lmdb.rs +++ b/src/storage/lmdb.rs @@ -686,7 +686,7 @@ fn compute_map_size(db_dir: &Path, reserve: u64) -> Result { // The MDB data file may not exist yet on first run. let mdb_file = db_dir.join("data.mdb"); - let current_db_bytes = std::fs::metadata(&mdb_file).map(|m| m.len()).unwrap_or(0); + let current_db_bytes = std::fs::metadata(&mdb_file).map_or(0, |m| m.len()); // available_space excludes the DB file, so we add it back to get the // total space the DB could occupy while still leaving `reserve` free. diff --git a/tests/e2e/data_types/mod.rs b/tests/e2e/data_types/mod.rs index a9b2514c..ade3ab03 100644 --- a/tests/e2e/data_types/mod.rs +++ b/tests/e2e/data_types/mod.rs @@ -22,8 +22,7 @@ impl TestData { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); + .map_or(0, |d| d.as_nanos()); format!("test-{nanos}") }