diff --git a/CLAUDE.md b/CLAUDE.md index 1413ba5d..a2d5cd73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,15 +48,13 @@ RUST_LOG=debug cargo run --release -- --listen 0.0.0.0:10000 - **Exception**: Test code may use these for assertions ### Payment Verification Policy -**Production nodes require payment by default.** +**Payment verification is always on — there is no way to disable it.** - All new chunk storage requires EVM payment verification on Arbitrum -- Payment verification is **enabled by default** via `PaymentConfig::default()` -- Test environments can disable payment via: - - CLI flag: `--disable-payment-verification` - - Config: `PaymentVerifierConfig { evm: EvmVerifierConfig { enabled: false, .. }, .. }` +- There is no `--disable-payment-verification` flag or `enabled` config field +- `rewards_address` is required — node startup fails without one - Previously-paid chunks are cached and do not require re-verification -- Test utilities (e.g., `create_test_protocol()`) explicitly disable EVM verification +- Test code bypasses EVM by pre-populating the cache via `PaymentVerifier::cache_insert()` (only available behind `#[cfg(test)]` or the `test-utils` feature) See `src/payment/verifier.rs` for implementation details. diff --git a/Cargo.toml b/Cargo.toml index d5fa01a0..db10ebd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "saorsa-node" -version = "0.3.2" +version = "0.4.0" edition = "2021" authors = ["David Irvine "] description = "Pure quantum-proof network node for the Saorsa decentralized network" @@ -26,10 +26,6 @@ path = "src/bin/keygen.rs" name = "saorsa-devnet" path = "src/bin/saorsa-devnet/main.rs" -[[bin]] -name = "saorsa-cli" -path = "src/bin/saorsa-cli/main.rs" - [dependencies] # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.17" @@ -53,11 +49,7 @@ heed = "0.22" aes-gcm-siv = "0.11" hkdf = "0.12" -# Self-encryption (convergent encryption + streaming) -# Branch name "post_quatum" is intentional (matches the upstream branch). -self_encryption = { git = "https://github.com/grumbach/self_encryption.git", branch = "post_quatum" } - -# Hashing (aligned with saorsa-core) +self_encryption = "0.35.0" blake3 = "1" # Async runtime @@ -110,15 +102,19 @@ proptest = "1" alloy = { version = "1", features = ["node-bindings"] } serial_test = "3" -# E2E test infrastructure +# E2E test infrastructure (run with --features test-utils) [[test]] name = "e2e" path = "tests/e2e/mod.rs" +required-features = ["test-utils"] [features] default = [] # Enable additional diagnostics for development dev = [] +# Expose test helpers (cache_insert, payment_verifier accessor) for +# integration tests and downstream test harnesses. +test-utils = [] [profile.release] lto = true @@ -182,4 +178,4 @@ assets = [ ] [package.metadata.generate-rpm.requires] -# Runtime dependencies auto-detected \ No newline at end of file +# Runtime dependencies auto-detected diff --git a/src/ant_protocol/chunk.rs b/src/ant_protocol/chunk.rs index a39a89b9..6e378a40 100644 --- a/src/ant_protocol/chunk.rs +++ b/src/ant_protocol/chunk.rs @@ -223,9 +223,15 @@ impl ChunkQuoteRequest { #[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), diff --git a/src/ant_protocol/mod.rs b/src/ant_protocol/mod.rs index f993b454..a8b146b6 100644 --- a/src/ant_protocol/mod.rs +++ b/src/ant_protocol/mod.rs @@ -5,12 +5,9 @@ //! //! # Data Types //! -//! The ANT protocol supports multiple data types: +//! The ANT protocol supports a single data type: //! //! - **Chunk**: Immutable, content-addressed data (hash == address) -//! - *Scratchpad*: Mutable, owner-indexed data (planned) -//! - *Pointer*: Lightweight mutable references (planned) -//! - *`GraphEntry`*: DAG entries with parent links (planned) //! //! # Protocol Overview //! diff --git a/src/bin/saorsa-cli/cli.rs b/src/bin/saorsa-cli/cli.rs deleted file mode 100644 index 00372153..00000000 --- a/src/bin/saorsa-cli/cli.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! CLI definition for saorsa-cli. - -use clap::{Parser, Subcommand}; -use std::net::SocketAddr; -use std::path::PathBuf; - -/// Saorsa CLI for file upload and download with EVM payments. -#[derive(Parser, Debug)] -#[command(name = "saorsa-cli")] -#[command(author, version, about, long_about = None)] -pub struct Cli { - /// Bootstrap peer addresses. - #[arg(long, short)] - pub bootstrap: Vec, - - /// Path to devnet manifest JSON (output of saorsa-devnet). - #[arg(long)] - pub devnet_manifest: Option, - - /// Timeout for network operations (seconds). - #[arg(long, default_value_t = 60)] - pub timeout_secs: u64, - - /// Allow loopback connections (required for devnet/local testing). - #[arg(long)] - pub allow_loopback: bool, - - /// Log level. - #[arg(long, default_value = "info")] - pub log_level: String, - - /// EVM network for payment processing. - #[arg(long, default_value = "local")] - pub evm_network: String, - - /// Command to run. - #[command(subcommand)] - pub command: CliCommand, -} - -/// CLI commands. -#[derive(Subcommand, Debug)] -pub enum CliCommand { - /// File operations (multi-chunk upload/download with EVM payment). - File { - #[command(subcommand)] - action: FileAction, - }, - /// Single-chunk operations (low-level put/get without file splitting). - Chunk { - #[command(subcommand)] - action: ChunkAction, - }, -} - -/// Chunk subcommands. -#[derive(Subcommand, Debug)] -pub enum ChunkAction { - /// Store a single chunk. Reads from FILE or stdin. - Put { - /// Input file (reads from stdin if omitted). - file: Option, - }, - /// Retrieve a single chunk. Writes to FILE or stdout. - Get { - /// Hex-encoded chunk address (64 hex chars). - address: String, - /// Output file (writes to stdout if omitted). - #[arg(long, short)] - output: Option, - }, -} - -/// File subcommands. -#[derive(Subcommand, Debug)] -pub enum FileAction { - /// Upload a file to the network with EVM payment. - Upload { - /// Path to the file to upload. - path: PathBuf, - /// Public mode: store the data map on the network (anyone with the - /// address can download). Default is private (data map saved locally). - #[arg(long)] - public: bool, - }, - /// Download a file from the network. - Download { - /// Hex-encoded address (public data map address). - address: Option, - /// Path to a local data map file (for private downloads). - #[arg(long)] - datamap: Option, - /// Output file path (defaults to stdout). - #[arg(long, short)] - output: Option, - }, -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - - #[test] - fn test_parse_upload_command() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "file", - "upload", - "/tmp/test.txt", - ]) - .unwrap(); - - assert!(!cli.bootstrap.is_empty()); - assert!(matches!( - cli.command, - CliCommand::File { - action: FileAction::Upload { .. } - } - )); - } - - #[test] - fn test_parse_download_command() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--devnet-manifest", - "/tmp/manifest.json", - "file", - "download", - "abcd1234", - "--output", - "/tmp/out.bin", - ]) - .unwrap(); - - assert!(cli.devnet_manifest.is_some()); - } - - #[test] - fn test_parse_upload_public() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "file", - "upload", - "--public", - "/tmp/test.txt", - ]) - .unwrap(); - - if let CliCommand::File { - action: FileAction::Upload { public, .. }, - } = cli.command - { - assert!(public, "Upload should be public"); - } else { - panic!("Expected File Upload"); - } - } - - #[test] - fn test_parse_download_with_datamap() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "file", - "download", - "--datamap", - "/tmp/my.datamap", - "--output", - "/tmp/out.bin", - ]) - .unwrap(); - - if let CliCommand::File { - action: - FileAction::Download { - address, - datamap, - output, - }, - } = cli.command - { - assert!( - address.is_none(), - "Address should be None for datamap download" - ); - assert_eq!( - datamap.as_deref(), - Some(std::path::Path::new("/tmp/my.datamap")) - ); - assert!(output.is_some()); - } else { - panic!("Expected File Download"); - } - } - - #[test] - fn test_secret_key_from_env() { - // SECRET_KEY is read at runtime, not parsed by clap - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "file", - "upload", - "/tmp/test.txt", - ]) - .unwrap(); - - assert_eq!(cli.evm_network, "local"); - } - - #[test] - fn test_parse_chunk_put() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "chunk", - "put", - "/tmp/test.txt", - ]) - .unwrap(); - assert!(matches!( - cli.command, - CliCommand::Chunk { - action: ChunkAction::Put { .. } - } - )); - } - - #[test] - fn test_parse_chunk_get() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "chunk", - "get", - "abcd1234", - "--output", - "/tmp/out.bin", - ]) - .unwrap(); - assert!(matches!( - cli.command, - CliCommand::Chunk { - action: ChunkAction::Get { .. } - } - )); - } - - #[test] - fn test_parse_chunk_put_stdin() { - let cli = Cli::try_parse_from([ - "saorsa-cli", - "--bootstrap", - "127.0.0.1:10000", - "chunk", - "put", - ]) - .unwrap(); - if let CliCommand::Chunk { - action: ChunkAction::Put { file }, - } = cli.command - { - assert!(file.is_none()); - } else { - panic!("Expected Chunk Put"); - } - } -} diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs deleted file mode 100644 index 62d2cefe..00000000 --- a/src/bin/saorsa-cli/main.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! saorsa-cli entry point — file upload/download with EVM payments. - -mod cli; - -use bytes::Bytes; -use clap::Parser; -use cli::{ChunkAction, Cli, CliCommand, FileAction}; -use evmlib::wallet::Wallet; -use evmlib::Network as EvmNetwork; -use saorsa_core::P2PNode; -use saorsa_node::ant_protocol::{MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE}; -use saorsa_node::client::self_encrypt::{ - deserialize_data_map, download_and_decrypt_file, encrypt_and_upload_file, - fetch_data_map_public, serialize_data_map, store_data_map_public, -}; -use saorsa_node::client::{QuantumClient, QuantumConfig, XorName}; -use saorsa_node::devnet::DevnetManifest; -use saorsa_node::error::Error; -use std::io::{Read as _, Write as _}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tracing::info; -use tracing_subscriber::{fmt, prelude::*, EnvFilter}; - -/// Length of an `XorName` address in bytes. -const XORNAME_BYTE_LEN: usize = 32; - -#[tokio::main] -async fn main() -> color_eyre::Result<()> { - color_eyre::install()?; - - let cli = Cli::parse(); - - let filter = - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&cli.log_level)); - - tracing_subscriber::registry() - .with(fmt::layer().with_writer(std::io::stderr)) - .with(filter) - .init(); - - info!("saorsa-cli v{}", env!("CARGO_PKG_VERSION")); - - // Resolve private key from SECRET_KEY env var (check early, before network bootstrap) - let private_key = std::env::var("SECRET_KEY").ok(); - - // Fail fast if storage operations require SECRET_KEY but it's not set - let needs_wallet = matches!( - cli.command, - CliCommand::File { - action: FileAction::Upload { .. } - } | CliCommand::Chunk { - action: ChunkAction::Put { .. } - } - ); - if needs_wallet && private_key.is_none() { - return Err(color_eyre::eyre::eyre!( - "SECRET_KEY environment variable required for storage operations (payment)" - )); - } - - let (bootstrap, manifest) = resolve_bootstrap(&cli)?; - let node = create_client_node(bootstrap, cli.allow_loopback).await?; - - // Build client with timeout - let mut client = QuantumClient::new(QuantumConfig { - timeout_secs: cli.timeout_secs, - replica_count: 1, - encrypt_data: false, - }) - .with_node(node); - - if needs_wallet { - if let Some(ref key) = private_key { - let network = resolve_evm_network(&cli.evm_network, manifest.as_ref())?; - let wallet = Wallet::new_from_private_key(network, key) - .map_err(|e| color_eyre::eyre::eyre!("Failed to create wallet: {e}"))?; - info!("Wallet configured for EVM payments"); - client = client.with_wallet(wallet); - } - } - - match cli.command { - CliCommand::File { action } => match action { - FileAction::Upload { path, public } => { - handle_upload(&client, &path, public).await?; - } - FileAction::Download { - address, - datamap, - output, - } => { - handle_download( - &client, - address.as_deref(), - datamap.as_deref(), - output.as_deref(), - ) - .await?; - } - }, - CliCommand::Chunk { action } => match action { - ChunkAction::Put { file } => { - handle_chunk_put(&client, file).await?; - } - ChunkAction::Get { address, output } => { - handle_chunk_get(&client, &address, output).await?; - } - }, - } - - Ok(()) -} - -async fn handle_upload( - client: &QuantumClient, - path: &Path, - public: bool, -) -> color_eyre::Result<()> { - let file_size = std::fs::metadata(path)?.len(); - info!("Uploading file: {} ({file_size} bytes)", path.display()); - - // Encrypt and upload all chunks using streaming self-encryption - let (data_map, all_tx_hashes) = encrypt_and_upload_file(path, client).await?; - let chunk_count = data_map.chunk_identifiers.len(); - let total_tx_count = all_tx_hashes.len(); - - if public { - // Public mode: store the DataMap on the network too - let (dm_address, dm_tx_hashes) = store_data_map_public(&data_map, client).await?; - let address_hex = hex::encode(dm_address); - let combined_tx = total_tx_count + dm_tx_hashes.len(); - - println!("FILE_ADDRESS={address_hex}"); - println!("MODE=public"); - println!("CHUNKS={chunk_count}"); - println!("TOTAL_SIZE={file_size}"); - println!("PAYMENTS={combined_tx}"); - - let mut all = all_tx_hashes; - all.extend(dm_tx_hashes); - println!("TX_HASHES={}", all.join(",")); - - info!("Upload complete (public): address={address_hex}, chunks={chunk_count}"); - } else { - // Private mode: save DataMap locally, never upload it - let data_map_bytes = serialize_data_map(&data_map)?; - let datamap_path = path.with_extension("datamap"); - std::fs::write(&datamap_path, &data_map_bytes)?; - - println!("DATAMAP_FILE={}", datamap_path.display()); - println!("MODE=private"); - println!("CHUNKS={chunk_count}"); - println!("TOTAL_SIZE={file_size}"); - println!("PAYMENTS={total_tx_count}"); - println!("TX_HASHES={}", all_tx_hashes.join(",")); - - info!( - "Upload complete (private): datamap saved to {}, chunks={chunk_count}", - datamap_path.display() - ); - } - - Ok(()) -} - -async fn handle_download( - client: &QuantumClient, - address: Option<&str>, - datamap_path: Option<&Path>, - output: Option<&Path>, -) -> color_eyre::Result<()> { - // Resolve the DataMap: either from network (public) or local file (private) - let data_map = if let Some(dm_path) = datamap_path { - info!("Loading DataMap from local file: {}", dm_path.display()); - let dm_bytes = std::fs::read(dm_path)?; - deserialize_data_map(&dm_bytes)? - } else if let Some(addr_str) = address { - let addr = parse_address(addr_str)?; - info!("Fetching DataMap from network: {addr_str}"); - fetch_data_map_public(&addr, client).await? - } else { - return Err(color_eyre::eyre::eyre!( - "Either an address or --datamap must be provided for download" - )); - }; - - let chunk_count = data_map.chunk_identifiers.len(); - info!("DataMap loaded: {chunk_count} chunk(s)"); - - // Determine output path - let output_path = output.map_or_else( - || PathBuf::from("downloaded_file"), - std::borrow::ToOwned::to_owned, - ); - - download_and_decrypt_file(&data_map, &output_path, client).await?; - - let file_size = std::fs::metadata(&output_path)?.len(); - println!("Downloaded {file_size} bytes to {}", output_path.display()); - - Ok(()) -} - -async fn handle_chunk_put(client: &QuantumClient, file: Option) -> color_eyre::Result<()> { - let content = read_input(file.as_deref())?; - info!("Storing single chunk ({} bytes)", content.len()); - - let (address, tx_hashes) = client.put_chunk_with_payment(Bytes::from(content)).await?; - let hex_addr = hex::encode(address); - info!("Chunk stored at {hex_addr}"); - - println!("{hex_addr}"); - let tx_strs: Vec = tx_hashes.iter().map(|tx| format!("{tx:?}")).collect(); - println!("TX_HASHES={}", tx_strs.join(",")); - - Ok(()) -} - -async fn handle_chunk_get( - client: &QuantumClient, - address: &str, - output: Option, -) -> color_eyre::Result<()> { - let addr = parse_address(address)?; - info!("Retrieving chunk {address}"); - - let result = client.get_chunk(&addr).await?; - match result { - Some(chunk) => { - if let Some(path) = output { - std::fs::write(&path, &chunk.content)?; - info!("Chunk saved to {}", path.display()); - } else { - std::io::stdout().write_all(&chunk.content)?; - } - } - None => { - return Err(color_eyre::eyre::eyre!( - "Chunk not found for address {address}" - )); - } - } - - Ok(()) -} - -fn read_input(file: Option<&Path>) -> color_eyre::Result> { - if let Some(path) = file { - let meta = std::fs::metadata(path)?; - if meta.len() > MAX_CHUNK_SIZE as u64 { - return Err(color_eyre::eyre::eyre!( - "Input file exceeds MAX_CHUNK_SIZE ({MAX_CHUNK_SIZE} bytes): {} bytes", - meta.len() - )); - } - return Ok(std::fs::read(path)?); - } - let limit = (MAX_CHUNK_SIZE + 1) as u64; - let mut buf = Vec::new(); - std::io::stdin().take(limit).read_to_end(&mut buf)?; - if buf.len() > MAX_CHUNK_SIZE { - return Err(color_eyre::eyre::eyre!( - "Stdin input exceeds MAX_CHUNK_SIZE ({MAX_CHUNK_SIZE} bytes)" - )); - } - Ok(buf) -} - -fn resolve_evm_network( - evm_network: &str, - manifest: Option<&DevnetManifest>, -) -> color_eyre::Result { - match evm_network { - "arbitrum-one" => Ok(EvmNetwork::ArbitrumOne), - "arbitrum-sepolia" => Ok(EvmNetwork::ArbitrumSepoliaTest), - "local" => { - if let Some(m) = manifest { - if let Some(ref evm) = m.evm { - let rpc_url: reqwest::Url = evm - .rpc_url - .parse() - .map_err(|e| color_eyre::eyre::eyre!("Invalid RPC URL: {e}"))?; - let token_addr: evmlib::common::Address = evm - .payment_token_address - .parse() - .map_err(|e| color_eyre::eyre::eyre!("Invalid token address: {e}"))?; - let payments_addr: evmlib::common::Address = evm - .data_payments_address - .parse() - .map_err(|e| color_eyre::eyre::eyre!("Invalid payments address: {e}"))?; - return Ok(EvmNetwork::Custom(evmlib::CustomNetwork { - rpc_url_http: rpc_url, - payment_token_address: token_addr, - data_payments_address: payments_addr, - merkle_payments_address: None, - })); - } - } - Err(color_eyre::eyre::eyre!( - "EVM network 'local' requires --devnet-manifest with EVM info" - )) - } - other => Err(color_eyre::eyre::eyre!( - "Unsupported EVM network: {other}. Use 'arbitrum-one', 'arbitrum-sepolia', or 'local'." - )), - } -} - -fn resolve_bootstrap( - cli: &Cli, -) -> color_eyre::Result<(Vec, Option)> { - if !cli.bootstrap.is_empty() { - let addrs = cli - .bootstrap - .iter() - .map(|addr| saorsa_core::MultiAddr::quic(*addr)) - .collect(); - return Ok((addrs, None)); - } - - if let Some(ref manifest_path) = cli.devnet_manifest { - let data = std::fs::read_to_string(manifest_path)?; - let manifest: DevnetManifest = serde_json::from_str(&data)?; - let bootstrap = manifest.bootstrap.clone(); - return Ok((bootstrap, Some(manifest))); - } - - Err(color_eyre::eyre::eyre!( - "No bootstrap peers provided. Use --bootstrap or --devnet-manifest." - )) -} - -async fn create_client_node( - bootstrap: Vec, - allow_loopback: bool, -) -> Result, Error> { - let mut core_config = saorsa_core::NodeConfig::builder() - .local(allow_loopback) - .max_message_size(MAX_WIRE_MESSAGE_SIZE) - .mode(saorsa_core::NodeMode::Client) - .build() - .map_err(|e| Error::Config(format!("Failed to create core config: {e}")))?; - core_config.bootstrap_peers = bootstrap; - - let node = P2PNode::new(core_config) - .await - .map_err(|e| Error::Network(format!("Failed to create P2P node: {e}")))?; - node.start() - .await - .map_err(|e| Error::Network(format!("Failed to start P2P node: {e}")))?; - - Ok(Arc::new(node)) -} - -fn parse_address(address: &str) -> color_eyre::Result { - let bytes = hex::decode(address)?; - if bytes.len() != XORNAME_BYTE_LEN { - return Err(color_eyre::eyre::eyre!( - "Invalid address length: expected {XORNAME_BYTE_LEN} bytes, got {}", - bytes.len() - )); - } - let mut out = [0u8; XORNAME_BYTE_LEN]; - out.copy_from_slice(&bytes); - Ok(out) -} diff --git a/src/bin/saorsa-devnet/cli.rs b/src/bin/saorsa-devnet/cli.rs index 4bb5ae3a..6b7fc8bf 100644 --- a/src/bin/saorsa-devnet/cli.rs +++ b/src/bin/saorsa-devnet/cli.rs @@ -48,8 +48,9 @@ pub struct Cli { #[arg(long, default_value = "info")] pub log_level: String, - /// Enable EVM payment enforcement with a local Anvil blockchain. - /// Starts Anvil, deploys contracts, and enables payment verification on all nodes. + /// Start a local Anvil blockchain for EVM payment verification. + /// Starts Anvil, deploys contracts, and configures all nodes to verify + /// payments against the local chain. #[arg(long)] pub enable_evm: bool, } diff --git a/src/bin/saorsa-devnet/main.rs b/src/bin/saorsa-devnet/main.rs index 8b94f04e..2b805d86 100644 --- a/src/bin/saorsa-devnet/main.rs +++ b/src/bin/saorsa-devnet/main.rs @@ -73,7 +73,6 @@ async fn main() -> color_eyre::Result<()> { } }; - config.enable_evm = true; config.evm_network = Some(network); info!("Anvil blockchain running at {rpc_url}"); diff --git a/src/bin/saorsa-node/cli.rs b/src/bin/saorsa-node/cli.rs index e04f2b5a..6b3594ee 100644 --- a/src/bin/saorsa-node/cli.rs +++ b/src/bin/saorsa-node/cli.rs @@ -42,10 +42,6 @@ pub struct Cli { )] pub upgrade_channel: CliUpgradeChannel, - /// Disable payment verification. - #[arg(long)] - pub disable_payment_verification: bool, - /// Cache capacity for verified `XorName` values. #[arg(long, default_value = "100000", env = "SAORSA_CACHE_CAPACITY")] pub cache_capacity: usize, @@ -218,9 +214,8 @@ impl Cli { ..config.upgrade }; - // Payment config + // Payment config (payment verification is always on) config.payment = PaymentConfig { - enabled: !self.disable_payment_verification, cache_capacity: self.cache_capacity, rewards_address: self.rewards_address, evm_network: self.evm_network.into(), diff --git a/src/client/data_types.rs b/src/client/data_types.rs index 4d1912d0..6c18e484 100644 --- a/src/client/data_types.rs +++ b/src/client/data_types.rs @@ -16,11 +16,7 @@ pub fn compute_address(content: &[u8]) -> XorName { /// Lexicographic comparison of the result gives correct Kademlia distance ordering. #[must_use] pub fn xor_distance(a: &XorName, b: &XorName) -> XorName { - let mut result = [0u8; 32]; - for i in 0..32 { - result[i] = a[i] ^ b[i]; - } - result + std::array::from_fn(|i| a[i] ^ b[i]) } /// Convert a hex-encoded peer ID string to an `XorName`. diff --git a/src/client/mod.rs b/src/client/mod.rs index 704a2c6c..9dc5f5aa 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,67 +1,81 @@ -//! Chunk client module for saorsa-node. +//! Protocol helpers for saorsa-node client operations. //! -//! This module provides a client interface for content-addressed chunk storage -//! on the saorsa network using post-quantum cryptography. +//! This module provides low-level protocol support for client-node communication. +//! For high-level client operations, use the `saorsa-client` crate instead. //! //! # Architecture //! -//! The chunk client provides: +//! This module contains: //! -//! 1. **Content-addressed storage**: Chunk address = BLAKE3(content) -//! 2. **PQC security**: All data uses ML-KEM-768 and ML-DSA-65 -//! 3. **EVM payment**: Chunks are paid for on Arbitrum network +//! 1. **Protocol message handlers**: Send/await pattern for chunks +//! 2. **Data types**: Common types like `XorName`, `DataChunk`, address computation //! -//! # Data Types +//! # Migration Note //! -//! Currently supports a single data type: -//! -//! - **Chunk**: Immutable content-addressed data (hash(value) == key) -//! -//! Future extensions may include non-content-addressed key-value storage. +//! The `QuantumClient` has been deprecated and consolidated into `saorsa-client::Client`. +//! Use `saorsa-client` for all client operations. //! //! # Example //! //! ```rust,ignore -//! use saorsa_node::client::{ChunkClient, ChunkConfig}; +//! use saorsa_client::Client; // Use saorsa-client instead of QuantumClient //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // Create client with default config -//! let client = ChunkClient::with_defaults(); +//! // High-level client API +//! let client = Client::connect(&bootstrap_peers, Default::default()).await?; //! -//! // Store new data (content-addressed) -//! let address = client.put_chunk(bytes::Bytes::from("hello world")).await?; +//! // Store data with payment +//! let address = client.chunk_put(bytes::Bytes::from("hello world")).await?; //! -//! // Retrieve data by address -//! let data = client.get_chunk(&address).await?; -//! -//! // Check statistics -//! let stats = client.stats(); -//! println!("Chunks stored: {}", stats.chunks_stored); -//! println!("Chunks retrieved: {}", stats.chunks_retrieved); +//! // Retrieve data +//! let chunk = client.chunk_get(&address).await?; //! //! Ok(()) //! } //! ``` -//! -//! # Security Model -//! -//! ## Quantum-Resistant Cryptography -//! -//! All data stored through this client uses: -//! - **ML-KEM-768** (NIST FIPS 203): Key encapsulation for encryption -//! - **ML-DSA-65** (NIST FIPS 204): Digital signatures for authentication -//! - **ChaCha20-Poly1305**: Symmetric encryption for data at rest mod chunk_protocol; mod data_types; -mod quantum; -pub mod self_encrypt; 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 quantum::{ - hex_node_id_to_encoded_peer_id, PaidChunk, PreparedChunk, QuantumClient, QuantumConfig, -}; + +// Re-export hex_node_id_to_encoded_peer_id for payment operations +use crate::error::{Error, Result}; +use ant_evm::EncodedPeerId; + +/// Identity multihash code (stores raw bytes without hashing). +const MULTIHASH_IDENTITY_CODE: u64 = 0x00; + +/// Convert a hex-encoded 32-byte saorsa-core node ID to an [`EncodedPeerId`]. +/// +/// Saorsa-core peer IDs are 64-character hex strings representing 32 raw bytes. +/// libp2p `PeerId` expects a multihash-encoded identity. This function bridges the two +/// formats by wrapping the raw bytes in an identity multihash (code 0x00) and then +/// converting to `EncodedPeerId` via `From`. +/// +/// # Errors +/// +/// Returns an error if the hex string is invalid or the peer ID cannot be constructed. +pub fn hex_node_id_to_encoded_peer_id(hex_id: &str) -> Result { + let raw_bytes = hex::decode(hex_id) + .map_err(|e| Error::Payment(format!("Invalid hex peer ID '{hex_id}': {e}")))?; + + let multihash = + multihash::Multihash::<64>::wrap(MULTIHASH_IDENTITY_CODE, &raw_bytes).map_err(|e| { + Error::Payment(format!( + "Failed to create multihash for peer '{hex_id}': {e}" + )) + })?; + + let peer_id = libp2p::PeerId::from_multihash(multihash).map_err(|_| { + Error::Payment(format!( + "Failed to create PeerId from multihash for peer '{hex_id}'" + )) + })?; + + Ok(EncodedPeerId::from(peer_id)) +} diff --git a/src/client/quantum.rs b/src/client/quantum.rs deleted file mode 100644 index e779416a..00000000 --- a/src/client/quantum.rs +++ /dev/null @@ -1,1121 +0,0 @@ -//! Quantum-resistant client operations for chunk storage. -//! -//! This module provides content-addressed chunk storage operations on the saorsa network -//! using post-quantum cryptography (ML-KEM-768 for key exchange, ML-DSA-65 for signatures). -//! -//! ## Data Model -//! -//! Chunks are the only data type supported: -//! - **Content-addressed**: Address = BLAKE3(content) -//! - **Immutable**: Once stored, content cannot change -//! - **Paid**: Storage requires EVM payment on Arbitrum when a wallet is configured; -//! devnets with EVM disabled accept unpaid puts -//! -//! ## Security Features -//! -//! - **ML-KEM-768**: NIST FIPS 203 compliant key encapsulation for encryption -//! - **ML-DSA-65**: NIST FIPS 204 compliant signatures for authentication -//! - **ChaCha20-Poly1305**: Symmetric encryption for data at rest - -use super::chunk_protocol::send_and_await_chunk_response; -use super::data_types::{compute_address, DataChunk, XorName}; -use crate::ant_protocol::{ - ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, - ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, -}; -use crate::error::{Error, Result}; -use crate::payment::single_node::REQUIRED_QUOTES; -use crate::payment::{calculate_price, PaymentProof, SingleNodePayment}; -use ant_evm::{Amount, EncodedPeerId, PaymentQuote, ProofOfPayment}; -use bytes::Bytes; -use evmlib::wallet::Wallet; -use futures::stream::{FuturesUnordered, StreamExt}; -use saorsa_core::identity::PeerId; -use saorsa_core::P2PNode; -use std::collections::{BTreeMap, HashSet}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tracing::{debug, info, warn}; - -/// Default timeout for network operations in seconds. -const DEFAULT_TIMEOUT_SECS: u64 = 30; - -/// Number of closest peers to consider for chunk routing. -const CLOSE_GROUP_SIZE: usize = 8; - -/// Default number of replicas for data redundancy. -const DEFAULT_REPLICA_COUNT: u8 = 4; - -/// A chunk that has been quoted but not yet paid or stored. -/// -/// Produced by [`QuantumClient::prepare_chunk_payment`] and consumed by -/// [`QuantumClient::batch_pay`] or [`QuantumClient::batch_pay_and_store`]. -pub struct PreparedChunk { - /// The raw chunk content. - pub content: Bytes, - /// Content-address (BLAKE3 hash). - pub address: XorName, - /// Peer ID + quote pairs for building `ProofOfPayment`. - pub peer_quotes: Vec<(EncodedPeerId, PaymentQuote)>, - /// The payment structure (sorted quotes, median selected). - pub payment: SingleNodePayment, - /// The closest peer to the chunk address, pinned during quote collection - /// so that the storage target is always one of the paid peers. - pub target_peer: PeerId, -} - -/// A chunk that has been paid on-chain but not yet stored on the network. -/// -/// Produced by [`QuantumClient::batch_pay`]. Store via -/// [`QuantumClient::put_chunk_with_proof`]. -pub struct PaidChunk { - /// The raw chunk content. - pub content: Bytes, - /// Serialized payment proof (msgpack bytes). - pub proof_bytes: Vec, - /// Transaction hashes from this chunk's on-chain payment. - pub tx_hashes: Vec, - /// The closest peer to the chunk address, pinned during quote collection - /// so that the storage target is always one of the paid peers. - pub target_peer: PeerId, -} - -/// Configuration for the quantum-resistant client. -#[derive(Debug, Clone)] -pub struct QuantumConfig { - /// Timeout for network operations in seconds. - pub timeout_secs: u64, - /// Number of replicas for data redundancy. - pub replica_count: u8, - /// Enable encryption for all stored data. - pub encrypt_data: bool, -} - -impl Default for QuantumConfig { - fn default() -> Self { - Self { - timeout_secs: DEFAULT_TIMEOUT_SECS, - replica_count: DEFAULT_REPLICA_COUNT, - encrypt_data: true, - } - } -} - -/// Client for quantum-resistant chunk operations on the saorsa network. -/// -/// This client uses post-quantum cryptography for all operations: -/// - ML-KEM-768 for key encapsulation -/// - ML-DSA-65 for digital signatures -/// - ChaCha20-Poly1305 for symmetric encryption -/// -/// ## Chunk Storage Model -/// -/// Chunks are content-addressed: the address is the BLAKE3 hash of the content. -/// This ensures data integrity - if the content matches the address, the data -/// is authentic. When a wallet is configured, chunk storage requires EVM payment -/// on Arbitrum. Without a wallet, chunks can be stored on devnets with EVM disabled. -pub struct QuantumClient { - config: QuantumConfig, - p2p_node: Option>, - wallet: Option>, - next_request_id: AtomicU64, -} - -impl QuantumClient { - /// Create a new quantum client with the given configuration. - #[must_use] - pub fn new(config: QuantumConfig) -> Self { - debug!("Creating quantum-resistant saorsa client"); - Self { - config, - p2p_node: None, - wallet: None, - next_request_id: AtomicU64::new(1), - } - } - - /// Create a quantum client with default configuration. - #[must_use] - pub fn with_defaults() -> Self { - Self::new(QuantumConfig::default()) - } - - /// Set the P2P node for network operations. - #[must_use] - pub fn with_node(mut self, node: Arc) -> Self { - self.p2p_node = Some(node); - self - } - - /// Set the wallet for payment operations. - #[must_use] - pub fn with_wallet(mut self, wallet: Wallet) -> Self { - self.wallet = Some(Arc::new(wallet)); - self - } - - /// Get a chunk from the saorsa network via ANT protocol. - /// - /// Sends a `ChunkGetRequest` to a connected peer and waits for the - /// `ChunkGetResponse`. - /// - /// # Arguments - /// - /// * `address` - The `XorName` address of the chunk (BLAKE3 of content) - /// - /// # Returns - /// - /// The chunk data if found, or None if not present in the network. - /// - /// # Errors - /// - /// Returns an error if the network operation fails. - pub async fn get_chunk(&self, address: &XorName) -> Result> { - if tracing::enabled!(tracing::Level::DEBUG) { - let addr_hex = hex::encode(address); - debug!("Querying saorsa network for chunk: {addr_hex}"); - } - - let Some(ref node) = self.p2p_node else { - return Err(Error::Network("P2P node not configured".into())); - }; - - let target_peer = Self::pick_target_peer(node, address).await?; - - // Create and send GET request - let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let request = ChunkGetRequest::new(*address); - let message = ChunkMessage { - request_id, - body: ChunkMessageBody::GetRequest(request), - }; - let message_bytes = message - .encode() - .map_err(|e| Error::Network(format!("Failed to encode GET request: {e}")))?; - - let timeout = Duration::from_secs(self.config.timeout_secs); - let addr_hex = hex::encode(address); - let timeout_secs = self.config.timeout_secs; - - send_and_await_chunk_response( - node, - &target_peer, - message_bytes, - request_id, - timeout, - |body| match body { - ChunkMessageBody::GetResponse(ChunkGetResponse::Success { - address: addr, - content, - }) => { - if addr != *address { - if tracing::enabled!(tracing::Level::WARN) { - warn!( - "Peer returned chunk {} but we requested {}", - hex::encode(addr), - addr_hex - ); - } - return Some(Err(Error::InvalidChunk(format!( - "Mismatched chunk address: expected {addr_hex}, got {}", - hex::encode(addr) - )))); - } - - let computed = compute_address(&content); - if computed != addr { - if tracing::enabled!(tracing::Level::WARN) { - warn!( - "Peer returned chunk {} with invalid content hash {}", - addr_hex, - hex::encode(computed) - ); - } - return Some(Err(Error::InvalidChunk(format!( - "Invalid chunk content: expected hash {addr_hex}, got {}", - hex::encode(computed) - )))); - } - - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - "Found chunk {} on saorsa network ({} bytes)", - hex::encode(addr), - content.len() - ); - } - Some(Ok(Some(DataChunk::new(addr, Bytes::from(content))))) - } - ChunkMessageBody::GetResponse(ChunkGetResponse::NotFound { .. }) => { - debug!("Chunk {} not found on saorsa network", addr_hex); - Some(Ok(None)) - } - ChunkMessageBody::GetResponse(ChunkGetResponse::Error(e)) => Some(Err( - Error::Network(format!("Remote GET error for {addr_hex}: {e}")), - )), - _ => None, - }, - |e| Error::Network(format!("Failed to send GET to peer {target_peer}: {e}")), - || { - Error::Network(format!( - "Timeout waiting for chunk {addr_hex} after {timeout_secs}s" - )) - }, - ) - .await - } - - /// Store a chunk on the saorsa network with full payment workflow. - /// - /// This method implements the complete payment flow: - /// 1. Request quotes from 5 closest nodes via DHT - /// 2. Sort quotes by price and select median (index 2) - /// 3. Pay median node 3x on Arbitrum, send 0 atto to other 4 - /// 4. Create `ProofOfPayment` with all 5 quotes - /// 5. Send chunk with payment proof to storage nodes - /// - /// # Arguments - /// - /// * `content` - The data to store - /// - /// # Returns - /// - /// The `XorName` address where the chunk was stored. - /// - /// # Errors - /// - /// Returns an error if: - /// - Wallet is not configured - /// - Quote collection fails - /// - Payment fails - /// - Storage operation fails - pub async fn put_chunk_with_payment( - &self, - content: Bytes, - ) -> Result<(XorName, Vec)> { - let content_len = content.len(); - info!("Storing chunk with payment ({content_len} bytes)"); - - let Some(ref node) = self.p2p_node else { - return Err(Error::Network("P2P node not configured".into())); - }; - - let Some(ref wallet) = self.wallet else { - return Err(Error::Payment( - "Wallet not configured - use with_wallet() to enable payments".to_string(), - )); - }; - - // Compute content address - let address = compute_address(&content); - let content_size = content.len(); - let data_size = u64::try_from(content_size) - .map_err(|e| Error::Network(format!("Content size too large: {e}")))?; - - // Step 1: Request quotes from network nodes via DHT. - // The closest peer is pinned here so we store to a peer that was paid. - let (target_peer, quotes_with_peers) = self - .get_quotes_from_dht_for_address(&address, data_size) - .await?; - - if quotes_with_peers.len() != REQUIRED_QUOTES { - return Err(Error::Payment(format!( - "Expected {REQUIRED_QUOTES} quotes but received {}", - quotes_with_peers.len() - ))); - } - - // Step 2: Split quotes into peer_quotes (for ProofOfPayment) and - // quotes_with_prices (for SingleNodePayment) in a single pass. - let mut peer_quotes: Vec<(EncodedPeerId, PaymentQuote)> = - Vec::with_capacity(quotes_with_peers.len()); - let mut quotes_with_prices: Vec<(PaymentQuote, Amount)> = - Vec::with_capacity(quotes_with_peers.len()); - - for (peer_id, quote, price) in quotes_with_peers { - let encoded_peer_id = hex_node_id_to_encoded_peer_id(&peer_id.to_hex())?; - peer_quotes.push((encoded_peer_id, quote.clone())); - quotes_with_prices.push((quote, price)); - } - - // Step 3: Create SingleNodePayment (sorts by price, selects median, pays 3x) - let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; - - info!( - "Payment prepared: {} atto total (3x median price)", - payment.total_amount() - ); - - // Step 4: Pay on-chain — capture transaction hashes - let tx_hashes = payment.pay(wallet).await?; - info!( - "Payment successful on Arbitrum ({} transactions)", - tx_hashes.len() - ); - - // Step 5: Build proof AFTER payment succeeds, including tx hashes - let proof = PaymentProof { - proof_of_payment: ProofOfPayment { peer_quotes }, - tx_hashes: tx_hashes.clone(), - }; - let payment_proof = rmp_serde::to_vec(&proof) - .map_err(|e| Error::Network(format!("Failed to serialize payment proof: {e}")))?; - - // Step 6: Send chunk with payment proof to the peer pinned during quoting - let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let request = ChunkPutRequest::with_payment(address, content.to_vec(), payment_proof); - let message = ChunkMessage { - request_id, - body: ChunkMessageBody::PutRequest(request), - }; - let message_bytes = message - .encode() - .map_err(|e| Error::Network(format!("Failed to encode PUT request: {e}")))?; - - let stored_address = Self::send_put_and_await( - node, - &target_peer, - message_bytes, - request_id, - self.config.timeout_secs, - hex::encode(address), - content_size, - ) - .await?; - - Ok((stored_address, tx_hashes)) - } - - /// Store a chunk with a pre-built payment proof, skipping the internal payment flow. - /// - /// The `target_peer` should be the peer pinned during quote collection so that - /// the storage target is guaranteed to be one of the paid peers. Use the - /// `target_peer` field from [`PaidChunk`] or the first element returned by - /// [`get_quotes_from_dht`](Self::get_quotes_from_dht). - /// - /// # Arguments - /// - /// * `content` - The data to store - /// * `proof` - A serialised [`ProofOfPayment`] (msgpack bytes) - /// * `target_peer` - The peer to send the chunk to (pinned during quoting) - /// - /// # Returns - /// - /// The `XorName` address where the chunk was stored. - /// - /// # Errors - /// - /// Returns an error if: - /// - P2P node is not configured - /// - Storage operation fails - pub async fn put_chunk_with_proof( - &self, - content: Bytes, - proof: Vec, - target_peer: &PeerId, - ) -> Result { - let Some(ref node) = self.p2p_node else { - return Err(Error::Network("P2P node not configured".into())); - }; - - let address = compute_address(&content); - let content_size = content.len(); - - let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let request = ChunkPutRequest::with_payment(address, content.to_vec(), proof); - let message = ChunkMessage { - request_id, - body: ChunkMessageBody::PutRequest(request), - }; - let message_bytes = message - .encode() - .map_err(|e| Error::Network(format!("Failed to encode PUT request: {e}")))?; - - Self::send_put_and_await( - node, - target_peer, - message_bytes, - request_id, - self.config.timeout_secs, - hex::encode(address), - content_size, - ) - .await - } - - /// Collect quotes for a chunk without paying. - /// - /// Returns a [`PreparedChunk`] containing all the information needed to - /// pay and store the chunk later. Use with [`batch_pay_and_store`](Self::batch_pay_and_store) - /// to pay for multiple chunks in a single EVM transaction. - /// - /// # Errors - /// - /// Returns an error if DHT lookup or quote collection fails. - pub async fn prepare_chunk_payment(&self, content: Bytes) -> Result { - let content_len = content.len(); - debug!("Preparing payment for chunk ({content_len} bytes)"); - - self.p2p_node - .as_ref() - .ok_or_else(|| Error::Network("P2P node not configured".into()))?; - - self.wallet.as_ref().ok_or_else(|| { - Error::Payment( - "Wallet not configured - use with_wallet() to enable payments".to_string(), - ) - })?; - - let address = compute_address(&content); - let data_size = u64::try_from(content.len()) - .map_err(|e| Error::Network(format!("Content size too large: {e}")))?; - - let (target_peer, quotes_with_peers) = self - .get_quotes_from_dht_for_address(&address, data_size) - .await?; - - if quotes_with_peers.len() != REQUIRED_QUOTES { - return Err(Error::Payment(format!( - "Expected {REQUIRED_QUOTES} quotes but received {}", - quotes_with_peers.len() - ))); - } - - let mut peer_quotes: Vec<(EncodedPeerId, PaymentQuote)> = - Vec::with_capacity(quotes_with_peers.len()); - let mut quotes_with_prices: Vec<(PaymentQuote, Amount)> = - Vec::with_capacity(quotes_with_peers.len()); - - for (peer_id, quote, price) in quotes_with_peers { - let encoded_peer_id = hex_node_id_to_encoded_peer_id(&peer_id.to_hex())?; - peer_quotes.push((encoded_peer_id, quote.clone())); - quotes_with_prices.push((quote, price)); - } - - let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; - - Ok(PreparedChunk { - content, - address, - peer_quotes, - payment, - target_peer, - }) - } - - /// Pay for multiple prepared chunks in a single EVM transaction. - /// - /// Returns [`PaidChunk`]s ready for storage via [`put_chunk_with_proof`](Self::put_chunk_with_proof). - /// Use this for pipelined uploads where stores from wave N overlap with - /// quotes for wave N+1. - /// - /// # Errors - /// - /// Returns an error if the EVM payment fails. - pub async fn batch_pay(&self, prepared: Vec) -> Result> { - let Some(ref wallet) = self.wallet else { - return Err(Error::Payment( - "Wallet not configured - use with_wallet() to enable payments".to_string(), - )); - }; - - if prepared.is_empty() { - return Ok(Vec::new()); - } - - let total_amount: Amount = prepared.iter().map(|p| p.payment.total_amount()).sum(); - let chunk_count = prepared.len(); - info!("Batch payment for {chunk_count} chunks: {total_amount} atto total"); - - let all_quote_payments: Vec<(ant_evm::QuoteHash, ant_evm::RewardsAddress, Amount)> = - prepared - .iter() - .flat_map(|p| &p.payment.quotes) - .map(|q| (q.quote_hash, q.rewards_address, q.amount)) - .collect(); - - let (tx_hash_map, _gas_info) = wallet.pay_for_quotes(all_quote_payments).await.map_err( - |evmlib::wallet::PayForQuotesError(err, _)| { - Error::Payment(format!("Batch payment failed: {err}")) - }, - )?; - - let unique_tx_count = { - let mut txs: Vec<_> = tx_hash_map.values().collect(); - txs.sort(); - txs.dedup(); - txs.len() - }; - info!("Batch payment successful: {unique_tx_count} on-chain transaction(s) for {chunk_count} chunks"); - - prepared - .into_iter() - .map(|prep| { - let chunk_tx_hashes = Self::collect_chunk_tx_hashes(&prep.payment, &tx_hash_map); - let proof = PaymentProof { - proof_of_payment: ProofOfPayment { - peer_quotes: prep.peer_quotes, - }, - tx_hashes: chunk_tx_hashes.clone(), - }; - let proof_bytes = rmp_serde::to_vec(&proof).map_err(|e| { - Error::Network(format!("Failed to serialize payment proof: {e}")) - })?; - Ok(PaidChunk { - content: prep.content, - proof_bytes, - tx_hashes: chunk_tx_hashes, - target_peer: prep.target_peer, - }) - }) - .collect() - } - - /// Pay for multiple chunks in a single EVM transaction, then store them. - /// - /// Convenience wrapper around [`batch_pay`](Self::batch_pay) followed by - /// concurrent [`put_chunk_with_proof`](Self::put_chunk_with_proof) calls. - /// - /// # Errors - /// - /// Returns an error if payment or any chunk storage fails. - pub async fn batch_pay_and_store( - &self, - prepared: Vec, - ) -> Result)>> { - let chunk_count = prepared.len(); - let paid_chunks = self.batch_pay(prepared).await?; - - let mut store_futures = FuturesUnordered::new(); - for (idx, paid) in paid_chunks.into_iter().enumerate() { - let tx_hashes = paid.tx_hashes.clone(); - let target_peer = paid.target_peer; - let fut = async move { - let address = self - .put_chunk_with_proof(paid.content, paid.proof_bytes, &target_peer) - .await?; - Ok::<_, Error>((idx, address, tx_hashes)) - }; - store_futures.push(fut); - } - - let mut results: Vec)>> = - vec![None; chunk_count]; - while let Some(result) = store_futures.next().await { - let (idx, address, tx_hashes) = result?; - results[idx] = Some((address, tx_hashes)); - } - - results - .into_iter() - .enumerate() - .map(|(i, opt)| { - opt.ok_or_else(|| { - Error::Network(format!("Missing store result for chunk index {i}")) - }) - }) - .collect() - } - - /// Extract transaction hashes relevant to a single chunk's payment. - fn collect_chunk_tx_hashes( - payment: &SingleNodePayment, - tx_hash_map: &BTreeMap, - ) -> Vec { - payment - .quotes - .iter() - .filter(|q| q.amount > Amount::ZERO) - .filter_map(|q| tx_hash_map.get(&q.quote_hash).copied()) - .collect() - } - - /// Store a chunk on the saorsa network. - /// - /// Requires a wallet to be configured. Delegates to - /// [`put_chunk_with_payment`](Self::put_chunk_with_payment) for the full - /// payment flow (quotes, on-chain payment, proof). - /// - /// # Arguments - /// - /// * `content` - The data to store - /// - /// # Returns - /// - /// The `XorName` address where the chunk was stored. - /// - /// # Errors - /// - /// Returns an error if: - /// - No wallet is configured - /// - P2P node is not configured - /// - No remote peers found near the target address - /// - The storage operation fails - pub async fn put_chunk(&self, content: Bytes) -> Result { - if self.wallet.is_some() { - let (address, _tx_hashes) = self.put_chunk_with_payment(content).await?; - return Ok(address); - } - - Err(Error::Payment( - "No wallet configured — payment is required for chunk storage. \ - Use --private-key or set SECRET_KEY to provide a wallet." - .to_string(), - )) - } - - /// Send a PUT request and await the response. - /// - /// Shared helper for all three PUT methods to avoid duplicating the - /// response-matching logic. - async fn send_put_and_await( - node: &P2PNode, - target_peer: &PeerId, - message_bytes: Vec, - request_id: u64, - timeout_secs: u64, - addr_hex: String, - content_size: usize, - ) -> Result { - let timeout = Duration::from_secs(timeout_secs); - send_and_await_chunk_response( - node, - target_peer, - message_bytes, - request_id, - timeout, - |body| match body { - ChunkMessageBody::PutResponse(ChunkPutResponse::Success { address: addr }) => { - info!( - "Chunk stored at address: {} ({content_size} bytes)", - hex::encode(addr), - ); - Some(Ok(addr)) - } - ChunkMessageBody::PutResponse(ChunkPutResponse::AlreadyExists { - address: addr, - }) => { - info!("Chunk already exists at address: {}", hex::encode(addr)); - Some(Ok(addr)) - } - ChunkMessageBody::PutResponse(ChunkPutResponse::PaymentRequired { message }) => { - Some(Err(Error::Network(format!("Payment required: {message}")))) - } - ChunkMessageBody::PutResponse(ChunkPutResponse::Error(e)) => Some(Err( - Error::Network(format!("Remote PUT error for {addr_hex}: {e}")), - )), - _ => None, - }, - |e| Error::Network(format!("Failed to send PUT to peer {target_peer}: {e}")), - || { - Error::Network(format!( - "Timeout waiting for store response for {addr_hex} after {timeout_secs}s" - )) - }, - ) - .await - } - - /// Check if a chunk exists on the saorsa network. - /// - /// Implemented via `get_chunk` — returns `Ok(true)` on success, - /// `Ok(false)` if not found. - /// - /// # Arguments - /// - /// * `address` - The `XorName` to check - /// - /// # Returns - /// - /// True if the chunk exists, false otherwise. - /// - /// # Errors - /// - /// Returns an error if the network operation fails. - pub async fn exists(&self, address: &XorName) -> Result { - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - "Checking existence on saorsa network: {}", - hex::encode(address) - ); - } - self.get_chunk(address).await.map(|opt| opt.is_some()) - } - - /// Pick the closest peer to `target` using an iterative Kademlia network lookup. - /// - /// Queries the DHT for the `CLOSE_GROUP_SIZE` closest nodes to the target - /// address and returns the single closest remote peer (excluding ourselves). - async fn pick_target_peer(node: &P2PNode, target: &XorName) -> Result { - let local_peer_id = node.peer_id(); - - let closest_nodes = node - .dht() - .find_closest_nodes(target, CLOSE_GROUP_SIZE) - .await - .map_err(|e| Error::Network(format!("Kademlia closest-nodes lookup failed: {e}")))?; - - let closest = closest_nodes - .into_iter() - .find(|n| n.peer_id != *local_peer_id) - .ok_or_else(|| Error::Network("No remote peers found near target address".into()))?; - - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - "Selected closest peer {} for target {}", - closest.peer_id, - hex::encode(target) - ); - } - - Ok(closest.peer_id) - } - - /// Get quotes from DHT peers for chunk storage. - /// - /// Computes the content address and requests quotes from the closest peers. - /// Collects exactly `REQUIRED_QUOTES` quotes. - /// - /// # Arguments - /// - /// * `content` - The chunk data to get quotes for - /// - /// # Returns - /// - /// A vector of (`peer_id`, `PaymentQuote`, `Amount`) tuples containing the quoting peer's ID, - /// the quote, and its price. - /// - /// # Errors - /// - /// Returns an error if: - /// - DHT lookup fails - /// - Failed to collect enough quotes - /// - Quote deserialization fails - pub async fn get_quotes_from_dht( - &self, - content: &[u8], - ) -> Result<(PeerId, Vec<(PeerId, PaymentQuote, Amount)>)> { - let address = compute_address(content); - let data_size = u64::try_from(content.len()) - .map_err(|e| Error::Network(format!("Content size too large: {e}")))?; - self.get_quotes_from_dht_for_address(&address, data_size) - .await - } - - /// Get quotes from DHT peers for chunk storage using a pre-computed address. - /// - /// Queries the DHT for the closest peers to the chunk address and requests - /// storage quotes from them. Collects exactly `REQUIRED_QUOTES` quotes. - /// - /// # Arguments - /// - /// * `address` - The pre-computed `XorName` address for the chunk - /// * `data_size` - The size of the chunk data in bytes - /// - /// # Returns - /// - /// A vector of (`peer_id`, `PaymentQuote`, `Amount`) tuples containing the quoting peer's ID, - /// the quote, and its price. - /// - /// # Errors - /// - /// Returns an error if: - /// - DHT lookup fails - /// - Failed to collect enough quotes - /// - Quote deserialization fails - #[allow(clippy::too_many_lines)] - async fn get_quotes_from_dht_for_address( - &self, - address: &XorName, - data_size: u64, - ) -> Result<(PeerId, Vec<(PeerId, PaymentQuote, Amount)>)> { - let Some(ref node) = self.p2p_node else { - return Err(Error::Network("P2P node not configured".into())); - }; - - if tracing::enabled!(tracing::Level::DEBUG) { - let addr_hex = hex::encode(address); - debug!( - "Requesting {REQUIRED_QUOTES} quotes from DHT for chunk {addr_hex} (size: {data_size})" - ); - } - - let local_peer_id = node.peer_id(); - - // Find closest peers via DHT - let closest_nodes = node - .dht() - .find_closest_nodes(address, CLOSE_GROUP_SIZE) - .await - .map_err(|e| Error::Network(format!("DHT closest-nodes lookup failed: {e}")))?; - - // Filter out self and collect remote peers - let mut remote_peers: Vec = closest_nodes - .into_iter() - .filter(|n| n.peer_id != *local_peer_id) - .map(|n| n.peer_id) - .collect(); - - // Fallback to connected_peers() if DHT has insufficient peers - // This handles the case where DHT routing tables are still warming up - if remote_peers.len() < REQUIRED_QUOTES { - warn!( - "DHT returned only {} peers for {}, falling back to connected_peers()", - remote_peers.len(), - hex::encode(address) - ); - - let connected = node.connected_peers().await; - debug!("Found {} connected P2P peers for fallback", connected.len()); - - // Add connected peers that aren't already in remote_peers (O(1) dedup via HashSet) - let mut existing: HashSet = remote_peers.iter().copied().collect(); - for peer_id in connected { - if existing.insert(peer_id) { - remote_peers.push(peer_id); - } - } - - if remote_peers.len() < REQUIRED_QUOTES { - return Err(Error::Network(format!( - "Insufficient peers for quotes: found {} (DHT + P2P fallback), need {}", - remote_peers.len(), - REQUIRED_QUOTES - ))); - } - - info!( - "Fallback successful: now have {} peers for quote requests", - remote_peers.len() - ); - } - - // Pin the closest peer as the storage target. This peer is always - // among the quoted set, so the payment proof will include it. - let closest_peer = remote_peers[0]; - - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - "Found {} remote peers, requesting quotes from first {} (closest: {})", - remote_peers.len(), - REQUIRED_QUOTES, - closest_peer - ); - } - - // Request quotes from all peers concurrently - // Collect the first REQUIRED_QUOTES successful responses - let timeout = Duration::from_secs(self.config.timeout_secs); - - // Create futures for all quote requests concurrently - let mut quote_futures = FuturesUnordered::new(); - - for peer_id in &remote_peers { - let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let request = ChunkQuoteRequest::new(*address, data_size); - let message = ChunkMessage { - request_id, - body: ChunkMessageBody::QuoteRequest(request), - }; - - let message_bytes = match message.encode() { - Ok(bytes) => bytes, - Err(e) => { - warn!("Failed to encode quote request for {peer_id}: {e}"); - continue; - } - }; - - // Clone necessary data for the async task - let peer_id_clone = *peer_id; - let node_clone = node.clone(); - - // Create a future for this quote request - let quote_future = async move { - let quote_result = send_and_await_chunk_response( - &node_clone, - &peer_id_clone, - message_bytes, - request_id, - timeout, - |body| match body { - ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success { quote }) => { - // Deserialize the quote - match rmp_serde::from_slice::("e) { - Ok(payment_quote) => { - let price = calculate_price(&payment_quote.quoting_metrics); - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - "Received quote from {peer_id_clone}: price = {price}" - ); - } - Some(Ok((payment_quote, price))) - } - Err(e) => Some(Err(Error::Network(format!( - "Failed to deserialize quote from {peer_id_clone}: {e}" - )))), - } - } - ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Error(e)) => Some(Err( - Error::Network(format!("Quote error from {peer_id_clone}: {e}")), - )), - _ => None, - }, - |e| { - Error::Network(format!( - "Failed to send quote request to {peer_id_clone}: {e}" - )) - }, - || Error::Network(format!("Timeout waiting for quote from {peer_id_clone}")), - ) - .await; - - (peer_id_clone, quote_result) - }; - - quote_futures.push(quote_future); - } - - // Collect quotes as they complete, stopping once we have REQUIRED_QUOTES - let mut quotes_with_peers = Vec::with_capacity(REQUIRED_QUOTES); - - while let Some((peer_id, quote_result)) = quote_futures.next().await { - match quote_result { - Ok((quote, price)) => { - quotes_with_peers.push((peer_id, quote, price)); - - // Stop collecting once we have enough quotes - if quotes_with_peers.len() >= REQUIRED_QUOTES { - break; - } - } - Err(e) => { - warn!("Failed to get quote from {peer_id}: {e}"); - // Continue trying other peers - } - } - } - - if quotes_with_peers.len() < REQUIRED_QUOTES { - return Err(Error::Network(format!( - "Failed to collect enough quotes: got {}, need {}", - quotes_with_peers.len(), - REQUIRED_QUOTES - ))); - } - - if tracing::enabled!(tracing::Level::INFO) { - let quote_count = quotes_with_peers.len(); - let addr_hex = hex::encode(address); - info!("Collected {quote_count} quotes for chunk {addr_hex}"); - } - - Ok((closest_peer, quotes_with_peers)) - } -} - -/// Identity multihash code (stores raw bytes without hashing). -const MULTIHASH_IDENTITY_CODE: u64 = 0x00; - -/// Convert a hex-encoded 32-byte saorsa-core node ID to an [`EncodedPeerId`]. -/// -/// Saorsa-core peer IDs are 64-character hex strings representing 32 raw bytes. -/// libp2p `PeerId` expects a multihash-encoded identity. This function bridges the two -/// formats by wrapping the raw bytes in an identity multihash (code 0x00) and then -/// converting to `EncodedPeerId` via `From`. -/// -/// # Errors -/// -/// Returns an error if the hex string is invalid or the peer ID cannot be constructed. -pub fn hex_node_id_to_encoded_peer_id(hex_id: &str) -> Result { - let raw_bytes = hex::decode(hex_id) - .map_err(|e| Error::Payment(format!("Invalid hex peer ID '{hex_id}': {e}")))?; - - let multihash = - multihash::Multihash::<64>::wrap(MULTIHASH_IDENTITY_CODE, &raw_bytes).map_err(|e| { - Error::Payment(format!( - "Failed to create multihash for peer '{hex_id}': {e}" - )) - })?; - - let peer_id = libp2p::PeerId::from_multihash(multihash).map_err(|_| { - Error::Payment(format!( - "Failed to create PeerId from multihash for peer '{hex_id}'" - )) - })?; - - Ok(EncodedPeerId::from(peer_id)) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn test_quantum_config_default() { - let config = QuantumConfig::default(); - assert_eq!(config.timeout_secs, DEFAULT_TIMEOUT_SECS); - assert_eq!(config.replica_count, DEFAULT_REPLICA_COUNT); - assert!(config.encrypt_data); - } - - #[test] - fn test_quantum_client_creation() { - let client = QuantumClient::with_defaults(); - assert_eq!(client.config.timeout_secs, DEFAULT_TIMEOUT_SECS); - assert!(client.p2p_node.is_none()); - } - - #[tokio::test] - async fn test_get_chunk_without_node_fails() { - let client = QuantumClient::with_defaults(); - let address = [0; 32]; - - let result = client.get_chunk(&address).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_put_chunk_without_node_fails() { - let client = QuantumClient::with_defaults(); - let content = Bytes::from("test data"); - - let result = client.put_chunk(content).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_exists_without_node_fails() { - let client = QuantumClient::with_defaults(); - let address = [0; 32]; - - let result = client.exists(&address).await; - assert!(result.is_err()); - } - - #[test] - fn test_hex_node_id_to_encoded_peer_id_valid() { - // A valid 32-byte hex-encoded node ID (64 hex chars) - let hex_id = "80b6427dc1b0490ffe743d39a4d4d68c252f5053f6234a9154cfb017f92a1399"; - let result = hex_node_id_to_encoded_peer_id(hex_id); - assert!( - result.is_ok(), - "Should convert valid hex node ID: {result:?}" - ); - } - - #[test] - fn test_hex_node_id_to_encoded_peer_id_invalid_hex() { - let result = hex_node_id_to_encoded_peer_id("not-valid-hex"); - assert!(result.is_err()); - } - - #[test] - fn test_hex_node_id_to_encoded_peer_id_all_zeros() { - let hex_id = "0000000000000000000000000000000000000000000000000000000000000000"; - let result = hex_node_id_to_encoded_peer_id(hex_id); - assert!(result.is_ok()); - } -} diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs deleted file mode 100644 index 50c5487c..00000000 --- a/src/client/self_encrypt.rs +++ /dev/null @@ -1,877 +0,0 @@ -//! Self-encryption integration for file encrypt/decrypt. -//! -//! Wraps the `self_encryption` crate's streaming API to provide: -//! - **Streaming encryption** with bounded-memory concurrent upload -//! - **Streaming decryption** to file path (bounded memory via batch fetching) -//! - **`DataMap` serialization** for public/private data modes -//! -//! ## Public vs Private Data -//! -//! - **Public**: `DataMap` is stored as a chunk on the network; anyone with -//! the `DataMap` address can reconstruct the file. -//! - **Private** (default): `DataMap` is returned to the caller and never -//! uploaded. Only the holder of the `DataMap` can access the file. - -use crate::client::data_types::XorName as ChunkAddress; -use crate::client::quantum::{PaidChunk, PreparedChunk, QuantumClient}; -use crate::error::{Error, Result}; -use bytes::Bytes; -use futures::stream::{FuturesUnordered, StreamExt}; -use self_encryption::DataMap; -use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::hash::BuildHasher; -use std::io::{BufReader, Read, Write}; -use std::path::Path; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use tokio::runtime::Handle; -use tracing::{info, warn}; -use xor_name::XorName; - -/// Size of the read buffer used when streaming file data into the encryptor. -const READ_BUFFER_SIZE: usize = 64 * 1024; - -/// Maximum chunks per payment wave. -/// -/// Balances EVM gas efficiency (more chunks per tx = fewer on-chain transactions) -/// against pipeline responsiveness (smaller waves = earlier store overlap). -/// evmlib supports up to 256 non-zero payments per transaction. -const PAYMENT_WAVE_SIZE: usize = 64; - -/// Shared error capture used by `open_encrypt_stream`. -type ReadErrorCapture = Arc>>; - -/// Open a file and produce a streaming encryption iterator. -/// -/// Returns the encrypted-chunk stream and an error capture that should be -/// checked after iteration completes. -#[allow(clippy::type_complexity)] -fn open_encrypt_stream( - file_path: &Path, - file_size: usize, -) -> Result<( - self_encryption::EncryptionStream>, - ReadErrorCapture, -)> { - let file = std::fs::File::open(file_path).map_err(Error::Io)?; - let mut reader = BufReader::new(file); - let read_error: Arc>> = Arc::new(Mutex::new(None)); - let read_error_writer = Arc::clone(&read_error); - let mut buf = vec![0u8; READ_BUFFER_SIZE]; - let data_iter = std::iter::from_fn(move || match reader.read(&mut buf) { - Ok(0) => None, - Err(e) => { - if let Ok(mut guard) = read_error_writer.lock() { - *guard = Some(e); - } - None - } - Ok(n) => Some(Bytes::copy_from_slice(&buf[..n])), - }); - - // NOTE: Chunk size headroom for encryption overhead is managed by the - // self_encryption crate itself. See self_encryption::MAX_CHUNK_SIZE. - let stream = self_encryption::stream_encrypt(file_size, data_iter) - .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; - - Ok((stream, read_error)) -} - -/// Check whether the read-error capture from `open_encrypt_stream` recorded -/// an I/O error during iteration. -fn check_read_error(read_error: &ReadErrorCapture) -> Result<()> { - if let Ok(guard) = read_error.lock() { - if let Some(ref e) = *guard { - return Err(Error::Io(std::io::Error::new(e.kind(), format!("{e}")))); - } - } - Ok(()) -} - -/// Write a stream of decrypted chunks to a file atomically. -/// -/// Writes to a temporary file first, then renames on success. -/// Cleans up the temp file on error. -fn write_stream_to_file( - stream: impl Iterator>, - output_path: &Path, -) -> Result<()> { - let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); - let unique: u64 = rand::random(); - let tmp_path = parent.join(format!( - ".saorsa_decrypt_{}_{unique}.tmp", - std::process::id() - )); - - let result = (|| -> Result<()> { - let mut file = std::fs::File::create(&tmp_path).map_err(Error::Io)?; - for chunk_result in stream { - let chunk_bytes = - chunk_result.map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - file.write_all(&chunk_bytes).map_err(Error::Io)?; - } - Ok(()) - })(); - - if let Err(e) = result { - if let Err(cleanup_err) = std::fs::remove_file(&tmp_path) { - warn!( - "Failed to remove temp file {}: {cleanup_err}", - tmp_path.display() - ); - } - return Err(e); - } - - // On Windows, rename fails if destination exists. Remove it first. - if output_path.exists() { - std::fs::remove_file(output_path).map_err(Error::Io)?; - } - std::fs::rename(&tmp_path, output_path).map_err(Error::Io)?; - - Ok(()) -} - -/// Encrypt a file using streaming self-encryption and upload chunks with -/// pipelined, wave-based EVM payment. -/// -/// The upload proceeds as follows: -/// 1. **Stream** encrypted chunks lazily from the file — at most one wave -/// of chunks lives in memory at a time. -/// 2. **Wave loop** — for each wave of `PAYMENT_WAVE_SIZE` chunks: -/// - **Quote** the wave concurrently, while draining completed stores -/// from the previous wave via `select!`. -/// - **Pay** the wave in a single EVM transaction. -/// - **Launch stores** for the wave (non-blocking, added to the shared -/// store pool). -/// 3. **Drain** — await any remaining in-flight stores. -/// 4. **`DataMap`** — extract the `DataMap` after the encryption stream is -/// exhausted. -/// -/// This gives us batched payments (no nonce collisions, fewer on-chain txs), -/// pipelining (stores from wave N overlap with quotes for wave N+1), and -/// bounded memory (only one wave of chunks buffered at a time). -/// -/// Returns the `DataMap` after all chunks are uploaded, plus the list of -/// transaction hash strings from payment. -/// -/// # Errors -/// -/// Returns an error if encryption, quoting, payment, or storage fails. -#[allow(clippy::too_many_lines)] -pub async fn encrypt_and_upload_file( - file_path: &Path, - client: &QuantumClient, -) -> Result<(DataMap, Vec)> { - let metadata = std::fs::metadata(file_path).map_err(Error::Io)?; - let file_size: usize = metadata - .len() - .try_into() - .map_err(|_| Error::Crypto("File too large for this platform".into()))?; - info!( - "Encrypting file: {} ({file_size} bytes)", - file_path.display() - ); - - let (mut stream, read_error) = open_encrypt_stream(file_path, file_size)?; - let mut all_tx_hashes: Vec = Vec::new(); - let mut chunk_count: usize = 0; - let mut duplicates_skipped: usize = 0; - - // Track chunk addresses already paid for to avoid duplicate payments - // across waves. Content-addressed chunks (BLAKE3) with identical content - // share the same address, so we only need to pay once. - let mut paid_addresses: HashSet = HashSet::new(); - - // Shared pool of in-flight store operations across all waves. - let mut store_futs: FuturesUnordered< - Pin> + Send + '_>>, - > = FuturesUnordered::new(); - - // Stream chunks lazily in waves — only one wave of content in memory at a time. - // The block scope ensures `chunks_iter` (which borrows `stream` mutably) - // is dropped before we call `stream.into_datamap()`. - { - let mut chunks_iter = stream.chunks(); - let mut wave_idx: usize = 0; - - loop { - // Pull the next wave of chunks from the encryption stream, - // skipping any chunk whose address was already paid in a prior wave. - let mut wave: Vec = Vec::with_capacity(PAYMENT_WAVE_SIZE); - for chunk_result in chunks_iter.by_ref() { - let (hash, content) = chunk_result - .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; - if !paid_addresses.insert(hash) { - duplicates_skipped += 1; - continue; - } - wave.push(content); - if wave.len() >= PAYMENT_WAVE_SIZE { - break; - } - } - - if wave.is_empty() { - break; - } - - let wave_size = wave.len(); - chunk_count += wave_size; - info!( - "Wave {wave_idx}: quoting {wave_size} chunks ({} stores in flight)", - store_futs.len() - ); - - // Quote this wave concurrently, draining completed stores in parallel. - let prepared = quote_wave_pipelined(&wave, client, &mut store_futs).await?; - - // Pay for this wave (single EVM transaction). - let paid = client.batch_pay(prepared).await?; - - // Launch stores for this wave — content moves into the futures, - // freeing the wave buffer for the next iteration. - for paid_chunk in paid { - all_tx_hashes.extend(paid_chunk.tx_hashes.iter().map(|tx| format!("{tx:?}"))); - store_futs.push(Box::pin(store_paid_chunk(client, paid_chunk))); - } - - wave_idx += 1; - } - } - - // Drain remaining stores. - while let Some(result) = store_futs.next().await { - result?; - } - - check_read_error(&read_error)?; - - let data_map = stream - .into_datamap() - .ok_or_else(|| Error::Crypto("DataMap not available after encryption".into()))?; - - if duplicates_skipped > 0 { - info!( - "All {chunk_count} unique encrypted chunks uploaded ({duplicates_skipped} duplicates skipped)" - ); - } else { - info!("All {chunk_count} encrypted chunks uploaded"); - } - Ok((data_map, all_tx_hashes)) -} - -/// Store a single paid chunk on the network. -async fn store_paid_chunk(client: &QuantumClient, paid: PaidChunk) -> Result { - client - .put_chunk_with_proof(paid.content, paid.proof_bytes, &paid.target_peer) - .await -} - -/// Quote a wave of chunks while draining completed stores from prior waves. -/// -/// Uses `select!` to multiplex between collecting quotes for the current wave -/// and acknowledging completed stores, so stores from the previous wave make -/// progress concurrently with the current wave's DHT quote requests. -async fn quote_wave_pipelined<'a>( - wave: &[Bytes], - client: &'a QuantumClient, - store_futs: &mut FuturesUnordered< - Pin> + Send + 'a>>, - >, -) -> Result> { - let wave_len = wave.len(); - let mut quote_futs = FuturesUnordered::new(); - - for (idx, content) in wave.iter().enumerate() { - let content = content.clone(); - let fut = async move { (idx, client.prepare_chunk_payment(content).await) }; - quote_futs.push(fut); - } - - let mut results: Vec<(usize, PreparedChunk)> = Vec::with_capacity(wave_len); - - while results.len() < wave_len { - tokio::select! { - biased; - // Drain completed stores from previous waves to free resources. - Some(store_result) = store_futs.next(), if !store_futs.is_empty() => { - store_result?; - } - // Collect quotes for this wave. - Some((idx, quote_result)) = quote_futs.next() => { - results.push((idx, quote_result?)); - } - } - } - - results.sort_by_key(|(idx, _)| *idx); - Ok(results.into_iter().map(|(_, prep)| prep).collect()) -} - -/// Encrypt a file from disk using `stream_encrypt`, returning the `DataMap` -/// and a list of `(XorName, Bytes)` encrypted chunks. -fn encrypt_file_to_chunks( - file_path: &Path, - file_size: usize, -) -> Result<(DataMap, Vec<(XorName, Bytes)>)> { - let (mut stream, read_error) = open_encrypt_stream(file_path, file_size)?; - - let mut chunks = Vec::new(); - for chunk_result in stream.chunks() { - let (hash, content) = - chunk_result.map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; - chunks.push((hash, content)); - } - - check_read_error(&read_error)?; - - let data_map = stream - .into_datamap() - .ok_or_else(|| Error::Crypto("DataMap not available after encryption".into()))?; - - Ok((data_map, chunks)) -} - -/// Download and decrypt a file given its `DataMap`. -/// -/// Uses `streaming_decrypt()` which yields decrypted chunks as an iterator, -/// fetching encrypted chunks on-demand in batches from the network. -/// -/// The sync callback required by `streaming_decrypt` bridges to async via -/// `block_in_place` + `block_on`, but fetches all chunks in each batch -/// concurrently using `FuturesUnordered`. This means each `block_on` call -/// resolves an entire batch in parallel rather than fetching one chunk at a -/// time, reducing thread pool contention to one blocking call per batch -/// instead of one per chunk. -/// -/// Memory usage is bounded by the batch size (default ~10 chunks), not -/// the total file size. -/// -/// # Errors -/// -/// Returns an error if any chunk cannot be fetched or decryption fails. -pub async fn download_and_decrypt_file( - data_map: &DataMap, - output_path: &Path, - client: &QuantumClient, -) -> Result<()> { - let chunk_count = data_map.chunk_identifiers.len(); - info!("Decrypting file: {chunk_count} chunk(s) to decrypt (fetching on-demand)"); - - let handle = Handle::current(); - - let stream = self_encryption::streaming_decrypt(data_map, |batch: &[(usize, XorName)]| { - let batch_owned: Vec<(usize, XorName)> = batch.to_vec(); - - // block_in_place panics on current_thread runtime, and handle.block_on - // deadlocks there. Reject unsupported runtime flavors explicitly. - if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread { - return Err(self_encryption::Error::Generic( - "download_and_decrypt_file requires a multi_thread tokio runtime".into(), - )); - } - tokio::task::block_in_place(|| { - handle.block_on(async { - let mut futs = FuturesUnordered::new(); - for (idx, hash) in batch_owned { - let addr = hash.0; - futs.push(async move { - let result = client.get_chunk(&addr).await; - (idx, hash, result) - }); - } - - let mut results = Vec::with_capacity(futs.len()); - while let Some((idx, hash, result)) = futs.next().await { - let addr_hex = hex::encode(hash.0); - let chunk = result - .map_err(|e| { - self_encryption::Error::Generic(format!( - "Network fetch failed for {addr_hex}: {e}" - )) - })? - .ok_or_else(|| { - self_encryption::Error::Generic(format!( - "Chunk not found on network: {addr_hex}" - )) - })?; - results.push((idx, chunk.content)); - } - Ok(results) - }) - }) - }) - .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - - write_stream_to_file(stream, output_path)?; - - info!("Decryption complete: {}", output_path.display()); - Ok(()) -} - -/// Serialize a `DataMap` to bytes using bincode (via `DataMap::to_bytes`). -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn serialize_data_map(data_map: &DataMap) -> Result> { - data_map - .to_bytes() - .map_err(|e| Error::Serialization(format!("Failed to serialize DataMap: {e}"))) -} - -/// Deserialize a `DataMap` from bytes. -/// -/// # Errors -/// -/// Returns an error if deserialization fails. -pub fn deserialize_data_map(bytes: &[u8]) -> Result { - DataMap::from_bytes(bytes) - .map_err(|e| Error::Serialization(format!("Failed to deserialize DataMap: {e}"))) -} - -/// Store a `DataMap` as a chunk on the network (public mode). -/// -/// Serializes the `DataMap` and uploads it as a regular content-addressed chunk. -/// Returns the address (BLAKE3 hash) of the stored `DataMap` chunk. -/// -/// # Errors -/// -/// Returns an error if serialization or upload fails. -pub async fn store_data_map_public( - data_map: &DataMap, - client: &QuantumClient, -) -> Result<(ChunkAddress, Vec)> { - let data_map_bytes = serialize_data_map(data_map)?; - let content = Bytes::from(data_map_bytes); - let (address, tx_hashes) = client.put_chunk_with_payment(content).await?; - let tx_strs: Vec = tx_hashes.iter().map(|tx| format!("{tx:?}")).collect(); - let address_hex = hex::encode(address); - info!("DataMap stored publicly at {address_hex}"); - Ok((address, tx_strs)) -} - -/// Retrieve a `DataMap` from the network (public mode). -/// -/// Fetches the `DataMap` chunk by address and deserializes it. -/// -/// # Errors -/// -/// Returns an error if the chunk is not found or deserialization fails. -pub async fn fetch_data_map_public( - address: &ChunkAddress, - client: &QuantumClient, -) -> Result { - let chunk = client.get_chunk(address).await?.ok_or_else(|| { - Error::Storage(format!( - "DataMap chunk not found at {}", - hex::encode(address) - )) - })?; - deserialize_data_map(&chunk.content) -} - -/// Encrypt a file to a local chunk store (no network). Useful for testing. -/// -/// Returns the `DataMap` and a `HashMap` of `XorName` -> `Bytes` containing -/// all encrypted chunks. -/// -/// # Errors -/// -/// Returns an error if the file cannot be read or encryption fails. -pub fn encrypt_file_local(file_path: &Path) -> Result<(DataMap, HashMap)> { - let file_size: usize = std::fs::metadata(file_path) - .map_err(Error::Io)? - .len() - .try_into() - .map_err(|_| Error::Crypto("File too large for this platform".into()))?; - let (data_map, chunk_list) = encrypt_file_to_chunks(file_path, file_size)?; - let store: HashMap = chunk_list.into_iter().collect(); - Ok((data_map, store)) -} - -/// Decrypt a file from a local chunk store (no network). Useful for testing. -/// -/// # Errors -/// -/// Returns an error if any chunk is missing or decryption fails. -pub fn decrypt_file_local( - data_map: &DataMap, - chunk_store: &HashMap, - output_path: &Path, -) -> Result<()> { - decrypt_from_store(data_map, chunk_store, output_path) -} - -/// Shared helper: decrypt a `DataMap` using a chunk store `HashMap`. -fn decrypt_from_store( - data_map: &DataMap, - chunk_store: &HashMap, - output_path: &Path, -) -> Result<()> { - let stream = self_encryption::streaming_decrypt(data_map, |batch: &[(usize, XorName)]| { - let mut results = Vec::with_capacity(batch.len()); - for &(idx, ref hash) in batch { - let content = chunk_store.get(hash).ok_or_else(|| { - self_encryption::Error::Generic(format!("Chunk not found: {hash:?}")) - })?; - results.push((idx, content.clone())); - } - Ok(results) - }) - .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - - write_stream_to_file(stream, output_path) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use crate::client::data_types::compute_address; - use std::io::Write; - - fn create_temp_file(content: &[u8]) -> tempfile::NamedTempFile { - let mut file = tempfile::NamedTempFile::new().unwrap(); - file.write_all(content).unwrap(); - file.flush().unwrap(); - file - } - - #[test] - fn test_encrypt_decrypt_roundtrip_small() { - let original = vec![0xABu8; 4096]; - let input_file = create_temp_file(&original); - - let (data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - assert!( - !data_map.chunk_identifiers.is_empty(), - "DataMap should have chunk identifiers" - ); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - decrypt_file_local(&data_map, &store, output_file.path()).unwrap(); - - let decrypted = std::fs::read(output_file.path()).unwrap(); - assert_eq!(decrypted, original); - } - - #[test] - fn test_encrypt_decrypt_roundtrip_medium() { - let original: Vec = (0u8..=255).cycle().take(1_048_576).collect(); - let input_file = create_temp_file(&original); - - let (data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - decrypt_file_local(&data_map, &store, output_file.path()).unwrap(); - - let decrypted = std::fs::read(output_file.path()).unwrap(); - assert_eq!(decrypted, original); - } - - #[test] - fn test_encrypt_produces_encrypted_output() { - let original = b"This is a known plaintext pattern for testing encryption"; - let mut data = Vec::new(); - for _ in 0..100 { - data.extend_from_slice(original); - } - let input_file = create_temp_file(&data); - - let (_data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - let plaintext_str = "This is a known plaintext pattern"; - for content in store.values() { - let chunk_str = String::from_utf8_lossy(content); - assert!( - !chunk_str.contains(plaintext_str), - "Encrypted chunk should not contain plaintext" - ); - } - } - - #[test] - fn test_data_map_serialization_roundtrip() { - let original = vec![0xCDu8; 8192]; - let input_file = create_temp_file(&original); - - let (data_map, _store) = encrypt_file_local(input_file.path()).unwrap(); - - let serialized = serialize_data_map(&data_map).unwrap(); - let deserialized = deserialize_data_map(&serialized).unwrap(); - - assert_eq!( - data_map.chunk_identifiers.len(), - deserialized.chunk_identifiers.len() - ); - assert_eq!(data_map.child, deserialized.child); - } - - #[test] - fn test_data_map_contains_correct_chunk_count() { - let original = vec![0xEFu8; 1_048_576]; - let input_file = create_temp_file(&original); - - let (data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - assert!( - data_map.chunk_identifiers.len() >= 3, - "Should have at least 3 chunk identifiers, got {}", - data_map.chunk_identifiers.len() - ); - - for info in &data_map.chunk_identifiers { - assert!( - store.contains_key(&info.dst_hash), - "Chunk store should contain chunk referenced by DataMap" - ); - } - } - - #[test] - fn test_encrypted_chunks_have_valid_addresses() { - let original = vec![0x42u8; 8192]; - let input_file = create_temp_file(&original); - - let (data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - for info in &data_map.chunk_identifiers { - let content = store.get(&info.dst_hash).expect("Chunk should exist"); - let computed = compute_address(content); - assert_eq!( - computed, info.dst_hash.0, - "BLAKE3(encrypted_content) should equal dst_hash" - ); - } - } - - #[test] - fn test_decryption_fails_without_correct_data_map() { - let original = vec![0x11u8; 8192]; - let input_file = create_temp_file(&original); - let (_data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - let other = vec![0x22u8; 8192]; - let other_file = create_temp_file(&other); - let (wrong_data_map, _) = encrypt_file_local(other_file.path()).unwrap(); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - let result = decrypt_file_local(&wrong_data_map, &store, output_file.path()); - - assert!(result.is_err(), "Decryption with wrong DataMap should fail"); - } - - #[test] - fn test_cannot_recover_data_from_chunks_alone() { - let original = vec![0x33u8; 8192]; - let input_file = create_temp_file(&original); - let (_data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - let mut concatenated = Vec::new(); - for content in store.values() { - concatenated.extend_from_slice(content); - } - - assert_ne!( - concatenated, original, - "Concatenated chunks should not match original data" - ); - } - - #[test] - fn test_chunks_do_not_contain_plaintext_patterns() { - let pattern = b"SENTINEL_PATTERN_12345"; - let mut data = Vec::with_capacity(pattern.len() * 500); - for _ in 0..500 { - data.extend_from_slice(pattern); - } - let input_file = create_temp_file(&data); - - let (_data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - - for content in store.values() { - let found = content - .windows(pattern.len()) - .any(|window| window == pattern); - assert!( - !found, - "Encrypted chunks must not contain plaintext patterns" - ); - } - } - - #[test] - fn test_missing_chunk_fails_decryption() { - let original = vec![0x44u8; 8192]; - let input_file = create_temp_file(&original); - let (data_map, mut store) = encrypt_file_local(input_file.path()).unwrap(); - - if let Some(info) = data_map.chunk_identifiers.first() { - store.remove(&info.dst_hash); - } - - let output_file = tempfile::NamedTempFile::new().unwrap(); - let result = decrypt_file_local(&data_map, &store, output_file.path()); - - assert!( - result.is_err(), - "Decryption should fail with a missing chunk" - ); - } - - #[test] - fn test_tampered_chunk_detected() { - let original = vec![0x55u8; 8192]; - let input_file = create_temp_file(&original); - let (data_map, mut store) = encrypt_file_local(input_file.path()).unwrap(); - - if let Some(info) = data_map.chunk_identifiers.first() { - if let Some(content) = store.get_mut(&info.dst_hash) { - let mut tampered = content.to_vec(); - if let Some(byte) = tampered.first_mut() { - *byte ^= 0xFF; - } - *content = Bytes::from(tampered); - } - } - - let output_file = tempfile::NamedTempFile::new().unwrap(); - let result = decrypt_file_local(&data_map, &store, output_file.path()); - - assert!( - result.is_err(), - "Decryption should fail with tampered chunk" - ); - } - - #[test] - fn test_wrong_data_map_fails_decryption() { - let original_a = vec![0x66u8; 8192]; - let file_a = create_temp_file(&original_a); - let (data_map_a, _store_a) = encrypt_file_local(file_a.path()).unwrap(); - - let original_b = vec![0x77u8; 8192]; - let file_b = create_temp_file(&original_b); - let (_data_map_b, store_b) = encrypt_file_local(file_b.path()).unwrap(); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - let result = decrypt_file_local(&data_map_a, &store_b, output_file.path()); - - assert!( - result.is_err(), - "Decryption with mismatched DataMap should fail" - ); - } - - #[test] - #[ignore = "Requires ugly_files/kad.pdf to be present"] - fn test_encrypt_decrypt_pdf() { - let pdf_path = Path::new("ugly_files/kad.pdf"); - if !pdf_path.exists() { - return; - } - let original = std::fs::read(pdf_path).unwrap(); - - let (data_map, store) = encrypt_file_local(pdf_path).unwrap(); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - decrypt_file_local(&data_map, &store, output_file.path()).unwrap(); - - let decrypted = std::fs::read(output_file.path()).unwrap(); - assert_eq!(decrypted, original); - } - - #[test] - #[ignore = "Requires ugly_files/pylon.mp4 to be present"] - fn test_encrypt_decrypt_video() { - let video_path = Path::new("ugly_files/pylon.mp4"); - if !video_path.exists() { - return; - } - let original = std::fs::read(video_path).unwrap(); - - let (data_map, store) = encrypt_file_local(video_path).unwrap(); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - decrypt_file_local(&data_map, &store, output_file.path()).unwrap(); - - let decrypted = std::fs::read(output_file.path()).unwrap(); - assert_eq!(decrypted, original); - } - - #[test] - fn test_file_below_min_encryptable_bytes_fails() { - // Files smaller than MIN_ENCRYPTABLE_BYTES (3 bytes) should fail - let tiny = create_temp_file(&[0xAA, 0xBB]); - let result = encrypt_file_local(tiny.path()); - assert!( - result.is_err(), - "Encryption of 2-byte file should fail (below MIN_ENCRYPTABLE_BYTES)" - ); - } - - #[test] - fn test_encrypt_at_minimum_size() { - // Exactly MIN_ENCRYPTABLE_BYTES = 3 bytes should succeed - let data = vec![0xAAu8; 3]; - let input_file = create_temp_file(&data); - - let (data_map, store) = encrypt_file_local(input_file.path()).unwrap(); - assert!( - !data_map.chunk_identifiers.is_empty(), - "DataMap should have chunk identifiers for 3-byte file" - ); - - let output_file = tempfile::NamedTempFile::new().unwrap(); - decrypt_file_local(&data_map, &store, output_file.path()).unwrap(); - - let decrypted = std::fs::read(output_file.path()).unwrap(); - assert_eq!(decrypted, data); - } - - #[test] - fn test_deterministic_encryption() { - // Same content should produce the same DataMap and chunks - let data = vec![0xDDu8; 8192]; - let file_a = create_temp_file(&data); - let file_b = create_temp_file(&data); - - let (data_map_a, store_a) = encrypt_file_local(file_a.path()).unwrap(); - let (data_map_b, store_b) = encrypt_file_local(file_b.path()).unwrap(); - - assert_eq!( - data_map_a.chunk_identifiers.len(), - data_map_b.chunk_identifiers.len(), - "Same content should produce same number of chunks" - ); - - for (a, b) in data_map_a - .chunk_identifiers - .iter() - .zip(data_map_b.chunk_identifiers.iter()) - { - assert_eq!(a.dst_hash, b.dst_hash, "Chunk addresses should match"); - } - - // Verify stores have the same keys - for key in store_a.keys() { - assert!( - store_b.contains_key(key), - "Both stores should contain the same chunk addresses" - ); - } - } - - #[test] - fn test_empty_file_fails() { - let empty = create_temp_file(&[]); - let result = encrypt_file_local(empty.path()); - assert!( - result.is_err(), - "Encryption of empty file should fail (below MIN_ENCRYPTABLE_BYTES)" - ); - } -} diff --git a/src/config.rs b/src/config.rs index 008d54ba..8105e522 100644 --- a/src/config.rs +++ b/src/config.rs @@ -193,21 +193,11 @@ pub enum EvmNetworkConfig { /// Payment verification configuration. /// -/// **Production nodes require payment by default.** -/// -/// All new data requires EVM payment on Arbitrum. The cache stores -/// previously verified payments to avoid redundant lookups. -/// -/// To disable payment verification (test/dev only): -/// - Use CLI flag: `--disable-payment-verification` -/// - Or set `enabled = false` in config file +/// All new data requires EVM payment on Arbitrum — there is no way to +/// disable payment verification. The cache stores previously verified +/// payments to avoid redundant on-chain lookups. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PaymentConfig { - /// Enable payment verification. - /// **Default: true (payment required).** - #[serde(default = "default_payment_enabled")] - pub enabled: bool, - /// Cache capacity for verified `XorNames`. #[serde(default = "default_cache_capacity")] pub cache_capacity: usize, @@ -230,7 +220,6 @@ pub struct PaymentConfig { impl Default for PaymentConfig { fn default() -> Self { Self { - enabled: default_payment_enabled(), cache_capacity: default_cache_capacity(), rewards_address: None, evm_network: EvmNetworkConfig::default(), @@ -243,10 +232,6 @@ const fn default_metrics_port() -> u16 { 9100 } -const fn default_payment_enabled() -> bool { - true -} - const fn default_cache_capacity() -> usize { 100_000 } @@ -511,18 +496,15 @@ mod tests { use super::*; #[test] - fn test_default_config_requires_payment() { + fn test_default_config_has_cache_capacity() { let config = PaymentConfig::default(); - assert!(config.enabled, "Payment must be enabled by default"); + assert!(config.cache_capacity > 0, "Cache capacity must be positive"); } #[test] - fn test_default_evm_verifier_enabled() { + fn test_default_evm_network() { use crate::payment::EvmVerifierConfig; - let config = EvmVerifierConfig::default(); - assert!( - config.enabled, - "EVM verification must be enabled by default" - ); + let _config = EvmVerifierConfig::default(); + // EVM verification is always on — no enabled field } } diff --git a/src/devnet.rs b/src/devnet.rs index e8a6f0da..cf63085d 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -164,13 +164,9 @@ pub struct DevnetConfig { /// Whether to remove the data directory on shutdown. pub cleanup_data_dir: bool, - /// Enable EVM payment enforcement on all nodes. - /// When true, nodes will require valid on-chain payment proofs. - pub enable_evm: bool, - /// Optional EVM network for payment verification. - /// When `enable_evm` is true and this is `Some`, nodes will use - /// this network (e.g. Anvil testnet) for on-chain verification. + /// When `Some`, nodes will use this network (e.g. Anvil testnet) for + /// on-chain verification. Defaults to Arbitrum One when `None`. pub evm_network: Option, } @@ -192,7 +188,6 @@ impl Default for DevnetConfig { node_startup_timeout: Duration::from_secs(DEFAULT_NODE_STARTUP_TIMEOUT_SECS), enable_node_logging: false, cleanup_data_dir: true, - enable_evm: false, evm_network: None, } } @@ -578,29 +573,20 @@ impl Devnet { .await .map_err(|e| DevnetError::Core(format!("Failed to create LMDB storage: {e}")))?; - let evm_config = if config.enable_evm { - EvmVerifierConfig { - enabled: true, - network: config - .evm_network - .clone() - .unwrap_or(EvmNetwork::ArbitrumOne), - } - } else { - EvmVerifierConfig { - enabled: false, - ..Default::default() - } + let evm_config = EvmVerifierConfig { + network: config + .evm_network + .clone() + .unwrap_or(EvmNetwork::ArbitrumOne), }; + let rewards_address = RewardsAddress::new(DEVNET_REWARDS_ADDRESS); let payment_config = PaymentVerifierConfig { evm: evm_config, cache_capacity: DEVNET_PAYMENT_CACHE_CAPACITY, - local_rewards_address: None, + local_rewards_address: rewards_address, }; let payment_verifier = PaymentVerifier::new(payment_config); - - let rewards_address = RewardsAddress::new(DEVNET_REWARDS_ADDRESS); let metrics_tracker = QuotingMetricsTracker::new(DEVNET_MAX_RECORDS, DEVNET_INITIAL_RECORDS); let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); @@ -635,7 +621,9 @@ impl Devnet { .map_err(|e| DevnetError::Core(format!("Failed to load node identity: {e}")))?; core_config.node_identity = Some(Arc::new(identity)); - core_config.bootstrap_peers = node.bootstrap_addrs.clone(); + core_config + .bootstrap_peers + .clone_from(&node.bootstrap_addrs); core_config.diversity_config = Some(IPDiversityConfig::permissive()); let index = node.index; diff --git a/src/lib.rs b/src/lib.rs index 42560d40..dde9ec4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,12 +54,8 @@ pub use ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, CHUNK_PROTOCOL_ID, MAX_CHUNK_SIZE, }; -pub use client::self_encrypt::{ - deserialize_data_map, download_and_decrypt_file, encrypt_and_upload_file, - fetch_data_map_public, serialize_data_map, store_data_map_public, -}; pub use client::{ - compute_address, peer_id_to_xor_name, xor_distance, DataChunk, QuantumClient, QuantumConfig, + compute_address, hex_node_id_to_encoded_peer_id, peer_id_to_xor_name, xor_distance, DataChunk, XorName, }; pub use config::{BootstrapCacheConfig, NodeConfig, StorageConfig}; @@ -69,3 +65,13 @@ pub use event::{NodeEvent, NodeEventsChannel}; pub use node::{NodeBuilder, RunningNode}; pub use payment::{PaymentStatus, PaymentVerifier, PaymentVerifierConfig}; pub use storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; + +/// Re-exports from `saorsa-core` so downstream crates (e.g. `saorsa-client`) +/// can depend on `saorsa-node` alone without a direct `saorsa-core` dependency. +pub mod core { + pub use saorsa_core::identity::{NodeIdentity, PeerId}; + pub use saorsa_core::{ + IPDiversityConfig, MlDsa65, MultiAddr, NodeConfig as CoreNodeConfig, NodeMode, P2PEvent, + P2PNode, + }; +} diff --git a/src/node.rs b/src/node.rs index 552e03c9..8cefec07 100644 --- a/src/node.rs +++ b/src/node.rs @@ -12,7 +12,7 @@ use crate::payment::wallet::parse_rewards_address; use crate::payment::{EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator}; use crate::storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; use crate::upgrade::{AutoApplyUpgrader, UpgradeMonitor, UpgradeResult}; -use ant_evm::RewardsAddress; + use evmlib::Network as EvmNetwork; use saorsa_core::identity::NodeIdentity; use saorsa_core::{ @@ -35,9 +35,6 @@ use tracing::{debug, error, info, warn}; /// fullness ratio instead of a hardcoded constant. pub const NODE_STORAGE_LIMIT_BYTES: u64 = 5 * 1024 * 1024 * 1024; -/// Default rewards address when none is configured (20-byte zero address). -const DEFAULT_REWARDS_ADDRESS: [u8; 20] = [0u8; 20]; - #[cfg(unix)] use tokio::signal::unix::{signal, SignalKind}; @@ -61,15 +58,6 @@ impl NodeBuilder { pub async fn build(mut self) -> Result { info!("Building saorsa-node with config: {:?}", self.config); - // Validate production requirements - if self.config.network_mode == NetworkMode::Production && !self.config.payment.enabled { - return Err(Error::Config( - "CRITICAL: Payment verification is REQUIRED in production mode. \ - Remove 'enabled = false' from config or --disable-payment-verification flag." - .to_string(), - )); - } - // Validate rewards address in production if self.config.network_mode == NetworkMode::Production { match self.config.payment.rewards_address { @@ -91,16 +79,6 @@ impl NodeBuilder { } } - // Warn if payment disabled in any mode - if !self.config.payment.enabled { - let mode = self.config.network_mode; - warn!("⚠️ ⚠️ ⚠️"); - warn!("⚠️ PAYMENT VERIFICATION DISABLED (mode: {mode:?})"); - warn!("⚠️ This should ONLY be used for testing!"); - warn!("⚠️ All storage requests will be accepted for FREE"); - warn!("⚠️ ⚠️ ⚠️"); - } - // Resolve identity and root_dir (may update self.config.root_dir) let identity = Arc::new(Self::resolve_identity(&mut self.config).await?); let peer_id = identity.peer_id().to_hex(); @@ -354,10 +332,14 @@ impl NodeBuilder { .await .map_err(|e| Error::Startup(format!("Failed to create LMDB storage: {e}")))?; - // Parse rewards address first (needed by both verifier and quote generator) + // Parse rewards address (required — node must know where to receive payments) let rewards_address = match config.payment.rewards_address { Some(ref addr) => parse_rewards_address(addr)?, - None => RewardsAddress::new(DEFAULT_REWARDS_ADDRESS), + None => { + return Err(Error::Startup( + "No rewards address configured. Set --rewards-address or payment.rewards_address in config.".to_string(), + )); + } }; // Create payment verifier @@ -367,11 +349,10 @@ impl NodeBuilder { }; let payment_config = PaymentVerifierConfig { evm: EvmVerifierConfig { - enabled: config.payment.enabled, network: evm_network, }, cache_capacity: config.payment.cache_capacity, - local_rewards_address: Some(rewards_address), + local_rewards_address: rewards_address, }; let payment_verifier = PaymentVerifier::new(payment_config); // Safe: 5GB fits in usize on all supported 64-bit platforms. @@ -650,8 +631,14 @@ impl RunningNode { data, } = event { - if topic == CHUNK_PROTOCOL_ID { - debug!("Received chunk protocol message from {source}"); + let handler_info: Option<(&str, &str)> = if topic == CHUNK_PROTOCOL_ID { + Some(("chunk", CHUNK_PROTOCOL_ID)) + } else { + None + }; + + if let Some((data_type, response_topic)) = handler_info { + debug!("Received {data_type} protocol message from {source}"); let protocol = Arc::clone(&protocol); let p2p = Arc::clone(&p2p); let sem = semaphore.clone(); @@ -659,17 +646,21 @@ impl RunningNode { let Ok(_permit) = sem.acquire().await else { return; }; - match protocol.handle_message(&data).await { + let result = match data_type { + "chunk" => protocol.handle_message(&data).await, + _ => return, + }; + match result { Ok(response) => { if let Err(e) = p2p - .send_message(&source, CHUNK_PROTOCOL_ID, response.to_vec()) + .send_message(&source, response_topic, response.to_vec()) .await { - warn!("Failed to send protocol response to {source}: {e}"); + warn!("Failed to send {data_type} protocol response to {source}: {e}"); } } Err(e) => { - warn!("Protocol handler error: {e}"); + warn!("{data_type} protocol handler error: {e}"); } } }); diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 312ec39f..9726215b 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -8,10 +8,8 @@ //! //! **Production nodes require payment by default.** //! -//! - `PaymentVerifierConfig::default()` has `evm.enabled = true` -//! - `PaymentConfig::default()` has `enabled = true` -//! - Test environments can disable via CLI flag `--disable-payment-verification` -//! - Test utilities explicitly disable EVM verification for unit tests +//! - EVM verification is always on — there is no way to disable it +//! - For unit tests, pre-populate the cache via `PaymentVerifier::cache_insert()` //! //! # Architecture //! diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 910599f5..f02ebde6 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -38,19 +38,19 @@ const QUOTE_MAX_AGE_SECS: u64 = 86_400; const QUOTE_CLOCK_SKEW_TOLERANCE_SECS: u64 = 60; /// Configuration for EVM payment verification. +/// +/// EVM verification is always on. All new data requires on-chain +/// payment verification. The network field selects which EVM chain to use. #[derive(Debug, Clone)] pub struct EvmVerifierConfig { /// EVM network to use (Arbitrum One, Arbitrum Sepolia, etc.) pub network: EvmNetwork, - /// Whether EVM verification is enabled. - pub enabled: bool, } impl Default for EvmVerifierConfig { fn default() -> Self { Self { network: EvmNetwork::ArbitrumOne, - enabled: true, } } } @@ -66,18 +66,8 @@ pub struct PaymentVerifierConfig { /// Cache capacity (number of `XorName` values to cache). pub cache_capacity: usize, /// Local node's rewards address. - /// When set, the verifier rejects payments that don't include this node as a recipient. - pub local_rewards_address: Option, -} - -impl Default for PaymentVerifierConfig { - fn default() -> Self { - Self { - evm: EvmVerifierConfig::default(), - cache_capacity: 100_000, - local_rewards_address: None, - } - } + /// The verifier rejects payments that don't include this node as a recipient. + pub local_rewards_address: RewardsAddress, } /// Status returned by payment verification. @@ -124,8 +114,7 @@ impl PaymentVerifier { let cache = VerifiedCache::with_capacity(config.cache_capacity); let cache_capacity = config.cache_capacity; - let evm_enabled = config.evm.enabled; - info!("Payment verifier initialized (cache_capacity={cache_capacity}, evm_enabled={evm_enabled})"); + info!("Payment verifier initialized (cache_capacity={cache_capacity}, evm=always-on)"); Self { cache, config } } @@ -196,19 +185,7 @@ impl PaymentVerifier { Ok(status) } PaymentStatus::PaymentRequired => { - // Test/devnet mode: EVM disabled - accept with or without proof - if !self.config.evm.enabled { - if tracing::enabled!(tracing::Level::DEBUG) { - debug!( - "Test mode: Allowing storage without EVM verification (EVM disabled): {}", - hex::encode(xorname) - ); - } - self.cache.insert(*xorname); - return Ok(PaymentStatus::PaymentVerified); - } - - // Production mode: EVM enabled - verify the proof + // EVM verification is always on — verify the proof if let Some(proof) = payment_proof { let proof_len = proof.len(); if proof_len < MIN_PAYMENT_PROOF_SIZE_BYTES { @@ -264,15 +241,19 @@ impl PaymentVerifier { self.cache.len() } - /// Check if EVM verification is enabled. - #[must_use] - pub fn evm_enabled(&self) -> bool { - self.config.evm.enabled + /// Pre-populate the payment cache for a given address. + /// + /// This marks the address as already paid, so subsequent `verify_payment` + /// calls will return `CachedAsVerified` without on-chain verification. + /// Useful for test setups where real EVM payment is not needed. + #[cfg(any(test, feature = "test-utils"))] + pub fn cache_insert(&self, xorname: XorName) { + self.cache.insert(xorname); } /// Verify an EVM payment proof. /// - /// This is production-only verification that ALWAYS validates payment proofs. + /// This verification ALWAYS validates payment proofs on-chain. /// It verifies that: /// 1. All quotes target the correct content address (xorname binding) /// 2. All quote ML-DSA-65 signatures are valid (offloaded to a blocking @@ -280,8 +261,9 @@ impl PaymentVerifier { /// is CPU-intensive) /// 3. The payment was made on-chain via the EVM payment vault contract /// - /// Test environments should disable EVM at the `verify_payment` level, - /// not bypass verification here. + /// For unit tests that don't need on-chain verification, pre-populate + /// the cache so `verify_payment` returns `CachedAsVerified` before + /// reaching this method. async fn verify_evm_payment(&self, xorname: &XorName, payment: &ProofOfPayment) -> Result<()> { if tracing::enabled!(tracing::Level::DEBUG) { let xorname_hex = hex::encode(xorname); @@ -289,8 +271,6 @@ impl PaymentVerifier { debug!("Verifying EVM payment for {xorname_hex} with {quote_count} quotes"); } - debug_assert!(self.config.evm.enabled); - Self::validate_quote_structure(payment)?; Self::validate_quote_content(payment, xorname)?; Self::validate_quote_timestamps(payment)?; @@ -445,16 +425,15 @@ impl PaymentVerifier { /// Verify this node is among the paid recipients. fn validate_local_recipient(&self, payment: &ProofOfPayment) -> Result<()> { - if let Some(ref local_addr) = self.config.local_rewards_address { - let is_recipient = payment - .peer_quotes - .iter() - .any(|(_, quote)| quote.rewards_address == *local_addr); - if !is_recipient { - return Err(Error::Payment( - "Payment proof does not include this node as a recipient".to_string(), - )); - } + let local_addr = &self.config.local_rewards_address; + let is_recipient = payment + .peer_quotes + .iter() + .any(|(_, quote)| quote.rewards_address == *local_addr); + if !is_recipient { + return Err(Error::Payment( + "Payment proof does not include this node as a recipient".to_string(), + )); } Ok(()) } @@ -465,26 +444,13 @@ impl PaymentVerifier { mod tests { use super::*; + /// Create a verifier for unit tests. EVM is always on, but tests can + /// pre-populate the cache to bypass on-chain verification. fn create_test_verifier() -> PaymentVerifier { let config = PaymentVerifierConfig { - evm: EvmVerifierConfig { - enabled: false, // Disabled for tests - ..Default::default() - }, - cache_capacity: 100, - local_rewards_address: None, - }; - PaymentVerifier::new(config) - } - - fn create_evm_enabled_verifier() -> PaymentVerifier { - let config = PaymentVerifierConfig { - evm: EvmVerifierConfig { - enabled: true, - network: EvmNetwork::ArbitrumOne, - }, + evm: EvmVerifierConfig::default(), cache_capacity: 100, - local_rewards_address: None, + local_rewards_address: RewardsAddress::new([1u8; 20]), }; PaymentVerifier::new(config) } @@ -513,40 +479,15 @@ mod tests { } #[tokio::test] - async fn test_verify_payment_without_proof() { + async fn test_verify_payment_without_proof_rejected() { let verifier = create_test_verifier(); let xorname = [1u8; 32]; - // Test mode (EVM disabled): Should SUCCEED without payment proof - // This allows tests to run without needing real EVM payments + // No proof provided => should return an error (EVM is always on) let result = verifier.verify_payment(&xorname, None).await; - assert!(result.is_ok(), "Expected Ok in test mode, got: {result:?}"); - assert_eq!( - result.expect("should succeed"), - PaymentStatus::PaymentVerified - ); - } - - #[tokio::test] - async fn test_verify_payment_with_proof() { - let verifier = create_test_verifier(); - let xorname = [1u8; 32]; - - // Create a properly-sized proof - let proof = ProofOfPayment { - peer_quotes: vec![], - }; - let mut proof_bytes = rmp_serde::to_vec(&proof).expect("should serialize"); - // Pad to minimum required size to pass validation - proof_bytes.resize(MIN_PAYMENT_PROOF_SIZE_BYTES, 0); - - // EVM disabled (test/devnet mode): should SUCCEED even with a proof present. - // When EVM is disabled, the verifier skips on-chain checks and accepts storage. - let result = verifier.verify_payment(&xorname, Some(&proof_bytes)).await; - assert!(result.is_ok(), "Expected Ok in test mode, got: {result:?}"); - assert_eq!( - result.expect("should succeed"), - PaymentStatus::PaymentVerified + assert!( + result.is_err(), + "Expected Err without proof, got: {result:?}" ); } @@ -555,7 +496,7 @@ mod tests { let verifier = create_test_verifier(); let xorname = [1u8; 32]; - // Add to cache + // Add to cache — simulates previously-paid data verifier.cache.insert(xorname); // Should succeed without payment (cached) @@ -579,7 +520,7 @@ mod tests { } #[tokio::test] - async fn test_verifier_caches_after_successful_verification() { + async fn test_cache_preload_bypasses_evm() { let verifier = create_test_verifier(); let xorname = [42u8; 32]; @@ -589,10 +530,8 @@ mod tests { PaymentStatus::PaymentRequired ); - // Verify payment (EVM disabled, so it succeeds and caches) - let result = verifier.verify_payment(&xorname, None).await; - assert!(result.is_ok()); - assert_eq!(result.expect("verified"), PaymentStatus::PaymentVerified); + // Pre-populate cache (simulates a previous successful payment) + verifier.cache.insert(xorname); // Now the xorname should be cached assert_eq!( @@ -601,19 +540,9 @@ mod tests { ); } - #[tokio::test] - async fn test_verifier_rejects_without_proof_when_evm_enabled() { - let verifier = create_evm_enabled_verifier(); - let xorname = [99u8; 32]; - - // EVM enabled + no proof provided => should return an error - let result = verifier.verify_payment(&xorname, None).await; - assert!(result.is_err()); - } - #[tokio::test] async fn test_proof_too_small() { - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [1u8; 32]; // Proof smaller than MIN_PAYMENT_PROOF_SIZE_BYTES @@ -629,7 +558,7 @@ mod tests { #[tokio::test] async fn test_proof_too_large() { - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [2u8; 32]; // Proof larger than MAX_PAYMENT_PROOF_SIZE_BYTES @@ -645,7 +574,7 @@ mod tests { #[tokio::test] async fn test_proof_at_min_boundary() { - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [3u8; 32]; // Exactly MIN_PAYMENT_PROOF_SIZE_BYTES — passes size check, but @@ -664,7 +593,7 @@ mod tests { #[tokio::test] async fn test_proof_at_max_boundary() { - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [4u8; 32]; // Exactly MAX_PAYMENT_PROOF_SIZE_BYTES — passes size check, but @@ -683,7 +612,7 @@ mod tests { #[tokio::test] async fn test_malformed_msgpack_proof() { - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [5u8; 32]; // Valid size but garbage bytes — should fail deserialization @@ -694,15 +623,6 @@ mod tests { assert!(err_msg.contains("deserialize")); } - #[test] - fn test_evm_enabled_getter() { - let verifier = create_test_verifier(); - assert!(!verifier.evm_enabled()); - - let verifier = create_evm_enabled_verifier(); - assert!(verifier.evm_enabled()); - } - #[test] fn test_cache_len_getter() { let verifier = create_test_verifier(); @@ -736,10 +656,15 @@ mod tests { } #[tokio::test] - async fn test_concurrent_verify_payment() { + async fn test_concurrent_cache_lookups() { let verifier = std::sync::Arc::new(create_test_verifier()); - let mut handles = Vec::new(); + // Pre-populate cache for all 10 xornames + for i in 0..10u8 { + verifier.cache.insert([i; 32]); + } + + let mut handles = Vec::new(); for i in 0..10u8 { let v = verifier.clone(); handles.push(tokio::spawn(async move { @@ -751,23 +676,16 @@ mod tests { for handle in handles { let result = handle.await.expect("task panicked"); assert!(result.is_ok()); + assert_eq!(result.expect("cached"), PaymentStatus::CachedAsVerified); } - // All 10 should be cached assert_eq!(verifier.cache_len(), 10); } - #[test] - fn test_default_config() { - let config = PaymentVerifierConfig::default(); - assert!(config.evm.enabled); - assert_eq!(config.cache_capacity, 100_000); - } - #[test] fn test_default_evm_config() { - let config = EvmVerifierConfig::default(); - assert!(config.enabled); + let _config = EvmVerifierConfig::default(); + // EVM is always on — default network is ArbitrumOne } #[test] @@ -837,7 +755,7 @@ mod tests { use libp2p::PeerId; use std::time::SystemTime; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); // The xorname we're trying to store let target_xorname = [0xAAu8; 32]; @@ -937,7 +855,7 @@ mod tests { use ant_evm::{EncodedPeerId, RewardsAddress}; use std::time::Duration; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [0xCCu8; 32]; let rewards_addr = RewardsAddress::new([1u8; 20]); @@ -968,7 +886,7 @@ mod tests { use ant_evm::{EncodedPeerId, RewardsAddress}; use std::time::Duration; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [0xDDu8; 32]; let rewards_addr = RewardsAddress::new([1u8; 20]); @@ -999,7 +917,7 @@ mod tests { use ant_evm::{EncodedPeerId, RewardsAddress}; use std::time::Duration; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [0xD1u8; 32]; let rewards_addr = RewardsAddress::new([1u8; 20]); @@ -1030,7 +948,7 @@ mod tests { use ant_evm::{EncodedPeerId, RewardsAddress}; use std::time::Duration; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [0xD2u8; 32]; let rewards_addr = RewardsAddress::new([1u8; 20]); @@ -1064,7 +982,7 @@ mod tests { use ant_evm::{EncodedPeerId, RewardsAddress}; use std::time::Duration; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [0xD3u8; 32]; let rewards_addr = RewardsAddress::new([1u8; 20]); @@ -1115,11 +1033,10 @@ mod tests { let local_addr = RewardsAddress::new([0xAAu8; 20]); let config = PaymentVerifierConfig { evm: EvmVerifierConfig { - enabled: true, network: EvmNetwork::ArbitrumOne, }, cache_capacity: 100, - local_rewards_address: Some(local_addr), + local_rewards_address: local_addr, }; let verifier = PaymentVerifier::new(config); @@ -1158,7 +1075,7 @@ mod tests { use saorsa_core::MlDsa65; use saorsa_pqc::pqc::MlDsaOperations; - let verifier = create_evm_enabled_verifier(); + let verifier = create_test_verifier(); let xorname = [0xFFu8; 32]; let rewards_addr = RewardsAddress::new([1u8; 20]); diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 936f2064..ecfdc3ac 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -231,6 +231,20 @@ impl AntProtocol { let data_size = request.data_size; debug!("Handling quote request for {addr_hex} (size: {data_size})"); + // Check if the chunk is already stored so we can tell the client + // to skip payment (already_stored = true). + let already_stored = match self.storage.exists(&request.address) { + Ok(exists) => exists, + Err(e) => { + warn!("Storage check failed for {addr_hex}: {e}"); + false // Assume not stored on error — generate a normal quote. + } + }; + + if already_stored { + debug!("Chunk {addr_hex} already stored — returning quote with already_stored=true"); + } + // Validate data size - data_size is u64, cast carefully and reject overflow let Ok(data_size_usize) = usize::try_from(request.data_size) else { return ChunkQuoteResponse::Error(ProtocolError::ChunkTooLarge { @@ -252,7 +266,10 @@ impl AntProtocol { Ok(quote) => { // Serialize the quote match rmp_serde::to_vec("e) { - Ok(quote_bytes) => ChunkQuoteResponse::Success { quote: quote_bytes }, + Ok(quote_bytes) => ChunkQuoteResponse::Success { + quote: quote_bytes, + already_stored, + }, Err(e) => ChunkQuoteResponse::Error(ProtocolError::QuoteFailed(format!( "Failed to serialize quote: {e}" ))), @@ -274,6 +291,17 @@ impl AntProtocol { self.payment_verifier.cache_stats() } + /// Get a reference to the payment verifier. + /// + /// Exposed for **test harnesses only** — production code should not call + /// this directly. Use `cache_insert()` on the returned verifier to + /// pre-populate the payment cache in test setups. + #[cfg(any(test, feature = "test-utils"))] + #[must_use] + pub fn payment_verifier(&self) -> &PaymentVerifier { + &self.payment_verifier + } + /// Check if a chunk exists locally. /// /// # Errors @@ -313,6 +341,9 @@ mod tests { use crate::payment::{EvmVerifierConfig, PaymentVerifierConfig}; use crate::storage::LmdbStorageConfig; use ant_evm::RewardsAddress; + use saorsa_core::identity::NodeIdentity; + use saorsa_core::MlDsa65; + use saorsa_pqc::pqc::types::MlDsaSecretKey; use tempfile::TempDir; async fn create_test_protocol() -> (AntProtocol, TempDir) { @@ -330,21 +361,30 @@ mod tests { .expect("create storage"), ); + let rewards_address = RewardsAddress::new([1u8; 20]); let payment_config = PaymentVerifierConfig { - evm: EvmVerifierConfig { - enabled: false, // Disable EVM for tests - ..Default::default() - }, - cache_capacity: 100, - local_rewards_address: None, + evm: EvmVerifierConfig::default(), + cache_capacity: 100_000, + local_rewards_address: rewards_address, }; let payment_verifier = Arc::new(PaymentVerifier::new(payment_config)); - - let rewards_address = RewardsAddress::new([1u8; 20]); let metrics_tracker = QuotingMetricsTracker::new(1000, 100); - let quote_generator = Arc::new(QuoteGenerator::new(rewards_address, metrics_tracker)); - - let protocol = AntProtocol::new(storage, payment_verifier, quote_generator); + let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); + + // Wire ML-DSA-65 signing so quote requests succeed + let identity = NodeIdentity::generate().expect("generate identity"); + let pub_key_bytes = identity.public_key().as_bytes().to_vec(); + let sk_bytes = identity.secret_key_bytes().to_vec(); + let sk = MlDsaSecretKey::from_bytes(&sk_bytes).expect("deserialize secret key"); + quote_generator.set_signer(pub_key_bytes, move |msg| { + use saorsa_pqc::pqc::MlDsaOperations; + let ml_dsa = MlDsa65::new(); + ml_dsa + .sign(&sk, msg) + .map_or_else(|_| vec![], |sig| sig.as_bytes().to_vec()) + }); + + let protocol = AntProtocol::new(storage, payment_verifier, Arc::new(quote_generator)); (protocol, temp_dir) } @@ -355,7 +395,9 @@ mod tests { let content = b"hello world"; let address = LmdbStorage::compute_address(content); - // Create PUT request - no payment proof needed (EVM disabled in test) + // Pre-populate payment cache so EVM verification is bypassed + protocol.payment_verifier().cache_insert(address); + let put_request = ChunkPutRequest::new(address, content.to_vec()); let put_msg = ChunkMessage { request_id: 1, @@ -442,7 +484,9 @@ mod tests { let content = b"test content"; let wrong_address = [0xFF; 32]; // Wrong address - // No payment proof needed (EVM disabled in test) + // Pre-populate cache for the wrong address so we test address mismatch, not payment + protocol.payment_verifier().cache_insert(wrong_address); + let put_request = ChunkPutRequest::new(wrong_address, content.to_vec()); let put_msg = ChunkMessage { request_id: 20, @@ -506,7 +550,9 @@ mod tests { let content = b"duplicate content"; let address = LmdbStorage::compute_address(content); - // Store first time - no payment proof needed (EVM disabled in test) + // Pre-populate cache so EVM verification is bypassed + protocol.payment_verifier().cache_insert(address); + let put_request = ChunkPutRequest::new(address, content.to_vec()); let put_msg = ChunkMessage { request_id: 40, @@ -563,17 +609,24 @@ mod tests { } #[tokio::test] - async fn test_put_populates_payment_cache() { + async fn test_cache_insert_is_visible() { let (protocol, _temp) = create_test_protocol().await; let content = b"cache test content"; let address = LmdbStorage::compute_address(content); - // Before PUT: cache should be empty + // Before insert: cache should be empty let stats_before = protocol.payment_cache_stats(); assert_eq!(stats_before.additions, 0); - // PUT (EVM disabled — verifier will auto-accept and cache) + // Pre-populate cache + protocol.payment_verifier().cache_insert(address); + + // After insert: cache should have the xorname + let stats_after = protocol.payment_cache_stats(); + assert_eq!(stats_after.additions, 1); + + // PUT should succeed (cache hit) let put_request = ChunkPutRequest::new(address, content.to_vec()); let put_msg = ChunkMessage { request_id: 100, @@ -591,10 +644,6 @@ mod tests { } else { panic!("expected success, got: {response:?}"); } - - // After PUT: cache should have the xorname - let stats_after = protocol.payment_cache_stats(); - assert_eq!(stats_after.additions, 1); } #[tokio::test] @@ -604,6 +653,9 @@ mod tests { let content = b"duplicate cache test"; let address = LmdbStorage::compute_address(content); + // Pre-populate cache for first PUT + protocol.payment_verifier().cache_insert(address); + // First PUT let put_request = ChunkPutRequest::new(address, content.to_vec()); let put_msg = ChunkMessage { @@ -640,9 +692,11 @@ mod tests { assert_eq!(stats.misses, 0); assert_eq!(stats.additions, 0); - // Store a chunk to trigger payment verification + // Pre-populate cache, then store a chunk to test stats let content = b"stats test"; let address = LmdbStorage::compute_address(content); + protocol.payment_verifier().cache_insert(address); + let put_request = ChunkPutRequest::new(address, content.to_vec()); let put_msg = ChunkMessage { request_id: 120, @@ -655,9 +709,9 @@ mod tests { .expect("handle put"); let stats = protocol.payment_cache_stats(); - // Should have 1 miss (first lookup) + 1 addition (after verify) - assert_eq!(stats.misses, 1); + // Should have 1 addition (from cache_insert) + 1 hit (payment verification found cache) assert_eq!(stats.additions, 1); + assert_eq!(stats.hits, 1); } #[tokio::test] @@ -693,4 +747,84 @@ mod tests { panic!("expected Internal error, got: {response:?}"); } } + + #[tokio::test] + async fn test_quote_already_stored_flag() { + let (protocol, _temp) = create_test_protocol().await; + + let content = b"already stored quote test"; + let address = LmdbStorage::compute_address(content); + + // Store the chunk first + protocol.payment_verifier().cache_insert(address); + let put_request = ChunkPutRequest::new(address, content.to_vec()); + let put_msg = ChunkMessage { + request_id: 300, + body: ChunkMessageBody::PutRequest(put_request), + }; + let put_bytes = put_msg.encode().expect("encode put"); + let _ = protocol + .handle_message(&put_bytes) + .await + .expect("handle put"); + + // Now request a quote for the same address — already_stored should be true + let quote_request = ChunkQuoteRequest { + address, + data_size: content.len() as u64, + data_type: DATA_TYPE_CHUNK, + }; + let quote_msg = ChunkMessage { + request_id: 301, + body: ChunkMessageBody::QuoteRequest(quote_request), + }; + let quote_bytes = quote_msg.encode().expect("encode quote"); + let response_bytes = protocol + .handle_message("e_bytes) + .await + .expect("handle quote"); + let response = ChunkMessage::decode(&response_bytes).expect("decode"); + + match response.body { + ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success { + already_stored, .. + }) => { + assert!( + already_stored, + "already_stored should be true for existing chunk" + ); + } + other => panic!("expected Success with already_stored, got: {other:?}"), + } + + // Request a quote for a chunk that does NOT exist — already_stored should be false + let new_address = [0xFFu8; 32]; + let quote_request2 = ChunkQuoteRequest { + address: new_address, + data_size: 100, + data_type: DATA_TYPE_CHUNK, + }; + let quote_msg2 = ChunkMessage { + request_id: 302, + body: ChunkMessageBody::QuoteRequest(quote_request2), + }; + let quote_bytes2 = quote_msg2.encode().expect("encode quote2"); + let response_bytes2 = protocol + .handle_message("e_bytes2) + .await + .expect("handle quote2"); + let response2 = ChunkMessage::decode(&response_bytes2).expect("decode2"); + + match response2.body { + ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success { + already_stored, .. + }) => { + assert!( + !already_stored, + "already_stored should be false for new chunk" + ); + } + other => panic!("expected Success with already_stored=false, got: {other:?}"), + } + } } diff --git a/src/storage/lmdb.rs b/src/storage/lmdb.rs index c7b93e6c..dac31e5b 100644 --- a/src/storage/lmdb.rs +++ b/src/storage/lmdb.rs @@ -247,6 +247,141 @@ impl LmdbStorage { Ok(true) } + /// Store data at the given address without content-address verification. + /// + /// Use this for data types where the address is not `BLAKE3(content)`. + /// + /// # Errors + /// + /// Returns an error if the write fails. + pub async fn put_raw(&self, address: &XorName, data: &[u8]) -> Result { + // Fast-path duplicate check + if self.exists(address)? { + trace!("Record {} already exists", hex::encode(address)); + self.stats.write().duplicates += 1; + return Ok(false); + } + + let key = *address; + let value = data.to_vec(); + let env = self.env.clone(); + let db = self.db; + let max_chunks = self.config.max_chunks; + + let was_new = spawn_blocking(move || -> Result { + let mut wtxn = env + .write_txn() + .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?; + + if db + .get(&wtxn, &key) + .map_err(|e| Error::Storage(format!("Failed to check existence: {e}")))? + .is_some() + { + return Ok(false); + } + + if max_chunks > 0 { + let current = db + .stat(&wtxn) + .map_err(|e| Error::Storage(format!("Failed to read db stats: {e}")))? + .entries; + if current >= max_chunks { + return Err(Error::Storage(format!( + "Storage capacity reached: {current} stored, max is {max_chunks}" + ))); + } + } + + db.put(&mut wtxn, &key, &value) + .map_err(|e| Error::Storage(format!("Failed to put record: {e}")))?; + wtxn.commit() + .map_err(|e| Error::Storage(format!("Failed to commit put: {e}")))?; + Ok(true) + }) + .await + .map_err(|e| Error::Storage(format!("LMDB put_raw task failed: {e}")))??; + + if !was_new { + // Race: fast-path missed it, txn-level check caught it. + self.stats.write().duplicates += 1; + return Ok(false); + } + + { + let mut stats = self.stats.write(); + stats.chunks_stored += 1; + stats.bytes_stored += data.len() as u64; + } + + debug!( + "Stored record {} ({} bytes)", + hex::encode(address), + data.len() + ); + + Ok(true) + } + + /// Overwrite a record at the given address without content-address verification. + /// + /// Unlike `put_raw`, this does not check for existing records and will + /// overwrite them. Used for mutable data types where + /// the handler has already validated the update. + /// + /// # Errors + /// + /// Returns an error if the write fails. + pub async fn put_overwrite(&self, address: &XorName, data: &[u8]) -> Result<()> { + let key = *address; + let value = data.to_vec(); + let env = self.env.clone(); + let db = self.db; + + // Returns the byte length of the previous value, if any. + let old_len: Option = spawn_blocking(move || -> Result> { + let mut wtxn = env + .write_txn() + .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?; + + let prev_len = db + .get(&wtxn, &key) + .map_err(|e| Error::Storage(format!("Failed to read old record: {e}")))? + .map(<[u8]>::len); + + db.put(&mut wtxn, &key, &value) + .map_err(|e| Error::Storage(format!("Failed to put record: {e}")))?; + wtxn.commit() + .map_err(|e| Error::Storage(format!("Failed to commit put: {e}")))?; + Ok(prev_len) + }) + .await + .map_err(|e| Error::Storage(format!("LMDB put_overwrite task failed: {e}")))??; + + { + let mut stats = self.stats.write(); + if let Some(prev) = old_len { + // Update: adjust byte count, chunk count stays the same. + stats.bytes_stored = stats + .bytes_stored + .saturating_sub(prev as u64) + .saturating_add(data.len() as u64); + } else { + // New record. + stats.chunks_stored += 1; + stats.bytes_stored += data.len() as u64; + } + } + + debug!( + "Overwritten record {} ({} bytes)", + hex::encode(address), + data.len() + ); + + Ok(()) + } + /// Retrieve a chunk. /// /// # Arguments @@ -314,6 +449,51 @@ impl LmdbStorage { Ok(Some(content)) } + /// Retrieve raw data without content-address verification. + /// + /// Use this for non-chunk data types where the stored bytes are + /// serialized records, not raw content. + /// + /// # Errors + /// + /// Returns an error if the read fails. + pub async fn get_raw(&self, address: &XorName) -> Result>> { + let key = *address; + let env = self.env.clone(); + let db = self.db; + + let content = spawn_blocking(move || -> Result>> { + let rtxn = env + .read_txn() + .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?; + let value = db + .get(&rtxn, &key) + .map_err(|e| Error::Storage(format!("Failed to get record: {e}")))?; + Ok(value.map(Vec::from)) + }) + .await + .map_err(|e| Error::Storage(format!("LMDB get_raw task failed: {e}")))??; + + let Some(content) = content else { + trace!("Record {} not found", hex::encode(address)); + return Ok(None); + }; + + { + let mut stats = self.stats.write(); + stats.chunks_retrieved += 1; + stats.bytes_retrieved += content.len() as u64; + } + + debug!( + "Retrieved record {} ({} bytes)", + hex::encode(address), + content.len() + ); + + Ok(Some(content)) + } + /// Check if a chunk exists. /// /// # Errors @@ -644,4 +824,95 @@ mod tests { assert_eq!(retrieved, Some(content.to_vec())); } } + + #[tokio::test] + async fn test_put_raw_and_get_raw() { + let (storage, _temp) = create_test_storage().await; + + // put_raw uses a caller-supplied address (not BLAKE3(content)) + let address = [0xAA; 32]; + let data = b"raw record data"; + + let was_new = storage.put_raw(&address, data).await.expect("put_raw"); + assert!(was_new, "first put_raw should return true"); + + // get_raw retrieves without content-address verification + let retrieved = storage.get_raw(&address).await.expect("get_raw"); + assert_eq!(retrieved, Some(data.to_vec())); + + // Duplicate put_raw returns false + let was_new2 = storage.put_raw(&address, data).await.expect("put_raw dup"); + assert!(!was_new2, "duplicate put_raw should return false"); + + // Stats: 1 stored, 1 duplicate + let stats = storage.stats(); + assert_eq!(stats.chunks_stored, 1); + assert_eq!(stats.duplicates, 1); + } + + #[tokio::test] + async fn test_get_raw_not_found() { + let (storage, _temp) = create_test_storage().await; + + let result = storage.get_raw(&[0xBB; 32]).await.expect("get_raw"); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_put_overwrite_new_record() { + let (storage, _temp) = create_test_storage().await; + + let address = [0xCC; 32]; + let data = b"new overwrite data"; + + storage + .put_overwrite(&address, data) + .await + .expect("put_overwrite"); + + let retrieved = storage.get_raw(&address).await.expect("get_raw"); + assert_eq!(retrieved, Some(data.to_vec())); + + let stats = storage.stats(); + assert_eq!(stats.chunks_stored, 1); + assert_eq!(stats.bytes_stored, data.len() as u64); + } + + #[tokio::test] + async fn test_put_overwrite_updates_stats_correctly() { + let (storage, _temp) = create_test_storage().await; + + let address = [0xDD; 32]; + let v1 = b"short"; + let v2 = b"much longer replacement value"; + + // First write + storage + .put_overwrite(&address, v1) + .await + .expect("put_overwrite v1"); + let stats1 = storage.stats(); + assert_eq!(stats1.chunks_stored, 1); + assert_eq!(stats1.bytes_stored, v1.len() as u64); + + // Overwrite with larger value — chunks_stored stays 1, bytes adjusts + storage + .put_overwrite(&address, v2) + .await + .expect("put_overwrite v2"); + let stats2 = storage.stats(); + assert_eq!( + stats2.chunks_stored, 1, + "overwrite should not increment chunk count" + ); + assert_eq!( + stats2.bytes_stored, + v2.len() as u64, + "bytes should reflect new value size" + ); + + // Verify the new value is stored + let retrieved = storage.get_raw(&address).await.expect("get_raw"); + assert_eq!(retrieved, Some(v2.to_vec())); + } } diff --git a/tests/e2e/complete_payment_e2e.rs b/tests/e2e/complete_payment_e2e.rs index a6c65d7c..c3a9a40c 100644 --- a/tests/e2e/complete_payment_e2e.rs +++ b/tests/e2e/complete_payment_e2e.rs @@ -1,9 +1,10 @@ //! Complete E2E test proving the payment protocol works on live nodes. //! -//! **All payment tests in this file use `payment_enforcement: true`.** -//! Nodes verify payments on-chain via Anvil before storing chunks. +//! **All payment tests in this file previously used `QuantumClient` which has been +//! removed.** Tests will be re-implemented using `saorsa-client::Client` once +//! the migration is complete. //! -//! ## Test Flow +//! ## Original Test Flow //! //! 1. **Network Setup**: Spawn 10 live saorsa nodes + Anvil EVM testnet //! 2. **Quote Collection**: Client requests quotes from 5 closest DHT peers @@ -13,541 +14,3 @@ //! 6. **Verification**: Nodes verify payment on-chain before storing //! 7. **Retrieval**: Retrieve chunk from storing node to prove storage succeeded //! 8. **Cross-Node**: Retrieve chunk from a DIFFERENT node (tests replication) - -use super::harness::TestHarness; -use super::testnet::TestNetworkConfig; -use ant_evm::ProofOfPayment; -use bytes::Bytes; -use evmlib::testnet::Testnet; -use evmlib::wallet::Wallet; -use saorsa_node::client::{hex_node_id_to_encoded_peer_id, QuantumClient}; -use saorsa_node::payment::{PaymentProof, SingleNodePayment}; -use serial_test::serial; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{info, warn}; - -/// Test environment for complete E2E payment flow. -/// -/// All nodes have `payment_enforcement: true` and use the same Anvil -/// instance as the client wallet, so on-chain verification is real. -struct CompletePaymentTestEnv { - harness: TestHarness, - /// Kept alive to prevent Anvil process from being dropped - _testnet: Testnet, - wallet: Wallet, -} - -impl CompletePaymentTestEnv { - /// Initialize complete payment test environment with enforcement enabled. - /// - /// Nodes and client share the SAME Anvil instance so on-chain - /// verification is real, not bypassed. - async fn setup() -> Result> { - info!("Setting up complete payment E2E test environment"); - - // Start Anvil EVM testnet FIRST so we can wire it to nodes - let testnet = Testnet::new().await; - let network = testnet.to_network(); - info!("Anvil testnet started"); - - // Setup 10-node network with payment enforcement ON and the - // SAME Anvil network so nodes verify on the same chain the client pays on. - // Use setup_with_config (NOT setup_with_evm_and_config) because we already - // created our own Testnet above — creating another would double-bind the port. - let config = TestNetworkConfig::small() - .with_payment_enforcement() - .with_evm_network(network.clone()); - - let harness = TestHarness::setup_with_config(config).await?; - - info!("10-node test network started with payment enforcement ENABLED"); - - // Wait for network to stabilize - sleep(Duration::from_secs(10)).await; - - let total_connections = harness.total_connections().await; - info!("Network stabilized with {total_connections} total connections"); - - // Warm up DHT routing tables (essential for quote collection) - harness.warmup_dht().await?; - sleep(Duration::from_secs(5)).await; - - // Create funded wallet from the SAME Anvil instance - let private_key = testnet.default_wallet_private_key(); - let wallet = Wallet::new_from_private_key(network, &private_key)?; - info!("Created funded wallet: {}", wallet.address()); - - Ok(Self { - harness, - _testnet: testnet, - wallet, - }) - } - - async fn teardown(self) -> Result<(), Box> { - self.harness.teardown().await?; - Ok(()) - } -} - -/// Complete chunk upload + payment + on-chain verification + retrieval flow. -/// -/// Nodes have `payment_enforcement: true`. The payment is verified on-chain. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_complete_payment_flow_live_nodes() -> Result<(), Box> { - info!("COMPLETE E2E PAYMENT TEST - LIVE NODES (enforcement ON)"); - - let mut env = CompletePaymentTestEnv::setup().await?; - - // Configure client node (node 0) with wallet - env.harness - .test_node_mut(0) - .ok_or("Node 0 not found")? - .set_wallet(env.wallet.clone()); - - let test_data = b"Complete E2E payment test data - proving the protocol works!"; - let expected_address = saorsa_node::compute_address(test_data); - - // Request quotes from DHT peers with retries - let client = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .client - .as_ref() - .ok_or("Client not configured")?; - - let mut quote_result = None; - for attempt in 1..=10 { - info!("Quote collection attempt {attempt}/10..."); - match client.get_quotes_from_dht(test_data).await { - Ok((target_peer, quotes)) => { - info!("Got {} quotes on attempt {attempt}", quotes.len()); - quote_result = Some((target_peer, quotes)); - break; - } - Err(e) => { - warn!("Attempt {attempt} failed: {e}"); - if attempt < 10 { - let _ = env.harness.warmup_dht().await; - sleep(Duration::from_secs(5)).await; - } - } - } - } - - let (target_peer, quotes_with_prices) = - quote_result.ok_or("Failed to get quotes after 10 attempts")?; - - assert_eq!( - quotes_with_prices.len(), - 5, - "Should receive exactly 5 quotes (REQUIRED_QUOTES)" - ); - - // Calculate payment (sort by price, select median) - let mut peer_quotes: Vec<_> = Vec::with_capacity(quotes_with_prices.len()); - let mut quotes_for_payment: Vec<_> = Vec::with_capacity(quotes_with_prices.len()); - for (peer_id_str, quote, price) in quotes_with_prices { - let encoded_peer_id = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Failed to convert peer ID '{peer_id_str}': {e}"))?; - peer_quotes.push((encoded_peer_id, quote.clone())); - quotes_for_payment.push((quote, price)); - } - let payment = SingleNodePayment::from_quotes(quotes_for_payment) - .map_err(|e| format!("Failed to create payment: {e}"))?; - - info!("Payment total: {} atto", payment.total_amount()); - - // Verify only median quote has non-zero amount - let non_zero_quotes = payment - .quotes - .iter() - .filter(|q| q.amount > ant_evm::Amount::ZERO) - .count(); - assert_eq!( - non_zero_quotes, 1, - "Only median quote should have non-zero amount" - ); - - // Make on-chain payment - let tx_hashes = payment - .pay(&env.wallet) - .await - .map_err(|e| format!("Payment failed: {e}"))?; - - assert!( - !tx_hashes.is_empty(), - "Expected at least one transaction hash from payment" - ); - info!( - "On-chain payment succeeded: {} transactions", - tx_hashes.len() - ); - - // Build proof AFTER payment with tx hashes included - let proof = PaymentProof { - proof_of_payment: ProofOfPayment { peer_quotes }, - tx_hashes, - }; - let proof_bytes = - rmp_serde::to_vec(&proof).map_err(|e| format!("Failed to serialize proof: {e}"))?; - - // Store chunk with payment proof — nodes WILL verify on-chain - // Retry with backoff: DHT routing tables may not be fully stabilized yet - let mut stored_address = None; - for attempt in 1..=10 { - match client - .put_chunk_with_proof( - Bytes::from(test_data.to_vec()), - proof_bytes.clone(), - &target_peer, - ) - .await - { - Ok(addr) => { - info!("Chunk stored on attempt {attempt}"); - stored_address = Some(addr); - break; - } - Err(e) => { - warn!("Storage attempt {attempt}/10 failed: {e}"); - if attempt < 10 { - let _ = env.harness.warmup_dht().await; - sleep(Duration::from_secs(5)).await; - } - } - } - } - let stored_address = - stored_address.ok_or("Storage MUST succeed with valid payment proof after 10 attempts")?; - - assert_eq!( - stored_address, expected_address, - "Stored address should match computed address" - ); - info!("Chunk stored at {}", hex::encode(stored_address)); - - // Verify chunk is retrievable - sleep(Duration::from_millis(500)).await; - - let retrieved = client - .get_chunk(&stored_address) - .await - .map_err(|e| format!("Failed to retrieve chunk: {e}"))?; - - let chunk = retrieved.ok_or("Chunk should be retrievable from storing node")?; - assert_eq!( - chunk.content.as_ref(), - test_data, - "Retrieved data should match original" - ); - - info!("Chunk retrieved and verified"); - - // Try cross-node retrieval (may not work without replication) - let node1_chunk = env - .harness - .test_node(1) - .ok_or("Node 1 not found")? - .get_chunk(&stored_address) - .await?; - - if let Some(chunk) = node1_chunk { - assert_eq!( - chunk.content.as_ref(), - test_data, - "Cross-node data should match original" - ); - info!("Cross-node retrieval succeeded"); - } else { - info!("Cross-node retrieval: not replicated yet (expected in test mode)"); - } - - info!("COMPLETE E2E PAYMENT TEST PASSED (enforcement ON)"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Nodes reject unpaid chunks when `payment_enforcement: true`. -/// -/// Validates server-side enforcement: the NODE rejects, not the client. -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_payment_verification_enforcement() -> Result<(), Box> { - info!("PAYMENT ENFORCEMENT TEST (enforcement ON)"); - - // Start Anvil and wire it to nodes - let testnet = Testnet::new().await; - let network = testnet.to_network(); - - let config = TestNetworkConfig::small() - .with_payment_enforcement() - .with_evm_network(network.clone()); - - // Use setup_with_config (NOT setup_with_evm_and_config) because we already - // created our own Testnet above — creating another would double-bind the port. - let harness = TestHarness::setup_with_config(config).await?; - - sleep(Duration::from_secs(10)).await; - harness.warmup_dht().await?; - sleep(Duration::from_secs(5)).await; - - // Try to store WITHOUT a wallet (sends no payment proof to server) - let client = - QuantumClient::with_defaults().with_node(harness.node(0).ok_or("Node 0 not found")?); - - let test_data = b"This should be rejected without payment"; - let result = client.put_chunk(Bytes::from(test_data.to_vec())).await; - - // MUST be rejected — assert exactly one outcome - assert!( - result.is_err(), - "Storage MUST fail without payment when enforcement is enabled" - ); - let error_msg = format!("{}", result.as_ref().err().ok_or("Expected error")?); - info!("Rejected as expected: {error_msg}"); - assert!( - error_msg.to_lowercase().contains("payment"), - "Error must be payment-related, got: {error_msg}" - ); - - // Now try WITH wallet and full payment flow — MUST succeed - let private_key = testnet.default_wallet_private_key(); - let wallet = Wallet::new_from_private_key(network, &private_key)?; - - let client_with_wallet = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet); - - let mut stored_address = None; - for attempt in 1..=10 { - match client_with_wallet - .put_chunk(Bytes::from(test_data.to_vec())) - .await - { - Ok(addr) => { - stored_address = Some(addr); - break; - } - Err(e) => { - warn!("Storage with payment attempt {attempt}/10 failed: {e}"); - if attempt < 10 { - let _ = harness.warmup_dht().await; - sleep(Duration::from_secs(5)).await; - } - } - } - } - - // MUST succeed — assert exactly one outcome - let address = - stored_address.ok_or("Storage MUST succeed with valid payment after 10 attempts")?; - info!("Stored with payment at {}", hex::encode(address)); - - info!("PAYMENT ENFORCEMENT TEST PASSED"); - - harness.teardown().await?; - Ok(()) -} - -/// Test: Forged ML-DSA-65 signature rejection. -/// -/// Gets valid quotes, makes real payment, builds proof, CORRUPTS the -/// signature bytes, sends to EVM-enabled node, asserts rejection. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_forged_signature_rejection() -> Result<(), Box> { - info!("FORGED SIGNATURE REJECTION TEST (enforcement ON)"); - - let testnet = Testnet::new().await; - let network = testnet.to_network(); - - let config = TestNetworkConfig::small() - .with_payment_enforcement() - .with_evm_network(network.clone()); - - // Use setup_with_config (NOT setup_with_evm_and_config) because we already - // created our own Testnet above — creating another would double-bind the port. - let harness = TestHarness::setup_with_config(config).await?; - - sleep(Duration::from_secs(10)).await; - harness.warmup_dht().await?; - - // Create client with wallet - let private_key = testnet.default_wallet_private_key(); - let wallet = Wallet::new_from_private_key(network, &private_key)?; - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - let test_data = b"Forged signature test data"; - - // Get quotes from DHT - let mut quote_result = None; - for attempt in 1..=5 { - match client.get_quotes_from_dht(test_data).await { - Ok((target_peer, quotes)) => { - quote_result = Some((target_peer, quotes)); - break; - } - Err(e) => { - warn!("Quote attempt {attempt} failed: {e}"); - if attempt < 5 { - sleep(Duration::from_secs(2u64.pow(attempt))).await; - } - } - } - } - - let (target_peer, quotes_with_prices) = - quote_result.ok_or("Failed to get quotes after 5 attempts")?; - - // Build peer_quotes and payment - let mut peer_quotes: Vec<_> = Vec::with_capacity(quotes_with_prices.len()); - let mut quotes_for_payment: Vec<_> = Vec::with_capacity(quotes_with_prices.len()); - for (peer_id_str, quote, price) in quotes_with_prices { - let encoded_peer_id = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Failed to convert peer ID '{peer_id_str}': {e}"))?; - peer_quotes.push((encoded_peer_id, quote.clone())); - quotes_for_payment.push((quote, price)); - } - - let payment = SingleNodePayment::from_quotes(quotes_for_payment) - .map_err(|e| format!("Failed to create payment: {e}"))?; - - // Pay on-chain (real payment) - let tx_hashes = payment - .pay(&wallet) - .await - .map_err(|e| format!("Payment failed: {e}"))?; - - // CORRUPT the signature on the first quote - let mut forged_quotes = peer_quotes.clone(); - if let Some((_peer_id, ref mut quote)) = forged_quotes.first_mut() { - // Flip all signature bytes to corrupt it - for byte in &mut quote.signature { - *byte = byte.wrapping_add(1); - } - } - - // Build proof with forged signature - let forged_proof = PaymentProof { - proof_of_payment: ProofOfPayment { - peer_quotes: forged_quotes, - }, - tx_hashes, - }; - let forged_proof_bytes = rmp_serde::to_vec(&forged_proof) - .map_err(|e| format!("Failed to serialize forged proof: {e}"))?; - - // Try to store with forged proof — MUST be rejected - let result = client - .put_chunk_with_proof( - Bytes::from(test_data.to_vec()), - forged_proof_bytes, - &target_peer, - ) - .await; - - assert!(result.is_err(), "Storage MUST fail with forged signature"); - let error_msg = format!("{}", result.as_ref().err().ok_or("Expected error")?); - info!("Forged signature rejected: {error_msg}"); - - info!("FORGED SIGNATURE REJECTION TEST PASSED"); - - harness.teardown().await?; - Ok(()) -} - -/// Test: Payment flow survives node failures. -/// -/// Validates that payment collection and storage continue to work -/// even when some nodes in the network fail. -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_payment_flow_with_failures() -> Result<(), Box> { - info!("PAYMENT FLOW RESILIENCE TEST (enforcement ON)"); - - let mut env = CompletePaymentTestEnv::setup().await?; - - // Configure client - env.harness - .test_node_mut(0) - .ok_or("Node 0 not found")? - .set_wallet(env.wallet.clone()); - - // Verify initial network - let initial_count = env.harness.running_node_count().await; - assert_eq!(initial_count, 10); - - // Simulate failures - shutdown 3 nodes - info!("Simulating node failures (shutting down nodes 5, 6, 7)"); - env.harness.shutdown_nodes(&[5, 6, 7]).await?; - - sleep(Duration::from_secs(15)).await; - - let remaining_count = env.harness.running_node_count().await; - assert_eq!(remaining_count, 7); - - // Re-warm DHT after node failures so routing tables adapt - env.harness.warmup_dht().await?; - sleep(Duration::from_secs(25)).await; - - // Payment flow with reduced network — MUST succeed (7 nodes > 5 required) - let test_data = b"Resilience test data"; - let client = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .client - .as_ref() - .ok_or("Client not configured")?; - - // Retry quote collection and storage up to 3 times to allow DHT to stabilize - let mut last_err = String::new(); - let mut succeeded = false; - for attempt in 1..=10 { - info!("Storage attempt {attempt}/10 after node failures..."); - match client.get_quotes_from_dht(test_data).await { - Ok((_target_peer, quotes)) => { - info!("Collected {} quotes despite failures", quotes.len()); - match client.put_chunk(Bytes::from(test_data.to_vec())).await { - Ok(_address) => { - info!("Storage succeeded with reduced network"); - succeeded = true; - break; - } - Err(e) => { - last_err = format!("Storage failed: {e}"); - warn!("Attempt {attempt} storage failed: {e}"); - } - } - } - Err(e) => { - last_err = format!("Quote collection failed: {e}"); - warn!("Attempt {attempt} quote collection failed: {e}"); - } - } - if attempt < 10 { - if attempt == 4 || attempt == 7 { - let _ = env.harness.warmup_dht().await; - } - sleep(Duration::from_secs(10)).await; - } - } - assert!( - succeeded, - "Storage MUST succeed with reduced network after retries: {last_err}" - ); - - info!("RESILIENCE TEST PASSED"); - - env.teardown().await?; - Ok(()) -} diff --git a/tests/e2e/data_types/chunk.rs b/tests/e2e/data_types/chunk.rs index 8f25ebd3..519682cd 100644 --- a/tests/e2e/data_types/chunk.rs +++ b/tests/e2e/data_types/chunk.rs @@ -249,6 +249,10 @@ mod tests { let mut rng = rand::thread_rng(); let target_peer_id = peers.choose(&mut rng).expect("peers is non-empty"); + // Pre-populate payment cache on the target node so the store is accepted + let expected_address = ChunkTestFixture::compute_address(&fixture.large); + harness.prepopulate_payment_cache_for_peer(target_peer_id, &expected_address); + // Use the max-size (4 MiB) chunk to exercise QUIC stream limits let address = requester .store_chunk_on_peer(target_peer_id, &fixture.large) @@ -256,7 +260,6 @@ mod tests { .expect("Failed to store max-size chunk on remote node"); // Verify the returned address matches the expected content hash - let expected_address = ChunkTestFixture::compute_address(&fixture.large); assert_eq!( address, expected_address, "Returned address should match computed content address" @@ -372,13 +375,11 @@ mod tests { } // Recreate AntProtocol from the same data directory (simulates restart) - // Pass false for payment_enforcement (disabled for this test) let restart_identity = saorsa_core::identity::NodeIdentity::generate() .expect("Failed to generate identity for restart"); - let new_protocol = - TestNetwork::create_ant_protocol(&data_dir, false, None, &restart_identity) - .await - .expect("Failed to recreate AntProtocol"); + let new_protocol = TestNetwork::create_ant_protocol(&data_dir, None, &restart_identity) + .await + .expect("Failed to recreate AntProtocol"); { let node = harness .network_mut() @@ -413,207 +414,6 @@ mod tests { .expect("Failed to teardown harness"); } - // ========================================================================= - // Payment E2E Tests - // ========================================================================= - - /// Test: Store chunk with payment (full E2E flow). - /// - /// This test validates the complete pay-to-store workflow: - /// 1. Starts a test network with Anvil EVM testnet - /// 2. Creates a funded wallet from Anvil - /// 3. Configures a client node with the wallet - /// 4. Stores a chunk (triggers quote request, payment, and storage) - /// 5. Retrieves and verifies the chunk - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn test_chunk_store_with_payment() { - let mut harness = TestHarness::setup_with_payments() - .await - .expect("Failed to setup harness with payments"); - - // Get wallet from Anvil - let anvil = harness.anvil().expect("Anvil should be running"); - let wallet = anvil - .create_funded_wallet() - .expect("Failed to create funded wallet"); - - // Setup client with wallet - let client_node = harness.test_node_mut(0).expect("Node 0 should exist"); - client_node.set_wallet(wallet); - - let fixture = ChunkTestFixture::new(); - - // Store chunk - should request quotes, pay, and store - let address = harness - .test_node(0) - .expect("Node 0 should exist") - .store_chunk_with_payment(&fixture.small) - .await - .expect("Failed to store chunk with payment"); - - // Verify the address matches the content hash - let expected_address = ChunkTestFixture::compute_address(&fixture.small); - assert_eq!( - address, expected_address, - "Returned address should match computed content address" - ); - - // Verify chunk was stored by retrieving it - let retrieved = harness - .test_node(0) - .expect("Node 0 should exist") - .get_chunk_with_client(&address) - .await - .expect("Failed to retrieve chunk"); - - let chunk = retrieved.expect("Chunk should exist after payment"); - assert_eq!( - chunk.content.as_ref(), - fixture.small.as_slice(), - "Retrieved data should match original" - ); - - harness - .teardown() - .await - .expect("Failed to teardown harness"); - } - - /// Test: Payment cache works (second PUT is free). - /// - /// This test verifies that storing the same chunk twice doesn't require - /// a second payment (the first payment is cached). - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn test_chunk_payment_cache() { - let mut harness = TestHarness::setup_with_payments() - .await - .expect("Failed to setup harness"); - - let anvil = harness.anvil().expect("Anvil should be running"); - let wallet = anvil - .create_funded_wallet() - .expect("Failed to create wallet"); - - harness - .test_node_mut(0) - .expect("Node 0 should exist") - .set_wallet(wallet); - - let fixture = ChunkTestFixture::new(); - - // First store - pays - let address1 = harness - .test_node(0) - .expect("Node 0 should exist") - .store_chunk_with_payment(&fixture.small) - .await - .expect("Failed to store chunk first time"); - - // Second store of same data - should return same address - // Note: The chunk already exists, so the node will return AlreadyExists - let address2 = harness - .test_node(0) - .expect("Node 0 should exist") - .store_chunk_with_payment(&fixture.small) - .await - .expect("Failed to store chunk second time"); - - assert_eq!( - address1, address2, - "Same data should produce same address both times" - ); - - harness - .teardown() - .await - .expect("Failed to teardown harness"); - } - - /// Test: Store fails without wallet. - /// - /// This test verifies that attempting to store a chunk without configuring - /// a wallet results in an appropriate error. - #[tokio::test(flavor = "multi_thread")] - async fn test_chunk_store_fails_without_wallet() { - let harness = TestHarness::setup_minimal() - .await - .expect("Failed to setup harness"); - - // Client without wallet - use the test node without calling with_wallet() - let client_node = harness.test_node(0).expect("Node 0 should exist"); - let fixture = ChunkTestFixture::new(); - - // This should fail because no client is configured (no wallet means no client) - let result = client_node.store_chunk_with_payment(&fixture.small).await; - - assert!( - result.is_err(), - "Store should fail without client/wallet configured" - ); - - harness - .teardown() - .await - .expect("Failed to teardown harness"); - } - - /// Test: Store fails with insufficient funds. - /// - /// This test verifies that attempting to store a chunk with an empty wallet - /// (no balance) results in a payment failure. - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn test_chunk_store_fails_with_insufficient_funds() { - let mut harness = TestHarness::setup_with_payments() - .await - .expect("Failed to setup harness"); - - // Create wallet with 0 balance - let anvil = harness.anvil().expect("Anvil should be running"); - let wallet = anvil - .create_empty_wallet() - .expect("Failed to create empty wallet"); - - harness - .test_node_mut(0) - .expect("Node 0 should exist") - .set_wallet(wallet); - - let fixture = ChunkTestFixture::new(); - - // Should fail with insufficient funds error - let result = harness - .test_node(0) - .expect("Node 0 should exist") - .store_chunk_with_payment(&fixture.small) - .await; - - assert!(result.is_err(), "Store should fail with insufficient funds"); - - // Verify the error is related to payment/funds - if let Err(e) = result { - let error_msg = format!("{e}"); - assert!( - { - let lower = error_msg.to_lowercase(); - lower.contains("payment") - || lower.contains("pay") - || lower.contains("funds") - || lower.contains("balance") - || lower.contains("insufficient") - }, - "Error should mention payment or funds, got: {error_msg}" - ); - } - - harness - .teardown() - .await - .expect("Failed to teardown harness"); - } - /// Create an `AntProtocol` with EVM verification enabled, backed by an Anvil testnet. /// /// Returns (protocol, `temp_dir`, testnet). The testnet must be kept alive for the @@ -635,16 +435,12 @@ mod tests { }) .await?; + let rewards_address = RewardsAddress::new([0x01; 20]); let payment_verifier = PaymentVerifier::new(PaymentVerifierConfig { - evm: EvmVerifierConfig { - enabled: true, - network, - }, + evm: EvmVerifierConfig { network }, cache_capacity: 100, - local_rewards_address: None, + local_rewards_address: rewards_address, }); - - let rewards_address = RewardsAddress::new([0x01; 20]); let metrics_tracker = QuotingMetricsTracker::new(1000, 100); let quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); diff --git a/tests/e2e/data_types/graph_entry.rs b/tests/e2e/data_types/graph_entry.rs deleted file mode 100644 index 2653b712..00000000 --- a/tests/e2e/data_types/graph_entry.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! `GraphEntry` data type E2E tests. -//! -//! `GraphEntry` represents nodes in a directed acyclic graph (DAG). -//! Each entry contains: -//! - Owner public key -//! - Parent links (`XorNames` of parent entries) -//! - Content/payload (up to 100KB) -//! - Signature (ML-DSA-65) -//! -//! ## Use Cases -//! -//! - Version control (commit history) -//! - Social feeds (post threads) -//! - Document revisions -//! - Multi-owner collaborative structures -//! -//! ## Test Coverage -//! -//! - Basic store and retrieve -//! - Parent link validation -//! - DAG traversal -//! - Multi-owner entries -//! - Cross-node replication -//! - Maximum size handling (100KB) -//! - ML-DSA-65 signature verification - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use super::{TestData, MAX_GRAPH_ENTRY_SIZE}; - -/// Test fixture for graph entry operations. -#[allow(dead_code)] -pub struct GraphEntryTestFixture { - /// Owner public key (32 bytes). - owner: [u8; 32], - /// Parent entry addresses. - pub parents: Vec<[u8; 32]>, - /// Small content (1KB). - pub small_content: Vec, - /// Large content (100KB - max size). - pub large_content: Vec, -} - -impl Default for GraphEntryTestFixture { - fn default() -> Self { - Self::new() - } -} - -impl GraphEntryTestFixture { - /// Create a new test fixture (root node with no parents). - #[must_use] - pub fn new() -> Self { - Self { - owner: TestData::test_owner(), - parents: Vec::new(), // Root node - small_content: TestData::generate(1024), - large_content: TestData::generate(MAX_GRAPH_ENTRY_SIZE), - } - } - - /// Create fixture with specific parents. - #[must_use] - pub fn with_parents(parents: Vec<[u8; 32]>) -> Self { - Self { - owner: TestData::test_owner(), - parents, - small_content: TestData::generate(1024), - large_content: TestData::generate(MAX_GRAPH_ENTRY_SIZE), - } - } - - /// Create fixture with specific owner and parents. - #[must_use] - #[allow(dead_code)] - pub fn with_owner_and_parents(owner: [u8; 32], parents: Vec<[u8; 32]>) -> Self { - Self { - owner, - parents, - small_content: TestData::generate(1024), - large_content: TestData::generate(MAX_GRAPH_ENTRY_SIZE), - } - } - - /// Compute graph entry address from owner and content. - #[must_use] - pub fn compute_address(owner: &[u8; 32], content: &[u8], parents: &[[u8; 32]]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"graph_entry:"); - hasher.update(owner); - hasher.update(content); - for parent in parents { - hasher.update(parent); - } - let address = *hasher.finalize().as_bytes(); - address - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Test 1: Graph entry address is deterministic - #[test] - fn test_graph_entry_address_deterministic() { - let owner = TestData::test_owner(); - let content = TestData::generate(100); - let parents: Vec<[u8; 32]> = vec![]; - - let addr1 = GraphEntryTestFixture::compute_address(&owner, &content, &parents); - let addr2 = GraphEntryTestFixture::compute_address(&owner, &content, &parents); - assert_eq!(addr1, addr2, "Same inputs should produce same address"); - } - - /// Test 2: Different content produces different addresses - #[test] - fn test_different_content_different_address() { - let owner = TestData::test_owner(); - let content1 = TestData::generate(100); - let mut content2 = TestData::generate(100); - content2[0] = 255; - let parents: Vec<[u8; 32]> = vec![]; - - let addr1 = GraphEntryTestFixture::compute_address(&owner, &content1, &parents); - let addr2 = GraphEntryTestFixture::compute_address(&owner, &content2, &parents); - assert_ne!( - addr1, addr2, - "Different content should produce different addresses" - ); - } - - /// Test 3: Different parents produce different addresses - #[test] - fn test_different_parents_different_address() { - let owner = TestData::test_owner(); - let content = TestData::generate(100); - let parent1 = [1u8; 32]; - let parent2 = [2u8; 32]; - - let addr1 = GraphEntryTestFixture::compute_address(&owner, &content, &[parent1]); - let addr2 = GraphEntryTestFixture::compute_address(&owner, &content, &[parent2]); - assert_ne!( - addr1, addr2, - "Different parents should produce different addresses" - ); - } - - /// Test 4: Fixture creates correct sizes - #[test] - fn test_fixture_content_sizes() { - let fixture = GraphEntryTestFixture::new(); - assert_eq!(fixture.small_content.len(), 1024); - assert_eq!(fixture.large_content.len(), MAX_GRAPH_ENTRY_SIZE); - } - - /// Test 5: Max graph entry size constant is correct - #[test] - fn test_max_graph_entry_size() { - assert_eq!(MAX_GRAPH_ENTRY_SIZE, 100 * 1024); // 100KB - } - - /// Test 6: Root fixture has no parents - #[test] - fn test_root_fixture_no_parents() { - let fixture = GraphEntryTestFixture::new(); - assert!(fixture.parents.is_empty()); - } - - /// Test 7: Fixture with parents - #[test] - fn test_fixture_with_parents() { - let parent = [42u8; 32]; - let fixture = GraphEntryTestFixture::with_parents(vec![parent]); - assert_eq!(fixture.parents.len(), 1); - assert_eq!(fixture.parents[0], parent); - } -} diff --git a/tests/e2e/data_types/mod.rs b/tests/e2e/data_types/mod.rs index a2e589a7..aa964ca8 100644 --- a/tests/e2e/data_types/mod.rs +++ b/tests/e2e/data_types/mod.rs @@ -1,37 +1,9 @@ //! Data type E2E tests for saorsa-node. //! -//! This module contains comprehensive tests for all four saorsa data types: +//! This module contains tests for the chunk data type: //! - **Chunk**: Immutable, content-addressed data (up to 4MB) -//! - **Scratchpad**: Mutable, owner-indexed with counter versioning (up to 4MB) -//! - **Pointer**: Lightweight mutable pointers to other addresses -//! - **`GraphEntry`**: DAG entries with parent links and multi-owner support -//! -//! ## Test Categories -//! -//! Each data type has tests covering: -//! 1. **Basic Operations**: Store and retrieve -//! 2. **Payment Verification**: EVM payment proofs -//! 3. **Signature Validation**: ML-DSA-65 signature verification -//! 4. **Replication**: Cross-node retrieval -//! 5. **Edge Cases**: Max size, empty data, etc. -//! -//! ## Running Tests -//! -//! ```bash -//! # Run all data type tests (requires testnet) -//! cargo test --test e2e data_types -- --ignored -//! -//! # Run specific data type tests -//! cargo test --test e2e chunk -- --ignored -//! cargo test --test e2e scratchpad -- --ignored -//! cargo test --test e2e pointer -- --ignored -//! cargo test --test e2e graph_entry -- --ignored -//! ``` mod chunk; -mod graph_entry; -mod pointer; -mod scratchpad; /// Test data generator for consistent test fixtures. pub struct TestData; @@ -70,12 +42,6 @@ impl TestData { /// Maximum chunk size (4MB). pub const MAX_CHUNK_SIZE: usize = 4 * 1024 * 1024; -/// Maximum scratchpad size (4MB). -pub const MAX_SCRATCHPAD_SIZE: usize = 4 * 1024 * 1024; - -/// Maximum graph entry size (100KB). -pub const MAX_GRAPH_ENTRY_SIZE: usize = 100 * 1024; - #[cfg(test)] mod tests { use super::*; diff --git a/tests/e2e/data_types/pointer.rs b/tests/e2e/data_types/pointer.rs deleted file mode 100644 index 93f043ce..00000000 --- a/tests/e2e/data_types/pointer.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Pointer data type E2E tests. -//! -//! Pointers are lightweight mutable references to other addresses. -//! They consist of: -//! - Owner public key (determines the pointer's address) -//! - Target `XorName` (the address being pointed to) -//! - Counter (for versioning like scratchpads) -//! - Signature (ML-DSA-65 for authenticity) -//! -//! ## Use Cases -//! -//! - Directory listings (pointer to current root) -//! - Mutable file references -//! - DNS-like name resolution -//! -//! ## Test Coverage -//! -//! - Basic store and retrieve -//! - Owner-based addressing -//! - Target update semantics -//! - Counter versioning -//! - Cross-node replication -//! - ML-DSA-65 signature verification - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use super::TestData; - -/// Test fixture for pointer operations. -#[allow(dead_code)] -pub struct PointerTestFixture { - /// Owner public key (32 bytes). - owner: [u8; 32], - /// Target address (`XorName`). - pub target: [u8; 32], - /// Alternative target for update tests. - pub alt_target: [u8; 32], -} - -impl Default for PointerTestFixture { - fn default() -> Self { - Self::new() - } -} - -impl PointerTestFixture { - /// Create a new test fixture. - #[must_use] - pub fn new() -> Self { - let mut target = [0u8; 32]; - target[0..8].copy_from_slice(b"target01"); - - let mut alt_target = [0u8; 32]; - alt_target[0..8].copy_from_slice(b"target02"); - - Self { - owner: TestData::test_owner(), - target, - alt_target, - } - } - - /// Create fixture with a specific owner. - #[must_use] - #[allow(dead_code)] - pub fn with_owner(owner: [u8; 32]) -> Self { - let mut target = [0u8; 32]; - target[0..8].copy_from_slice(b"target01"); - - let mut alt_target = [0u8; 32]; - alt_target[0..8].copy_from_slice(b"target02"); - - Self { - owner, - target, - alt_target, - } - } - - /// Compute pointer address from owner public key. - #[must_use] - pub fn compute_address(owner: &[u8; 32]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"pointer:"); - hasher.update(owner); - *hasher.finalize().as_bytes() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Test 1: Pointer address is derived from owner - #[test] - fn test_pointer_address_from_owner() { - let owner = TestData::test_owner(); - let addr1 = PointerTestFixture::compute_address(&owner); - let addr2 = PointerTestFixture::compute_address(&owner); - assert_eq!(addr1, addr2, "Same owner should produce same address"); - } - - /// Test 2: Different owners produce different addresses - #[test] - fn test_different_owners_different_addresses() { - let owner1 = [1u8; 32]; - let owner2 = [2u8; 32]; - - let addr1 = PointerTestFixture::compute_address(&owner1); - let addr2 = PointerTestFixture::compute_address(&owner2); - assert_ne!( - addr1, addr2, - "Different owners should produce different addresses" - ); - } - - /// Test 3: Fixture creates valid targets - #[test] - fn test_fixture_targets() { - let fixture = PointerTestFixture::new(); - assert_eq!(fixture.target.len(), 32); - assert_eq!(fixture.alt_target.len(), 32); - assert_ne!(fixture.target, fixture.alt_target); - } - - /// Test 4: Pointer address differs from scratchpad address - #[test] - fn test_pointer_address_namespace() { - use super::super::scratchpad::ScratchpadTestFixture; - - let owner = [42u8; 32]; - let pointer_addr = PointerTestFixture::compute_address(&owner); - let scratchpad_addr = ScratchpadTestFixture::compute_address(&owner); - - // Different prefixes should produce different addresses - assert_ne!( - pointer_addr, scratchpad_addr, - "Pointer and scratchpad addresses should be in different namespaces" - ); - } -} diff --git a/tests/e2e/data_types/scratchpad.rs b/tests/e2e/data_types/scratchpad.rs deleted file mode 100644 index 0a332444..00000000 --- a/tests/e2e/data_types/scratchpad.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Scratchpad data type E2E tests. -//! -//! Scratchpads are mutable, owner-indexed data blocks (up to 4MB) with -//! counter-based versioning (CRDT). The address is derived from the owner's -//! public key. -//! -//! ## Test Coverage -//! -//! - Basic store and retrieve -//! - Owner-based addressing -//! - Counter versioning (CRDT) -//! - Update semantics (higher counter wins) -//! - Cross-node replication -//! - Maximum size handling (4MB) -//! - Payment verification -//! - ML-DSA-65 signature verification - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use super::{TestData, MAX_SCRATCHPAD_SIZE}; - -/// Test fixture for scratchpad operations. -#[allow(dead_code)] -pub struct ScratchpadTestFixture { - /// Owner public key (32 bytes). - pub owner: [u8; 32], - /// Content type identifier. - content_type: u64, - /// Small test data (1KB). - pub small_data: Vec, - /// Large test data (4MB - max size). - pub large_data: Vec, -} - -impl Default for ScratchpadTestFixture { - fn default() -> Self { - Self::new() - } -} - -impl ScratchpadTestFixture { - /// Create a new test fixture with pre-generated data. - #[must_use] - pub fn new() -> Self { - Self { - owner: TestData::test_owner(), - content_type: 1, // Generic content type - small_data: TestData::generate(1024), - large_data: TestData::generate(MAX_SCRATCHPAD_SIZE), - } - } - - /// Create fixture with a specific owner. - #[must_use] - pub fn with_owner(owner: [u8; 32]) -> Self { - Self { - owner, - content_type: 1, - small_data: TestData::generate(1024), - large_data: TestData::generate(MAX_SCRATCHPAD_SIZE), - } - } - - /// Compute scratchpad address from owner public key. - #[must_use] - pub fn compute_address(owner: &[u8; 32]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"scratchpad:"); - hasher.update(owner); - *hasher.finalize().as_bytes() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Test 1: Scratchpad address is derived from owner - #[test] - fn test_scratchpad_address_from_owner() { - let owner = TestData::test_owner(); - let addr1 = ScratchpadTestFixture::compute_address(&owner); - let addr2 = ScratchpadTestFixture::compute_address(&owner); - assert_eq!(addr1, addr2, "Same owner should produce same address"); - } - - /// Test 2: Different owners produce different addresses - #[test] - fn test_different_owners_different_addresses() { - let owner1 = [1u8; 32]; - let owner2 = [2u8; 32]; - - let addr1 = ScratchpadTestFixture::compute_address(&owner1); - let addr2 = ScratchpadTestFixture::compute_address(&owner2); - assert_ne!( - addr1, addr2, - "Different owners should produce different addresses" - ); - } - - /// Test 3: Fixture creates correct sizes - #[test] - fn test_fixture_data_sizes() { - let fixture = ScratchpadTestFixture::new(); - assert_eq!(fixture.small_data.len(), 1024); - assert_eq!(fixture.large_data.len(), MAX_SCRATCHPAD_SIZE); - } - - /// Test 4: Max scratchpad size constant is correct - #[test] - fn test_max_scratchpad_size() { - assert_eq!(MAX_SCRATCHPAD_SIZE, 4 * 1024 * 1024); // 4MB - } - - /// Test 5: Custom owner fixture - #[test] - fn test_custom_owner_fixture() { - let custom_owner = [42u8; 32]; - let fixture = ScratchpadTestFixture::with_owner(custom_owner); - assert_eq!(fixture.owner, custom_owner); - } -} diff --git a/tests/e2e/harness.rs b/tests/e2e/harness.rs index d75ce09f..09e76851 100644 --- a/tests/e2e/harness.rs +++ b/tests/e2e/harness.rs @@ -344,6 +344,34 @@ impl TestHarness { self.network.node_mut(index) } + /// Pre-populate the payment cache on the node matching `peer_id`. + /// + /// Inserts `address` into the target node's payment verifier cache so + /// P2P store requests are accepted without an on-chain proof. This + /// simulates the address having been previously paid for. + /// # Panics + /// + /// Panics if no running node matches `peer_id` — this indicates a test + /// configuration error (e.g. wrong peer ID or the target node was shut down). + #[allow(clippy::panic)] + pub fn prepopulate_payment_cache_for_peer( + &self, + peer_id: &saorsa_core::identity::PeerId, + address: &XorName, + ) { + for node in self.network.nodes() { + if let Some(ref p2p) = node.p2p_node { + if p2p.peer_id() == peer_id { + if let Some(ref protocol) = node.ant_protocol { + protocol.payment_verifier().cache_insert(*address); + } + return; + } + } + } + panic!("prepopulate_payment_cache_for_peer: no running node with peer_id {peer_id}"); + } + /// Get a random non-bootstrap node. /// /// Useful for tests that need to pick an arbitrary regular node. diff --git a/tests/e2e/integration_tests.rs b/tests/e2e/integration_tests.rs index c9734241..80ed3659 100644 --- a/tests/e2e/integration_tests.rs +++ b/tests/e2e/integration_tests.rs @@ -9,11 +9,8 @@ use super::testnet::{ SMALL_NODE_COUNT, TEST_PORT_RANGE_MAX, TEST_PORT_RANGE_MIN, }; use super::{NetworkState, TestHarness, TestNetwork, TestNetworkConfig}; -use bytes::Bytes; use saorsa_core::P2PEvent; -use saorsa_node::client::{QuantumClient, QuantumConfig}; use serial_test::serial; -use std::sync::Arc; use std::time::Duration; /// Test that a minimal network (5 nodes) can form and stabilize. @@ -276,107 +273,3 @@ async fn test_node_to_node_messaging() { .await .expect("Failed to teardown test harness"); } - -/// Test that `QuantumClient` can store and retrieve chunks through the ANT -/// protocol on a live testnet. -/// -/// This validates the full client→P2P→protocol handler→disk storage round-trip: -/// 1. Spin up a minimal 5-node testnet -/// 2. Create a `QuantumClient` connected to a regular node -/// 3. Store a chunk via `put_chunk()` (sends `ChunkPutRequest` over P2P) -/// 4. Retrieve it via `get_chunk()` (sends `ChunkGetRequest` over P2P) -/// 5. Verify existence via `exists()` -/// 6. Confirm a missing chunk returns `None` / `false` -#[tokio::test(flavor = "multi_thread")] -async fn test_quantum_client_chunk_round_trip() { - /// Timeout tuned for local testnet; the default 30s is generous but - /// avoids flakes in CI where node startup is slow. - const CLIENT_TIMEOUT_SECS: u64 = 30; - - let harness = TestHarness::setup_minimal() - .await - .expect("Failed to setup test harness"); - - // Pick a regular node (node 3) and wire a QuantumClient to it - let node = harness.node(3).expect("Node 3 should exist"); - - let config = QuantumConfig { - timeout_secs: CLIENT_TIMEOUT_SECS, - ..Default::default() - }; - let client = QuantumClient::new(config).with_node(Arc::clone(&node)); - - // ── PUT ────────────────────────────────────────────────────────────── - // Nodes use payment_enforcement: false, so we send a dummy proof via - // put_chunk_with_proof() (put_chunk() requires a wallet since the - // client-side early-rejection fix). - let content = Bytes::from("quantum client e2e test payload"); - let dummy_proof = vec![0u8; 64]; - let peers = node.connected_peers().await; - let target_peer = peers.first().expect("Node should have connected peers"); - let address = client - .put_chunk_with_proof(content.clone(), dummy_proof, target_peer) - .await - .expect("QuantumClient::put_chunk_with_proof should succeed"); - - // Address must equal BLAKE3(content) - let expected_address = saorsa_node::compute_address(&content); - assert_eq!( - address, expected_address, - "put_chunk should return the content-addressed hash" - ); - - // ── GET ────────────────────────────────────────────────────────────── - let retrieved = client - .get_chunk(&address) - .await - .expect("QuantumClient::get_chunk should succeed"); - - let chunk = retrieved.expect("Chunk should be found after storing it"); - assert_eq!( - chunk.content.as_ref(), - content.as_ref(), - "Retrieved content should match what was stored" - ); - assert_eq!( - chunk.address, address, - "Retrieved address should match the stored address" - ); - - // ── EXISTS ─────────────────────────────────────────────────────────── - let found = client - .exists(&address) - .await - .expect("QuantumClient::exists should succeed"); - assert!(found, "exists() should return true for a stored chunk"); - - // ── GET missing ────────────────────────────────────────────────────── - let missing_address = [0xDE; 32]; - let missing = client - .get_chunk(&missing_address) - .await - .expect("get_chunk for missing address should not error"); - assert!( - missing.is_none(), - "get_chunk should return None for a non-existent chunk" - ); - - // ── EXISTS missing ─────────────────────────────────────────────────── - let missing_exists = client - .exists(&missing_address) - .await - .expect("exists for missing address should not error"); - assert!( - !missing_exists, - "exists() should return false for a non-existent chunk" - ); - - // Drop the client (and its event subscriptions) before teardown - drop(client); - drop(node); - - harness - .teardown() - .await - .expect("Failed to teardown test harness"); -} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index b81e89c6..ea7a2f0c 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -39,6 +39,7 @@ mod anvil; mod data_types; mod harness; +#[allow(clippy::missing_errors_doc, clippy::match_same_arms)] mod testnet; #[cfg(test)] diff --git a/tests/e2e/payment_flow.rs b/tests/e2e/payment_flow.rs index 3645255d..b87a6e78 100644 --- a/tests/e2e/payment_flow.rs +++ b/tests/e2e/payment_flow.rs @@ -23,15 +23,12 @@ //! **Network Setup**: Uses a 10-node test network (need 8+ for `CLOSE_GROUP_SIZE`). use super::harness::TestHarness; -use bytes::Bytes; use evmlib::testnet::Testnet; use evmlib::wallet::Wallet; -use saorsa_node::client::QuantumClient; -use saorsa_node::payment::SingleNodePayment; use serial_test::serial; use std::time::Duration; use tokio::time::sleep; -use tracing::{info, warn}; +use tracing::info; /// Test environment containing both the test network and EVM testnet. struct PaymentTestEnv { @@ -107,528 +104,6 @@ async fn init_testnet_and_evm() -> Result Result<(), Box> { - info!("Starting E2E payment test: client pays and stores on network"); - - // Initialize test environment (network + EVM) - let mut env = init_testnet_and_evm().await?; - - // Create funded wallet for client - let wallet = env.create_funded_wallet()?; - - // Configure node 0 as the client with wallet - let client_node = env.harness.test_node_mut(0).ok_or("Node 0 not found")?; - client_node.set_wallet(wallet); - - info!("Client configured with funded wallet"); - - // Store a chunk using the payment-enabled client - let test_data = b"Test data for payment E2E flow"; - info!("Storing {} bytes", test_data.len()); - - let address = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .store_chunk_with_payment(test_data) - .await?; - info!("Chunk stored successfully at: {}", hex::encode(address)); - - // Verify chunk is retrievable via DHT-routed client (same routing as PUT) - sleep(Duration::from_millis(500)).await; - - let retrieved = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .get_chunk_with_client(&address) - .await?; - - assert!( - retrieved.is_some(), - "Chunk should be retrievable via DHT routing" - ); - - let chunk = retrieved.ok_or("Chunk not found")?; - assert_eq!( - chunk.content.as_ref(), - test_data, - "Retrieved data should match original" - ); - - info!("✅ Chunk successfully retrieved via DHT routing"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Multiple clients store chunks with independent payments. -/// -/// Validates that: -/// - Multiple clients can operate concurrently -/// - Each payment is independent -/// - All chunks are stored correctly -/// - Payment cache doesn't interfere between clients -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_multiple_clients_concurrent_payments() -> Result<(), Box> { - info!("Starting E2E payment test: multiple clients with concurrent payments"); - - // Initialize test environment (network + EVM) - let mut env = init_testnet_and_evm().await?; - - // Create 3 clients with separate wallets - for i in 0..3 { - let wallet = env.create_funded_wallet()?; - let node = env - .harness - .test_node_mut(i) - .ok_or_else(|| format!("Node {i} not found"))?; - node.set_wallet(wallet); - } - - info!("Created 3 clients with independent funded wallets"); - - // Extra stabilization after wallet setup - sleep(Duration::from_secs(3)).await; - - // Store chunks concurrently using payment-enabled client - let mut addresses = Vec::new(); - for i in 0..3 { - let data = format!("Data from client {i}"); - let address = env - .harness - .test_node(i) - .ok_or_else(|| format!("Node {i} not found"))? - .store_chunk_with_payment(data.as_bytes()) - .await?; - info!("Client {} stored chunk at: {}", i, hex::encode(address)); - addresses.push(address); - } - - assert_eq!(addresses.len(), 3, "All clients should store successfully"); - - // Verify all chunks are retrievable via DHT routing - for (i, address) in addresses.iter().enumerate() { - let retrieved = env - .harness - .test_node(i) - .ok_or_else(|| format!("Node {i} not found"))? - .get_chunk_with_client(address) - .await?; - - assert!(retrieved.is_some(), "Chunk {i} should be retrievable"); - - let expected = format!("Data from client {i}"); - assert_eq!( - retrieved.ok_or("Chunk not found")?.content.as_ref(), - expected.as_bytes(), - "Retrieved data should match for client {i}" - ); - } - - info!("✅ All chunks from multiple clients verified"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Payment verification prevents storage without valid payment. -/// -/// Validates that: -/// - Nodes reject chunks without payment when EVM verification is enabled -/// - Payment verification is enforced on the server side -/// - Clients without wallets get appropriate errors -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_payment_required_enforcement() -> Result<(), Box> { - info!("Starting E2E payment test: payment enforcement validation"); - - // Start Anvil EVM testnet FIRST so we can wire it to nodes - let testnet = Testnet::new().await; - let network = testnet.to_network(); - info!("Anvil testnet started"); - - // Setup 10-node network with payment enforcement ON and the - // SAME Anvil network so nodes verify on the same chain. - let config = super::testnet::TestNetworkConfig::small() - .with_payment_enforcement() - .with_evm_network(network); - - // Use setup_with_config (NOT setup_with_evm_and_config) because we already - // created our own Testnet above — creating another would double-bind the port. - let harness = TestHarness::setup_with_config(config).await?; - - info!("10-node test network started with payment enforcement ENABLED"); - - // Wait for network to stabilize (10 nodes need more time) - sleep(Duration::from_secs(5)).await; - - let total_connections = harness.total_connections().await; - info!("Payment test environment ready: {total_connections} total connections"); - - let env = PaymentTestEnv { harness, testnet }; - - // Try to store without wallet (should fail) - let client_without_wallet = - QuantumClient::with_defaults().with_node(env.harness.node(0).ok_or("Node 0 not found")?); - - let test_data = b"This should be rejected"; - let result = client_without_wallet - .put_chunk(Bytes::from(test_data.to_vec())) - .await; - - assert!(result.is_err(), "Store should fail without wallet/payment"); - - info!("✅ Payment enforcement validated - storage rejected without payment"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Large chunk storage with payment. -/// -/// Validates that: -/// - Large chunks (near max size) work with payment flow -/// - Quote prices scale appropriately with chunk size -/// - Payment and storage succeed for realistic data sizes -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_large_chunk_payment_flow() -> Result<(), Box> { - info!("Starting E2E payment test: large chunk storage"); - - // Initialize test environment (network + EVM) - let mut env = init_testnet_and_evm().await?; - - // Configure client with wallet - let wallet = env.create_funded_wallet()?; - env.harness - .test_node_mut(0) - .ok_or("Node 0 not found")? - .set_wallet(wallet); - - // Create a large chunk (512 KB) - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let large_data: Vec = (0..524_288).map(|i| (i % 256) as u8).collect(); - info!("Storing large chunk: {} bytes", large_data.len()); - - let address = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .store_chunk_with_payment(&large_data) - .await?; - info!("Large chunk stored at: {}", hex::encode(address)); - - // Verify retrieval via DHT routing - sleep(Duration::from_millis(500)).await; - - let retrieved = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .get_chunk_with_client(&address) - .await?; - - assert!(retrieved.is_some(), "Large chunk should be retrievable"); - - let chunk = retrieved.ok_or("Chunk not found")?; - assert_eq!( - chunk.content.len(), - large_data.len(), - "Retrieved size should match" - ); - assert_eq!( - chunk.content.as_ref(), - large_data.as_slice(), - "Retrieved data should match original" - ); - - info!("✅ Large chunk payment flow validated"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Idempotent chunk storage — storing the same chunk twice succeeds. -/// -/// Validates that: -/// - First store with payment succeeds -/// - Second store of same data returns same address (`AlreadyExists` on node) -/// - Both stores produce valid addresses -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_idempotent_chunk_storage() -> Result<(), Box> { - info!("Starting E2E payment test: idempotent chunk storage"); - - // Initialize test environment (network + EVM) - let mut env = init_testnet_and_evm().await?; - - // Configure client - let wallet = env.create_funded_wallet()?; - env.harness - .test_node_mut(0) - .ok_or("Node 0 not found")? - .set_wallet(wallet); - - let test_data = b"Test data for idempotent storage"; - - // First store - let address1 = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .store_chunk_with_payment(test_data) - .await?; - info!("First store: {}", hex::encode(address1)); - - // Second store of same data — node should respond with AlreadyExists - let address2 = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .store_chunk_with_payment(test_data) - .await?; - info!("Second store: {}", hex::encode(address2)); - - assert_eq!( - address1, address2, - "Same data should produce same address on both stores" - ); - - // Verify chunk is retrievable - let retrieved = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .get_chunk_with_client(&address1) - .await?; - - assert!( - retrieved.is_some(), - "Chunk should be retrievable after idempotent store" - ); - - let chunk = retrieved.ok_or("Chunk not found")?; - assert_eq!( - chunk.content.as_ref(), - test_data, - "Retrieved data should match original" - ); - - info!("✅ Idempotent chunk storage validated"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Quote collection from DHT peers. -/// -/// Validates that: -/// - Client can discover and contact peers via DHT -/// - Multiple quotes are received -/// - Median price calculation works correctly -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_quote_collection_via_dht() -> Result<(), Box> { - info!("Starting E2E payment test: quote collection via DHT"); - - // Initialize test environment (network + EVM) - let env = init_testnet_and_evm().await?; - - // Create a client connected to node 0 - let client = - QuantumClient::with_defaults().with_node(env.harness.node(0).ok_or("Node 0 not found")?); - - // Prepare test data - let test_data = b"Test data for quote collection"; - info!("Requesting quotes for {} bytes", test_data.len()); - - // Request quotes from DHT peers - let (_target_peer, quotes_with_prices) = client.get_quotes_from_dht(test_data).await?; - - // Validate we got exactly 5 quotes (REQUIRED_QUOTES) - assert_eq!( - quotes_with_prices.len(), - 5, - "Should collect exactly 5 quotes" - ); - - info!( - "✅ Successfully collected {} quotes from DHT", - quotes_with_prices.len() - ); - - // Validate each quote has a price and peer ID - for (i, (peer_id, quote, price)) in quotes_with_prices.iter().enumerate() { - info!( - "Quote {}: peer = {peer_id}, price = {} atto, address = {}", - i + 1, - price, - quote.rewards_address - ); - - // Verify quote content matches our data - let address = saorsa_node::compute_address(test_data); - assert_eq!( - quote.content.0, address, - "Quote content address should match computed address" - ); - } - - // Create SingleNodePayment to test median selection (strip peer IDs) - let quotes_for_payment: Vec<_> = quotes_with_prices - .into_iter() - .map(|(_peer_id, quote, price)| (quote, price)) - .collect(); - let payment = SingleNodePayment::from_quotes(quotes_for_payment)?; - - info!("✅ Successfully created SingleNodePayment from quotes"); - info!(" Total payment amount: {} atto", payment.total_amount()); - info!( - " Paid quote (median): {} atto", - payment - .paid_quote() - .ok_or("Missing paid quote at median index")? - .amount - ); - - // Verify only the median quote has a non-zero amount - let non_zero_quotes = payment - .quotes - .iter() - .filter(|q| q.amount > ant_evm::Amount::ZERO) - .count(); - assert_eq!( - non_zero_quotes, 1, - "Only median quote should have non-zero amount" - ); - - info!("✅ Quote collection and median selection validated"); - - env.teardown().await?; - Ok(()) -} - -/// Test: Network resilience - storage succeeds even if some nodes fail. -/// -/// Validates that: -/// - Payment flow works when some nodes are unavailable -/// - Chunk is still stored on available nodes -/// - System gracefully handles partial failures -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_payment_with_node_failures() -> Result<(), Box> { - info!("Starting E2E payment test: resilience with node failures"); - - // Initialize test environment (network + EVM) - let mut env = init_testnet_and_evm().await?; - - // Configure client - let wallet = env.create_funded_wallet()?; - env.harness - .test_node_mut(0) - .ok_or("Node 0 not found")? - .set_wallet(wallet); - - // Verify initial network has all nodes running - let initial_count = env.harness.running_node_count().await; - info!("Initial network has {} running nodes", initial_count); - assert_eq!(initial_count, 10, "Should start with 10 nodes"); - - // Simulate node failures by shutting down nodes 5, 6, and 7 - info!("Simulating node failures: shutting down nodes 5, 6, 7"); - env.harness.shutdown_nodes(&[5, 6, 7]).await?; - - // Wait for network to adapt to failures - sleep(Duration::from_secs(15)).await; - - // Verify nodes are shut down - let remaining_count = env.harness.running_node_count().await; - info!("After failures: {remaining_count} running nodes remain"); - assert_eq!( - remaining_count, 7, - "Should have 7 nodes after shutting down 3" - ); - - // Re-warm DHT after node failures so routing tables adapt - env.harness.warmup_dht().await?; - sleep(Duration::from_secs(15)).await; - - // Store a chunk with the remaining nodes (7 nodes still > 5 needed for quotes) - let test_data = b"Resilience test data"; - let mut address = None; - for attempt in 1..=10 { - info!("Storage attempt {attempt}/10 after node failures..."); - match env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .store_chunk_with_payment(test_data) - .await - { - Ok(addr) => { - address = Some(addr); - break; - } - Err(e) => { - warn!("Storage attempt {attempt}/10 failed: {e}"); - if attempt < 10 { - let _ = env.harness.warmup_dht().await; - sleep(Duration::from_secs(10)).await; - } - } - } - } - let address = address.ok_or("Storage MUST succeed after node failures with 10 attempts")?; - - info!( - "Successfully stored chunk despite simulated failures: {}", - hex::encode(address) - ); - - // Verify chunk is retrievable via DHT routing - sleep(Duration::from_millis(500)).await; - - let retrieved = env - .harness - .test_node(0) - .ok_or("Node 0 not found")? - .get_chunk_with_client(&address) - .await?; - - assert!( - retrieved.is_some(), - "Chunk should be retrievable despite node failures" - ); - - let chunk = retrieved.ok_or("Chunk not found")?; - assert_eq!( - chunk.content.as_ref(), - test_data, - "Retrieved data should match original" - ); - - info!( - "✅ Network resilience validated: storage succeeds with {} nodes after 3 failures", - remaining_count - ); - - env.teardown().await?; - Ok(()) -} - #[cfg(test)] mod helper_tests { use super::*; diff --git a/tests/e2e/security_attacks.rs b/tests/e2e/security_attacks.rs index 49590d03..f837f41c 100644 --- a/tests/e2e/security_attacks.rs +++ b/tests/e2e/security_attacks.rs @@ -11,20 +11,17 @@ use super::harness::TestHarness; use super::testnet::TestNetworkConfig; use ant_evm::ProofOfPayment; -use bytes::Bytes; use evmlib::testnet::Testnet; -use evmlib::wallet::Wallet; use rand::Rng; use saorsa_node::ant_protocol::{ ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ProtocolError, }; -use saorsa_node::client::{hex_node_id_to_encoded_peer_id, QuantumClient}; use saorsa_node::compute_address; -use saorsa_node::payment::{PaymentProof, SingleNodePayment}; +use saorsa_node::payment::PaymentProof; use serial_test::serial; use std::time::Duration; use tokio::time::sleep; -use tracing::{info, warn}; +use tracing::info; // --------------------------------------------------------------------------- // Helpers @@ -83,23 +80,6 @@ async fn setup_enforcement_env() -> Result<(TestHarness, Testnet), Box Result<(TestHarness, Testnet, Wallet), Box> { - let testnet = Testnet::new().await; - let network = testnet.to_network(); - let config = TestNetworkConfig::small() - .with_payment_enforcement() - .with_evm_network(network.clone()); - let harness = TestHarness::setup_with_config(config).await?; - sleep(Duration::from_secs(10)).await; - harness.warmup_dht().await?; - let private_key = testnet.default_wallet_private_key(); - let wallet = Wallet::new_from_private_key(network, &private_key)?; - Ok((harness, testnet, wallet)) -} - // =========================================================================== // Category 1: No/Invalid Proof Bytes (Direct Protocol Handler) // =========================================================================== @@ -257,521 +237,3 @@ async fn test_attack_proof_too_large() -> Result<(), Box> harness.teardown().await?; Ok(()) } - -// =========================================================================== -// Category 2: Cryptographic Attacks (Real Quotes + Anvil) -// =========================================================================== - -/// Helper: get quotes from DHT with retries (up to 5 attempts, exponential backoff). -/// -/// Returns the target peer (closest to the chunk address, pinned during quoting) -/// alongside the quotes. -async fn get_quotes_with_retries( - client: &QuantumClient, - test_data: &[u8], -) -> Result< - ( - saorsa_core::identity::PeerId, - Vec<( - saorsa_core::identity::PeerId, - ant_evm::PaymentQuote, - ant_evm::Amount, - )>, - ), - String, -> { - let mut last_err = String::new(); - for attempt in 1..=5u32 { - match client.get_quotes_from_dht(test_data).await { - Ok((target_peer, quotes)) => { - info!("Got {} quotes on attempt {attempt}", quotes.len()); - return Ok((target_peer, quotes)); - } - Err(e) => { - last_err = format!("{e}"); - warn!("Quote attempt {attempt} failed: {e}"); - if attempt < 5 { - sleep(Duration::from_secs(2u64.pow(attempt))).await; - } - } - } - } - Err(format!("Failed to get quotes after 5 attempts: {last_err}")) -} - -/// Helper: build a valid proof from quotes + wallet payment. -/// Returns (`proof_bytes`, `tx_hashes`). -async fn build_valid_proof( - quotes_with_prices: Vec<( - saorsa_core::identity::PeerId, - ant_evm::PaymentQuote, - ant_evm::Amount, - )>, - wallet: &Wallet, -) -> Result<(Vec, Vec), Box> { - let mut peer_quotes = Vec::with_capacity(quotes_with_prices.len()); - let mut quotes_for_payment = Vec::with_capacity(quotes_with_prices.len()); - for (peer_id_str, quote, price) in quotes_with_prices { - let encoded = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Peer ID conversion failed: {e}"))?; - peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); - } - let payment = SingleNodePayment::from_quotes(quotes_for_payment) - .map_err(|e| format!("Payment creation failed: {e}"))?; - let tx_hashes = payment - .pay(wallet) - .await - .map_err(|e| format!("Payment failed: {e}"))?; - let proof = PaymentProof { - proof_of_payment: ProofOfPayment { peer_quotes }, - tx_hashes: tx_hashes.clone(), - }; - let proof_bytes = rmp_serde::to_vec(&proof).map_err(|e| format!("Serialize failed: {e}"))?; - Ok((proof_bytes, tx_hashes)) -} - -/// Attack: Forge ALL ML-DSA-65 signatures on valid quotes + real payment. -/// Node MUST reject because signature verification fails. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_forged_ml_dsa_signature() -> Result<(), Box> { - info!("ATTACK TEST: forged ML-DSA-65 signatures (ALL quotes)"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - let test_data = b"Attack: forge all ML-DSA signatures"; - let (target_peer, quotes) = get_quotes_with_retries(&client, test_data).await?; - - // Build peer_quotes and payment - let mut peer_quotes = Vec::with_capacity(quotes.len()); - let mut quotes_for_payment = Vec::with_capacity(quotes.len()); - for (peer_id_str, quote, price) in quotes { - let encoded = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Peer ID conversion failed: {e}"))?; - peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); - } - let payment = SingleNodePayment::from_quotes(quotes_for_payment) - .map_err(|e| format!("Payment creation failed: {e}"))?; - let tx_hashes = payment - .pay(&wallet) - .await - .map_err(|e| format!("Payment failed: {e}"))?; - - // CORRUPT ALL signatures (flip every byte) - let mut forged_quotes = peer_quotes; - for (_peer_id, ref mut quote) in &mut forged_quotes { - for byte in &mut quote.signature { - *byte = byte.wrapping_add(1); - } - } - - let forged_proof = PaymentProof { - proof_of_payment: ProofOfPayment { - peer_quotes: forged_quotes, - }, - tx_hashes, - }; - let forged_bytes = - rmp_serde::to_vec(&forged_proof).map_err(|e| format!("Serialize failed: {e}"))?; - - // Try to store with forged proof - let result = client - .put_chunk_with_proof(Bytes::from(test_data.to_vec()), forged_bytes, &target_peer) - .await; - - assert!( - result.is_err(), - "Attack MUST be rejected with forged signatures" - ); - let err_msg = format!("{}", result.expect_err("just asserted is_err")); - info!("Correctly rejected forged signatures: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} - -/// Attack: Pay for chunk A, try to store chunk B using chunk A's proof. -/// The proof was generated for A's xorname; on-chain verification should fail for B. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_wrong_chunk_address() -> Result<(), Box> { - info!("ATTACK TEST: wrong chunk address (use A's proof for B)"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - // Get quotes and pay for chunk A - let chunk_a_data = b"Attack: this is chunk A with valid payment"; - let (target_peer, quotes) = get_quotes_with_retries(&client, chunk_a_data).await?; - let (proof_bytes_a, _tx_hashes) = build_valid_proof(quotes, &wallet).await?; - - // Try to store chunk B using chunk A's proof - let chunk_b_data = b"Attack: this is chunk B, using A's proof"; - let result = client - .put_chunk_with_proof( - Bytes::from(chunk_b_data.to_vec()), - proof_bytes_a, - &target_peer, - ) - .await; - - assert!( - result.is_err(), - "Attack MUST be rejected: proof was for a different chunk" - ); - let err_msg = format!("{}", result.expect_err("just asserted is_err")); - info!("Correctly rejected wrong chunk address: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} - -/// Attack: Replay chunk A's proof to store chunk B. -/// First legitimately store chunk A, then try to reuse its proof for chunk B. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_replay_different_chunk() -> Result<(), Box> { - info!("ATTACK TEST: replay proof from chunk A to store chunk B"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - // Legitimately upload chunk A - let chunk_a_data = b"Attack: legitimate chunk A for replay test"; - let (target_peer, quotes) = get_quotes_with_retries(&client, chunk_a_data).await?; - let (proof_bytes_a, _tx_hashes) = build_valid_proof(quotes, &wallet).await?; - - // Store chunk A (should succeed) — retry for slow DHT on CI - let mut chunk_a_stored = false; - for attempt in 1..=5u32 { - match client - .put_chunk_with_proof( - Bytes::from(chunk_a_data.to_vec()), - proof_bytes_a.clone(), - &target_peer, - ) - .await - { - Ok(_addr) => { - chunk_a_stored = true; - break; - } - Err(e) => { - warn!("Legitimate store of chunk A attempt {attempt}/5 failed: {e}"); - if attempt < 5 { - let _ = harness.warmup_dht().await; - sleep(Duration::from_secs(3)).await; - } - } - } - } - assert!( - chunk_a_stored, - "Legitimate store of chunk A should succeed after retries" - ); - info!("Chunk A stored successfully (legitimate)"); - - // Now replay A's proof for chunk B - let chunk_b_data = b"Attack: trying to replay A's proof for chunk B"; - let result_b = client - .put_chunk_with_proof( - Bytes::from(chunk_b_data.to_vec()), - proof_bytes_a, - &target_peer, - ) - .await; - - assert!( - result_b.is_err(), - "Replay attack MUST be rejected: proof is for chunk A, not B" - ); - let err_msg = format!("{}", result_b.expect_err("just asserted is_err")); - info!("Correctly rejected replay attack: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} - -/// Attack: Build proof with real quotes but NO on-chain payment (empty `tx_hashes`). -/// Node MUST reject because on-chain verification finds no payment. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_zero_amount_payment() -> Result<(), Box> { - info!("ATTACK TEST: real quotes but no on-chain payment (empty tx_hashes)"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - let test_data = b"Attack: quotes but no payment"; - let (target_peer, quotes) = get_quotes_with_retries(&client, test_data).await?; - - // Build peer_quotes from real quotes but skip on-chain payment - let mut peer_quotes = Vec::with_capacity(quotes.len()); - for (peer_id_str, quote, _price) in quotes { - let encoded = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Peer ID conversion failed: {e}"))?; - peer_quotes.push((encoded, quote)); - } - - // Build proof with valid structure but NO payment - let unpaid_proof = PaymentProof { - proof_of_payment: ProofOfPayment { peer_quotes }, - tx_hashes: vec![], // No on-chain payment! - }; - let proof_bytes = - rmp_serde::to_vec(&unpaid_proof).map_err(|e| format!("Serialize failed: {e}"))?; - - let result = client - .put_chunk_with_proof(Bytes::from(test_data.to_vec()), proof_bytes, &target_peer) - .await; - - assert!( - result.is_err(), - "Attack MUST be rejected: no on-chain payment exists" - ); - let err_msg = format!("{}", result.expect_err("just asserted is_err")); - info!("Correctly rejected zero-amount payment: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} - -/// Attack: Use real quotes but fabricate a random tx hash (no corresponding on-chain tx). -/// Node MUST reject because on-chain verification fails. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_fabricated_tx_hash() -> Result<(), Box> { - info!("ATTACK TEST: fabricated transaction hash"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - let test_data = b"Attack: fabricated tx hash"; - let (target_peer, quotes) = get_quotes_with_retries(&client, test_data).await?; - - // Build peer_quotes from real quotes - let mut peer_quotes = Vec::with_capacity(quotes.len()); - for (peer_id_str, quote, _price) in quotes { - let encoded = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Peer ID conversion failed: {e}"))?; - peer_quotes.push((encoded, quote)); - } - - // Fabricate a fake tx hash - let fake_tx = alloy::primitives::FixedBytes::from([0xDE; 32]); - - let fake_proof = PaymentProof { - proof_of_payment: ProofOfPayment { peer_quotes }, - tx_hashes: vec![fake_tx], - }; - let proof_bytes = - rmp_serde::to_vec(&fake_proof).map_err(|e| format!("Serialize failed: {e}"))?; - - let result = client - .put_chunk_with_proof(Bytes::from(test_data.to_vec()), proof_bytes, &target_peer) - .await; - - assert!( - result.is_err(), - "Attack MUST be rejected: fabricated tx hash has no on-chain payment" - ); - let err_msg = format!("{}", result.expect_err("just asserted is_err")); - info!("Correctly rejected fabricated tx hash: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} - -// =========================================================================== -// Category 3: Advanced Protocol Attacks -// =========================================================================== - -/// Attack: Double-spend the same proof for the same chunk (idempotent check). -/// The first store succeeds; the second returns `AlreadyExists` (not an error). -/// This proves double-spend is prevented by idempotent storage. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_double_spend_same_proof() -> Result<(), Box> { - info!("ATTACK TEST: double-spend same proof for same chunk"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - let test_data = b"Attack: double-spend same proof"; - let (target_peer, quotes) = get_quotes_with_retries(&client, test_data).await?; - let (proof_bytes, _tx_hashes) = build_valid_proof(quotes, &wallet).await?; - - // First store: should succeed — retry for slow DHT on CI - let mut first_stored = false; - for attempt in 1..=5u32 { - match client - .put_chunk_with_proof( - Bytes::from(test_data.to_vec()), - proof_bytes.clone(), - &target_peer, - ) - .await - { - Ok(_addr) => { - first_stored = true; - break; - } - Err(e) => { - warn!("First store attempt {attempt}/5 failed: {e}"); - if attempt < 5 { - let _ = harness.warmup_dht().await; - sleep(Duration::from_secs(3)).await; - } - } - } - } - assert!( - first_stored, - "First store MUST succeed with valid payment after retries" - ); - info!("First store succeeded (legitimate)"); - - // Second store with same proof: should return AlreadyExists (idempotent) - let result2 = client - .put_chunk_with_proof(Bytes::from(test_data.to_vec()), proof_bytes, &target_peer) - .await; - - // AlreadyExists is returned as Ok (it's idempotent success), proving the chunk - // was cached and the proof cannot be used to double-store different data. - match result2 { - Ok(addr) => { - let expected = compute_address(test_data); - assert_eq!(addr, expected, "AlreadyExists should return same address"); - info!("Double-spend correctly returned existing address (idempotent)"); - } - Err(e) => { - // Some implementations may also reject duplicates -- both behaviors are safe - info!("Double-spend rejected outright: {e}"); - } - } - - harness.teardown().await?; - Ok(()) -} - -/// Attack: Corrupt the ML-DSA-65 public key in quotes (replace with random bytes). -/// Node MUST reject because public key parsing or signature verification fails. -#[tokio::test(flavor = "multi_thread")] -#[serial] -#[allow(clippy::too_many_lines)] -async fn test_attack_corrupted_public_key() -> Result<(), Box> { - info!("ATTACK TEST: corrupted ML-DSA-65 public key"); - - let (harness, _testnet, wallet) = setup_full_payment_env().await?; - - let client = QuantumClient::with_defaults() - .with_node(harness.node(0).ok_or("Node 0 not found")?) - .with_wallet(wallet.clone()); - - let test_data = b"Attack: corrupted public key"; - let (target_peer, quotes) = get_quotes_with_retries(&client, test_data).await?; - - // Build peer_quotes and payment - let mut peer_quotes = Vec::with_capacity(quotes.len()); - let mut quotes_for_payment = Vec::with_capacity(quotes.len()); - for (peer_id_str, quote, price) in quotes { - let encoded = hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| format!("Peer ID conversion failed: {e}"))?; - peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push((quote, price)); - } - let payment = SingleNodePayment::from_quotes(quotes_for_payment) - .map_err(|e| format!("Payment creation failed: {e}"))?; - let tx_hashes = payment - .pay(&wallet) - .await - .map_err(|e| format!("Payment failed: {e}"))?; - - // CORRUPT ALL public keys (replace with random bytes of same length) - let mut corrupted_quotes = peer_quotes; - for (_peer_id, ref mut quote) in &mut corrupted_quotes { - let key_len = quote.pub_key.len(); - quote.pub_key = (0..key_len).map(|_| rand::thread_rng().gen()).collect(); - } - - let corrupted_proof = PaymentProof { - proof_of_payment: ProofOfPayment { - peer_quotes: corrupted_quotes, - }, - tx_hashes, - }; - let proof_bytes = - rmp_serde::to_vec(&corrupted_proof).map_err(|e| format!("Serialize failed: {e}"))?; - - let result = client - .put_chunk_with_proof(Bytes::from(test_data.to_vec()), proof_bytes, &target_peer) - .await; - - assert!( - result.is_err(), - "Attack MUST be rejected: corrupted public keys" - ); - let err_msg = format!("{}", result.expect_err("just asserted is_err")); - info!("Correctly rejected corrupted public key: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} - -/// Attack: Use `QuantumClient` without wallet (no proof sent to server). -/// Server-side enforcement MUST reject the storage attempt. -#[tokio::test(flavor = "multi_thread")] -#[serial] -async fn test_attack_client_without_wallet() -> Result<(), Box> { - info!("ATTACK TEST: QuantumClient without wallet"); - - let (harness, _testnet) = setup_enforcement_env().await?; - - // Create client WITHOUT wallet -- sends no payment proof - let client = - QuantumClient::with_defaults().with_node(harness.node(0).ok_or("Node 0 not found")?); - - let test_data = b"Attack: client with no wallet configured"; - let result = client.put_chunk(Bytes::from(test_data.to_vec())).await; - - assert!( - result.is_err(), - "Storage MUST fail without wallet when enforcement is enabled" - ); - let err_msg = format!("{}", result.expect_err("just asserted is_err")); - assert!( - err_msg.to_lowercase().contains("payment"), - "Error must be payment-related, got: {err_msg}" - ); - info!("Correctly rejected client without wallet: {err_msg}"); - - harness.teardown().await?; - Ok(()) -} diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 378e3291..e5bfdd2c 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -15,7 +15,6 @@ use ant_evm::RewardsAddress; use bytes::Bytes; -use evmlib::wallet::Wallet; use evmlib::Network as EvmNetwork; use futures::future::join_all; use rand::Rng; @@ -28,9 +27,9 @@ use saorsa_node::ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, CHUNK_PROTOCOL_ID, }; -use saorsa_node::client::{send_and_await_chunk_response, DataChunk, QuantumClient, XorName}; +use saorsa_node::client::{send_and_await_chunk_response, DataChunk, XorName}; use saorsa_node::payment::{ - EvmVerifierConfig, PaymentProof, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, + EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, }; use saorsa_node::storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; @@ -40,7 +39,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::{broadcast, RwLock}; use tokio::task::JoinHandle; -use tokio::time::{sleep, Instant}; +use tokio::time::Instant; use tracing::{debug, info, warn}; // ============================================================================= @@ -368,12 +367,6 @@ pub struct TestNode { /// ANT protocol handler (`AntProtocol`) for processing chunk PUT/GET requests. pub ant_protocol: Option>, - /// `QuantumClient` for payment-enabled operations. - pub client: Option>, - - /// EVM wallet for payment operations. - pub wallet: Option, - /// Is this a bootstrap node? pub is_bootstrap: bool, @@ -398,152 +391,6 @@ pub struct TestNode { } impl TestNode { - /// Set wallet for payment tests. - /// - /// This updates the node's wallet and creates a new `QuantumClient` configured - /// with both the P2P node and wallet for payment-enabled operations. - pub fn set_wallet(&mut self, wallet: Wallet) { - // Create a new QuantumClient with the P2P node and wallet if available - if let Some(ref p2p_node) = self.p2p_node { - let client = QuantumClient::with_defaults() - .with_node(Arc::clone(p2p_node)) - .with_wallet(wallet.clone()); - self.client = Some(Arc::new(client)); - } - - self.wallet = Some(wallet); - } - - /// Store a chunk using the `QuantumClient` (with payment). - /// - /// This is the payment-enabled variant that uses the `QuantumClient` to handle - /// quote requests, payments, and chunk storage. - /// - /// # Errors - /// - /// Returns an error if the client is not configured or the store operation fails. - pub async fn store_chunk_with_payment(&self, data: &[u8]) -> Result { - let client = self.client.as_ref().ok_or(TestnetError::NodeNotRunning)?; - let data_bytes = Bytes::from(data.to_vec()); - - let mut last_err = String::new(); - for attempt in 1..=5 { - match client.put_chunk(data_bytes.clone()).await { - Ok(addr) => return Ok(addr), - Err(e) => { - last_err = format!("Client PUT error: {e}"); - if attempt < 5 { - warn!("store_chunk_with_payment attempt {attempt}/5 failed: {e}"); - sleep(Duration::from_secs(3)).await; - } - } - } - } - - Err(TestnetError::Storage(last_err)) - } - - /// Store a chunk with payment tracking. - /// - /// This method stores a chunk using the payment-enabled client and records - /// the payment transaction to the provided tracker. This allows tests to - /// verify payment behavior (e.g., that caching prevents duplicate payments). - /// - /// # Arguments - /// - /// * `data` - The chunk data to store - /// * `tracker` - Payment tracker to record transactions - /// - /// # Errors - /// - /// Returns an error if the client/wallet is not configured or the store operation fails. - pub async fn store_chunk_with_tracked_payment( - &self, - data: &[u8], - tracker: &super::harness::PaymentTracker, - ) -> Result { - use saorsa_node::payment::SingleNodePayment; - - // Reuse the client created by set_wallet() - let client = self.client.as_ref().ok_or_else(|| { - TestnetError::Storage( - "Client not configured - use set_wallet() to create a payment-enabled client" - .to_string(), - ) - })?; - let wallet = self.wallet.as_ref().ok_or_else(|| { - TestnetError::Storage("Wallet not configured - use set_wallet()".to_string()) - })?; - - // Compute the chunk address - let address = Self::compute_chunk_address(data); - - // Get quotes from the network (includes peer IDs for proof of payment). - // The target_peer is the closest peer pinned during quoting — we store to - // this peer to guarantee the storage target was paid. - let (target_peer, quotes_with_peers) = client - .get_quotes_from_dht(data) - .await - .map_err(|e| TestnetError::Storage(format!("Failed to get quotes: {e}")))?; - - // Collect peer_quotes and strip peer IDs for SingleNodePayment - let mut peer_quotes: Vec<_> = Vec::with_capacity(quotes_with_peers.len()); - let mut quotes_with_prices: Vec<_> = Vec::with_capacity(quotes_with_peers.len()); - for (peer_id_str, quote, price) in quotes_with_peers { - let encoded_peer_id = - saorsa_node::client::hex_node_id_to_encoded_peer_id(&peer_id_str.to_hex()) - .map_err(|e| { - TestnetError::Storage(format!( - "Failed to convert peer ID '{peer_id_str}': {e}" - )) - })?; - peer_quotes.push((encoded_peer_id, quote.clone())); - quotes_with_prices.push((quote, price)); - } - - // Create payment structure (sorts by price, selects median) - let payment = SingleNodePayment::from_quotes(quotes_with_prices) - .map_err(|e| TestnetError::Storage(format!("Failed to create payment: {e}")))?; - - // Make the payment and get transaction hashes - let tx_hashes = payment - .pay(wallet) - .await - .map_err(|e| TestnetError::Storage(format!("Payment failed: {e}")))?; - - // Record the payment in the tracker - tracker.record_payment(address, tx_hashes.clone()); - - // Build proof AFTER payment with tx hashes included - let proof = PaymentProof { - proof_of_payment: ant_evm::ProofOfPayment { peer_quotes }, - tx_hashes, - }; - let proof_bytes = rmp_serde::to_vec(&proof) - .map_err(|e| TestnetError::Storage(format!("Failed to serialize proof: {e}")))?; - - // Use put_chunk_with_proof to send the pre-built proof, avoiding a - // redundant quote+pay cycle that put_chunk_with_payment would perform. - client - .put_chunk_with_proof(Bytes::from(data.to_vec()), proof_bytes, &target_peer) - .await - .map_err(|e| TestnetError::Storage(format!("Client PUT error: {e}"))) - } - - /// Retrieve a chunk using the `QuantumClient`. - /// - /// # Errors - /// - /// Returns an error if the client is not configured or the retrieval fails. - pub async fn get_chunk_with_client(&self, address: &XorName) -> Result> { - let client = self.client.as_ref().ok_or(TestnetError::NodeNotRunning)?; - - client - .get_chunk(address) - .await - .map_err(|e| TestnetError::Retrieval(format!("Client GET error: {e}"))) - } - /// Check if this node is running. pub async fn is_running(&self) -> bool { matches!( @@ -568,9 +415,6 @@ impl TestNode { handle.abort(); } - // Drop client to release its Arc reference - self.client = None; - *self.state.write().await = NodeState::Stopping; // Shutdown P2P node if running @@ -627,9 +471,10 @@ impl TestNode { // Compute content address let address = Self::compute_chunk_address(data); - // Create PUT request WITHOUT payment proof (EVM disabled in tests) - // When EVM verification is disabled, we send None instead of an empty proof - // to avoid triggering the fail-secure rejection in PaymentVerifier + // Pre-populate payment cache so the handler accepts the store + // without an on-chain proof. + protocol.payment_verifier().cache_insert(address); + let request_id: u64 = rand::thread_rng().gen(); let request = ChunkPutRequest::new(address, data.to_vec()); let message = ChunkMessage { @@ -783,7 +628,8 @@ impl TestNode { ) -> Result { let p2p = self.p2p_node.as_ref().ok_or(TestnetError::NodeNotRunning)?; - // Create PUT request WITHOUT payment proof (EVM disabled in tests) + // Create PUT request without payment proof — caller must pre-populate + // the target node's payment cache via harness.prepopulate_payment_cache_for_peer(). let address = Self::compute_chunk_address(data); let request_id: u64 = rand::thread_rng().gen(); @@ -1156,13 +1002,9 @@ impl TestNetwork { })?); // Initialize AntProtocol for this node with payment enforcement setting - let ant_protocol = Self::create_ant_protocol( - &data_dir, - self.config.payment_enforcement, - self.config.evm_network.clone(), - &identity, - ) - .await?; + let ant_protocol = + Self::create_ant_protocol(&data_dir, self.config.evm_network.clone(), &identity) + .await?; Ok(TestNode { index, @@ -1171,8 +1013,6 @@ impl TestNetwork { data_dir, p2p_node: None, ant_protocol: Some(Arc::new(ant_protocol)), - client: None, - wallet: None, is_bootstrap, state: Arc::new(RwLock::new(NodeState::Pending)), bootstrap_addrs, @@ -1198,7 +1038,6 @@ impl TestNetwork { /// Returns an error if LMDB storage initialisation fails. pub async fn create_ant_protocol( data_dir: &std::path::Path, - payment_enforcement: bool, evm_network: Option, identity: &saorsa_core::identity::NodeIdentity, ) -> Result { @@ -1213,21 +1052,20 @@ impl TestNetwork { .await .map_err(|e| TestnetError::Core(format!("Failed to create LMDB storage: {e}")))?; - // Create payment verifier with EVM enabled/disabled based on test config. - // When payment_enforcement is true and an EVM network is provided, - // use that network (e.g. Anvil) for on-chain verification. + // Create payment verifier (EVM is always on). + // When an EVM network is provided (e.g. Anvil), use it for on-chain verification. + // Otherwise default to ArbitrumSepoliaTest for test nodes. + let rewards_address = RewardsAddress::new(TEST_REWARDS_ADDRESS); let payment_config = PaymentVerifierConfig { evm: EvmVerifierConfig { - enabled: payment_enforcement, network: evm_network.unwrap_or(EvmNetwork::ArbitrumSepoliaTest), }, cache_capacity: TEST_PAYMENT_CACHE_CAPACITY, - local_rewards_address: None, + local_rewards_address: rewards_address, }; let payment_verifier = PaymentVerifier::new(payment_config); // Create quote generator with ML-DSA-65 signing from the test node's identity - let rewards_address = RewardsAddress::new(TEST_REWARDS_ADDRESS); let metrics_tracker = QuotingMetricsTracker::new(TEST_MAX_RECORDS, TEST_INITIAL_RECORDS); let mut quote_generator = QuoteGenerator::new(rewards_address, metrics_tracker); @@ -1262,6 +1100,7 @@ impl TestNetwork { } /// Start a single node. + #[allow(clippy::too_many_lines)] async fn start_node(&mut self, mut node: TestNode) -> Result<()> { debug!("Starting node {} on port {}", node.index, node.port); *node.state.write().await = NodeState::Starting; @@ -1276,7 +1115,9 @@ impl TestNetwork { .build() .map_err(|e| TestnetError::Core(format!("Failed to create core config: {e}")))?; - core_config.bootstrap_peers = node.bootstrap_addrs.clone(); + core_config + .bootstrap_peers + .clone_from(&node.bootstrap_addrs); core_config.diversity_config = Some(CoreDiversityConfig::permissive()); // Inject the ML-DSA identity so the P2PNode's transport peer ID @@ -1327,12 +1168,14 @@ impl TestNetwork { .await { warn!( - "Node {node_index} failed to send response to {source}: {e}" + "Node {node_index} failed to send chunk response to {source}: {e}" ); } } Err(e) => { - warn!("Node {node_index} protocol handler error: {e}"); + warn!( + "Node {node_index} chunk protocol handler error: {e}" + ); } } });