diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2382b67c..09209673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,49 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-targets --all-features -- -D warnings + browser-wasm: + name: Browser WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Check the portable Rust library + run: >- + cargo check -p ant-core + --target wasm32-unknown-unknown + --no-default-features + --features browser-wasm + - name: Lint the portable Rust library + run: >- + cargo clippy -p ant-core + --target wasm32-unknown-unknown + --no-default-features + --features browser-wasm + -- -D warnings + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.15.0 --locked + - name: Generate the browser bindings + working-directory: ant-core + run: >- + wasm-pack build + --target web + --out-dir wasm-tests/pkg + --release + . + --no-default-features + --features browser-wasm + - name: Test the generated browser bindings + working-directory: ant-core + run: >- + node --import ./wasm-tests/setup-wasm.mjs + --test ./wasm-tests/*.test.mjs + test-unit: name: Unit Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index fc94dea5..433c87e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/ant-core/wasm-tests/pkg/ .cargo/config.toml .claude/plans/ .claude/scheduled_tasks.lock diff --git a/Cargo.lock b/Cargo.lock index b61d5377..a65edebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,25 +843,34 @@ dependencies = [ "anyhow", "async-stream", "axum", + "base64", "blake3", "bytes", + "console_error_panic_hook", "flate2", "fs2", "futures", + "futures-channel", "futures-core", "futures-util", + "getrandom 0.2.17", + "gloo-timers", "hex", + "js-sys", "libc", "lru", "openssl", - "postcard", "rand 0.8.6", "reqwest 0.12.28", "rmp-serde", + "saorsa-dht-lookup", + "saorsa-webrtc", "self-replace", "self_encryption", "semver 1.0.28", "serde", + "serde-wasm-bindgen", + "serde_bytes", "serde_json", "serial_test", "sysinfo", @@ -875,7 +884,12 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "utoipa", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", "windows-sys 0.61.2", "xor_name", "zip", @@ -1849,6 +1863,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -2911,6 +2935,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -3255,7 +3291,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.58.0", ] [[package]] @@ -5199,8 +5235,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "454529f8a72b4cf22f7d9c3b009ad9d4ba78520e11f6444ae460795d55c002da" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=1432fa9751d46887dc86767928bd3320a24c6367#1432fa9751d46887dc86767928bd3320a24c6367" dependencies = [ "anyhow", "async-trait", @@ -5215,6 +5250,7 @@ dependencies = [ "parking_lot", "postcard", "rand 0.8.6", + "saorsa-dht-lookup", "saorsa-pqc 0.5.1", "saorsa-transport", "serde", @@ -5228,6 +5264,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "saorsa-dht-lookup" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=1432fa9751d46887dc86767928bd3320a24c6367#1432fa9751d46887dc86767928bd3320a24c6367" +dependencies = [ + "futures-core", + "futures-util", +] + [[package]] name = "saorsa-pqc" version = "0.4.2" @@ -5314,12 +5359,12 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.35.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3284026c300f642077315b782462b22558e24d621265a8deeae23287b1c5542" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=2c6e23cd6cd4db79f475676e25af9334a044a343#2c6e23cd6cd4db79f475676e25af9334a044a343" dependencies = [ "anyhow", "async-trait", "aws-lc-rs", + "base64", "blake3", "bytes", "chrono", @@ -5371,6 +5416,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "saorsa-webrtc" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=9cf337b0dce2ee5b2f6b1b613a047d2be85a9754#9cf337b0dce2ee5b2f6b1b613a047d2be85a9754" +dependencies = [ + "base64", + "blake3", + "chacha20poly1305", + "fips203", + "fips204", + "getrandom 0.2.17", + "hex", + "postcard", + "serde", + "serde_bytes", + "serde_json", + "thiserror 2.0.18", + "tiny-keccak", + "zeroize", +] + [[package]] name = "schannel" version = "0.1.29" @@ -5556,6 +5622,27 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index f3978b22..e5b3feb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ [workspace] members = ["ant-core", "ant-cli"] resolver = "2" + +[patch.crates-io] +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "1432fa9751d46887dc86767928bd3320a24c6367" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "2c6e23cd6cd4db79f475676e25af9334a044a343" } diff --git a/README.md b/README.md index 0f02eeb9..395d8dc2 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,29 @@ A unified CLI and Rust library for storing data on the Autonomi decentralized ne ## Overview -This project provides two crates: +This project provides two Rust crates: -- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Designed to be consumed by any frontend (CLI, GUI, AI agents, REST clients). +- **ant-core** — A headless Rust library containing the Autonomi business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Its `browser` module shares manifest validation, protocol framing, authenticated WebRTC Direct access, lookup, quote verification, and complete public-file workflows with browser applications through the `browser-wasm` feature. - **ant-cli** — A thin CLI binary (`ant`) built on `ant-core`. Data on Autonomi is **content-addressed**. Files are split into encrypted chunks (via [self-encryption](https://en.wikipedia.org/wiki/Convergent_encryption)), each stored at an XOR address derived from its content. A `DataMap` tracks which chunks belong to a file. Payments for storage are made on an EVM-compatible blockchain (Arbitrum). +### Browser WebAssembly client + +Build `ant-core` for browsers without changing native callers: + +```bash +wasm-pack build --target web ant-core \ + --no-default-features --features browser-wasm +``` + +This produces the low-level `wasm-bindgen` API backed by the shared Rust +implementation. The companion +[`ant-client-browser-sdk`](https://github.com/WithAutonomi/ant-client-browser-sdk) +project owns the TypeScript API, wallet adapters, browser storage and worker +integration, media streaming bridge, runnable examples, and browser end-to-end +tests. + ## Installation ### Linux / macOS diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 3fcff457..72228749 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -6,30 +6,50 @@ description = "Headless Rust library for the Autonomi network: data storage and license = "MIT OR Apache-2.0" repository = "https://github.com/WithAutonomi/ant-client" readme = "../README.md" +exclude = ["wasm-tests"] + +[lib] +crate-type = ["rlib", "cdylib"] [dependencies] -# Node management -async-stream = "0.3" -axum = "0.8" -flate2 = "1" +# Cross-platform data primitives. These are the only dependencies compiled for +# the browser-WASM target; native networking and node management stay behind +# the `native` feature below. blake3 = "1" -fs2 = "0.4" -futures-core = "0.3" -futures-util = "0.3" -self-replace = "1" -semver = "1" +base64 = "0.22" +bytes = "1" +hex = "0.4" +rmp-serde = "1" serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" serde_json = "1" -reqwest = { version = "0.12", features = ["json", "stream"] } -tar = "0.4" -tempfile = "3" -toml = "0.8" +self_encryption = "0.36" +saorsa-dht-lookup = { version = "0.1.0", git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "1432fa9751d46887dc86767928bd3320a24c6367" } +# Portable WebRTC Direct profile shared with ant-node. +saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "9cf337b0dce2ee5b2f6b1b613a047d2be85a9754" } thiserror = "2" -tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } -utoipa = { version = "5", features = ["axum_extras"] } -zip = "2" -tower-http = { version = "0.6.8", features = ["cors"] } +url = "2" +web-time = "1.1" + +# Node management +async-stream = { version = "0.3", optional = true } +axum = { version = "0.8", optional = true } +flate2 = { version = "1", optional = true } +fs2 = { version = "0.4", optional = true } +futures-core = { version = "0.3", optional = true } +futures-channel = { version = "0.3", optional = true } +futures-util = { version = "0.3", optional = true } +self-replace = { version = "1", optional = true } +semver = { version = "1", optional = true } +reqwest = { version = "0.12", features = ["json", "stream"], optional = true } +tar = { version = "0.4", optional = true } +tempfile = { version = "3", optional = true } +toml = { version = "0.8", optional = true } +tokio = { version = "1", features = ["full"], optional = true } +tokio-util = { version = "0.7", features = ["rt"], optional = true } +utoipa = { version = "5", features = ["axum_extras"], optional = true } +zip = { version = "2", optional = true } +tower-http = { version = "0.6.8", features = ["cors"], optional = true } # Data operations # Wire protocol crate: gives us `ant_protocol::{chunk, payment, …}` plus @@ -37,23 +57,18 @@ tower-http = { version = "0.6.8", features = ["cors"] } # under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for # those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc # deps here or the version can skew between ant-client and ant-node. -ant-protocol = "2.3.1" -xor_name = "5" -self_encryption = "0.36" -futures = "0.3" -postcard = { version = "1.1.3", features = ["use-std"] } -rmp-serde = "1" -hex = "0.4" -tracing = "0.1" -bytes = "1" -lru = "0.16" -rand = "0.8" +ant-protocol = { version = "2.3.1", default-features = false, optional = true } +xor_name = { version = "5", optional = true } +futures = { version = "0.3", optional = true } +tracing = { version = "0.1", optional = true } +lru = { version = "0.16", optional = true } +rand = { version = "0.8", optional = true } # Used by the daemon supervisor to scan the OS process table when adopting # running nodes whose pid file is missing (e.g. nodes spawned by a pre-adoption # daemon). Happy-path adoption reads the pid file directly and doesn't touch # sysinfo, so the crate is loaded lazily and the cost is bounded to first-time # upgrade scenarios. -sysinfo = { version = "0.32", default-features = false, features = ["system"] } +sysinfo = { version = "0.32", default-features = false, features = ["system"], optional = true } # ant-node is optional. It is only linked for the `LocalDevnet` wrapper # that spawns a local in-process network for development and testing. # Enable with `--features devnet`. @@ -66,7 +81,33 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"] } # track the matching released version carrying the same saorsa-core / # ant-protocol lineage. ant-node = { version = "0.16.0", optional = true } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } + +# Browser bindings are optional so ordinary native consumers do not pull the +# wasm-bindgen toolchain into their dependency graph. +js-sys = { version = "0.3", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +web-sys = { version = "0.3", optional = true, features = [ + "Event", + "MessageEvent", + "RtcConfiguration", + "RtcDataChannel", + "RtcDataChannelInit", + "RtcDataChannelState", + "RtcDataChannelType", + "RtcPeerConnection", + "RtcSdpType", + "RtcSessionDescription", + "RtcSessionDescriptionInit", +] } +gloo-timers = { version = "0.3", features = ["futures"], optional = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +# self_encryption/rand use getrandom 0.2. Browser entropy is supplied by the +# Web Crypto API through its `js` feature. +getrandom = { version = "0.2", features = ["js"] } +console_error_panic_hook = { version = "0.1", optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -79,12 +120,52 @@ openssl = { version = "0.10", features = ["vendored"] } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console", "Win32_System_Threading"] } [features] -# No features enabled by default — consumers that want to spawn a local -# devnet opt in with `features = ["devnet"]`. -default = [] +# Preserve the existing native ant-core API for ordinary consumers. WASM +# consumers build with `--no-default-features --features browser-wasm`. +default = ["native"] +native = [ + "dep:ant-protocol", + "ant-protocol/logging", + "dep:async-stream", + "dep:axum", + "dep:flate2", + "dep:fs2", + "dep:futures", + "dep:futures-core", + "dep:futures-util", + "dep:lru", + "dep:rand", + "dep:reqwest", + "dep:self-replace", + "dep:semver", + "dep:sysinfo", + "dep:tar", + "dep:tempfile", + "dep:tokio", + "dep:tokio-util", + "dep:toml", + "dep:tower-http", + "dep:tracing", + "dep:tracing-subscriber", + "dep:utoipa", + "dep:xor_name", + "dep:zip", +] +browser-wasm = [ + "dep:console_error_panic_hook", + "dep:futures-channel", + "dep:futures-util", + "dep:gloo-timers", + "dep:js-sys", + "dep:serde-wasm-bindgen", + "dep:tracing", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] # Enable `LocalDevnet` (ant-core/src/node/devnet.rs) which wraps # `ant_node::devnet::Devnet` and an Anvil EVM testnet. -devnet = ["dep:ant-node"] +devnet = ["native", "dep:ant-node"] # Expose test-only client seams (e.g. forcing the ADR-0002 extended PUT # fallback) used by the e2e/integration test suite. test-utils = [] diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs new file mode 100644 index 00000000..174111bb --- /dev/null +++ b/ant-core/src/browser.rs @@ -0,0 +1,990 @@ +//! Cross-platform Autonomi client logic and browser bindings. +//! +//! The manifest, protocol, payment, and immutable-data modules are portable +//! Rust shared by native clients and the browser WASM package. The +//! `browser-wasm` feature additionally provides the `web-sys` WebRTC Direct +//! host adapter. Higher-level browser integration is owned by the companion +//! `ant-client-browser-sdk` project. + +pub mod manifest; +pub mod payment; +pub mod protocol; + +pub use manifest::{ + parse_browser_manifest, validate_browser_payment_network, BrowserManifest, + BrowserManifestEndpoint, PublicFileDescriptor, BROWSER_MANIFEST_VERSION, +}; +pub use payment::{storage_payment_total, verify_storage_quote, VerifiedStorageQuote}; +pub use protocol::{ + parse_webrtc_direct_multiaddr, BrowserPaymentNetwork, BrowserQuoteArtifact, + WebRtcDirectEndpoint, BROWSER_PROTOCOL_NAME, BROWSER_PROTOCOL_VERSION, + WEBRTC_DIRECT_DATA_CHANNEL, +}; + +#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] +mod wasm_transport; + +use bytes::Bytes; +use self_encryption::{DataMap, EncryptedChunk}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Maximum file size accepted by the browser API (1 GB decimal). +/// +/// The page upload path streams through a worker and browser storage. Complete +/// downloads and the legacy whole-buffer encryption binding remain memory-bound. +pub const MAX_BROWSER_FILE_BYTES: usize = 1_000_000_000; + +/// One native self-encryption chunk descriptor exposed to the browser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserChunkInfo { + /// Zero-based chunk index. + pub index: usize, + /// BLAKE3 address of the encrypted record. + pub dst_hash: String, + /// BLAKE3 hash of the plaintext chunk. + pub src_hash: String, + /// Plaintext chunk size. + pub src_size: usize, +} + +/// One content-addressed record ready for network upload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserRecord { + /// Lowercase hexadecimal BLAKE3 record address. + pub address: String, + /// Raw record bytes. `serde_bytes` maps this to `Uint8Array` in WASM. + #[serde(with = "serde_bytes")] + pub content: Vec, +} + +/// Metadata for a content-addressed record staged outside WASM memory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserRecordInfo { + /// Lowercase hexadecimal BLAKE3 record address. + pub address: String, + /// Raw record size in bytes. + pub size: usize, +} + +/// Result of streaming self-encryption whose record bytes live in browser storage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserStagedFile { + /// Display filename supplied by the selected browser `File`. + pub name: String, + /// Browser MIME type, or `application/octet-stream` when none was supplied. + pub content_type: String, + /// Public DataMap record address. + pub address: String, + /// Whole-file plaintext BLAKE3 hash. + pub blake3: String, + /// Plaintext file size. + pub size: usize, + /// Serialized public DataMap size. + pub data_map_size: usize, + /// Native root DataMap chunk descriptors. + pub chunks: Vec, + /// Staged encrypted records followed by the public DataMap record. + pub records: Vec, +} + +/// Result of native public-file self-encryption for a browser upload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserEncryptedFile { + /// Public DataMap record address. + pub address: String, + /// Whole-file plaintext BLAKE3 hash. + pub blake3: String, + /// Serialized public DataMap size. + pub data_map_size: usize, + /// Native root DataMap chunk descriptors. + pub chunks: Vec, + /// Encrypted data records followed by the public DataMap record. + pub records: Vec, +} + +/// Browser immutable-data processing error. +#[derive(Debug, thiserror::Error)] +pub enum BrowserError { + /// Input did not satisfy the browser API limits. + #[error("invalid browser data: {0}")] + Invalid(String), + /// Native self-encryption failed. + #[error("self-encryption failed: {0}")] + SelfEncryption(String), + /// Public DataMap encoding or decoding failed. + #[error("DataMap serialization failed: {0}")] + DataMap(String), +} + +/// BLAKE3-address bytes using the same lowercase hexadecimal representation as +/// the native chunk protocol. +#[must_use] +pub fn content_address(content: &[u8]) -> String { + blake3::hash(content).to_hex().to_string() +} + +/// Verify raw record bytes against a lowercase or uppercase hexadecimal BLAKE3 +/// address. +pub fn verify_record(address: &str, content: &[u8]) -> Result<(), BrowserError> { + let expected = address.strip_prefix("0x").unwrap_or(address); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(BrowserError::Invalid( + "record address must be 32 hexadecimal bytes".to_string(), + )); + } + let actual = content_address(content); + if !actual.eq_ignore_ascii_case(expected) { + return Err(BrowserError::Invalid(format!( + "BLAKE3 mismatch: expected {}, received {actual}", + expected.to_ascii_lowercase() + ))); + } + Ok(()) +} + +/// Encrypt a complete public file with the native `self_encryption 0.36` +/// implementation and append its MessagePack DataMap as a public record. +pub fn encrypt_public_file(content: &[u8]) -> Result { + if content.len() < self_encryption::MIN_ENCRYPTABLE_BYTES { + return Err(BrowserError::Invalid(format!( + "self-encryption requires at least {} bytes", + self_encryption::MIN_ENCRYPTABLE_BYTES + ))); + } + if content.len() > MAX_BROWSER_FILE_BYTES { + return Err(BrowserError::Invalid(format!( + "browser files are limited to {MAX_BROWSER_FILE_BYTES} bytes" + ))); + } + + let whole_file_hash = content_address(content); + let (published_data_map, encrypted_chunks) = + self_encryption::encrypt(Bytes::copy_from_slice(content)) + .map_err(|error| BrowserError::SelfEncryption(error.to_string()))?; + let root_data_map = { + let encrypted_by_address = encrypted_chunks + .iter() + .map(|chunk| (*blake3::hash(&chunk.content).as_bytes(), &chunk.content)) + .collect::>(); + let mut get_local_chunk = |address: self_encryption::XorName| { + encrypted_by_address + .get(&address.0) + .map(|content| (*content).clone()) + .ok_or_else(|| { + self_encryption::Error::Generic(format!( + "self-encryption output omitted DataMap chunk {}", + hex::encode(address.0) + )) + }) + }; + self_encryption::get_root_data_map(published_data_map.clone(), &mut get_local_chunk) + .map_err(|error| BrowserError::SelfEncryption(error.to_string()))? + }; + let chunks = chunk_infos(&root_data_map); + + let mut records: Vec = encrypted_chunks + .into_iter() + .map(|chunk| BrowserRecord { + address: content_address(&chunk.content), + content: chunk.content.to_vec(), + }) + .collect(); + let encoded_data_map = rmp_serde::to_vec(&published_data_map) + .map_err(|error| BrowserError::DataMap(error.to_string()))?; + let address = content_address(&encoded_data_map); + let data_map_size = encoded_data_map.len(); + records.push(BrowserRecord { + address: address.clone(), + content: encoded_data_map, + }); + + Ok(BrowserEncryptedFile { + address, + blake3: whole_file_hash, + data_map_size, + chunks, + records, + }) +} + +/// Decode and normalize a native public DataMap. +pub fn decode_public_data_map(content: &[u8]) -> Result, BrowserError> { + let data_map: DataMap = + rmp_serde::from_slice(content).map_err(|error| BrowserError::DataMap(error.to_string()))?; + Ok(chunk_infos(&data_map)) +} + +/// Reconstruct a public file with native self-encryption after verifying every +/// encrypted record against its DataMap destination address. +pub fn decrypt_public_file( + data_map_content: &[u8], + encrypted_contents: &[Vec], +) -> Result, BrowserError> { + let data_map: DataMap = rmp_serde::from_slice(data_map_content) + .map_err(|error| BrowserError::DataMap(error.to_string()))?; + let available = encrypted_contents + .iter() + .map(|content| *blake3::hash(content).as_bytes()) + .collect::>(); + for info in data_map.infos() { + if !available.contains(&info.dst_hash.0) { + return Err(BrowserError::Invalid(format!( + "record set does not contain DataMap chunk {}; a record may be missing or corrupt", + hex::encode(info.dst_hash.0) + ))); + } + } + let encrypted_chunks = encrypted_contents + .iter() + .map(|content| EncryptedChunk { + content: Bytes::copy_from_slice(content), + }) + .collect::>(); + self_encryption::decrypt(&data_map, &encrypted_chunks) + .map(|bytes| bytes.to_vec()) + .map_err(|error| BrowserError::SelfEncryption(error.to_string())) +} + +fn chunk_infos(data_map: &DataMap) -> Vec { + data_map + .infos() + .iter() + .map(|info| BrowserChunkInfo { + index: info.index, + dst_hash: hex::encode(info.dst_hash.0), + src_hash: hex::encode(info.src_hash.0), + src_size: info.src_size, + }) + .collect() +} + +#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] +mod wasm { + use super::manifest::parse_browser_manifest; + use super::payment::{payment_quote_hash, verify_storage_quote, BrowserQuoteArtifact}; + use super::protocol::{ + ice_password_from_sdp, parse_response_frame, parse_webrtc_direct_multiaddr, + server_answer_sdp, v2_server_ice_credential, BrowserEndpointInput, + }; + use super::{ + chunk_infos, content_address, decrypt_public_file, encrypt_public_file, verify_record, + BrowserRecord, BrowserRecordInfo, BrowserStagedFile, MAX_BROWSER_FILE_BYTES, + }; + use bytes::Bytes; + use js_sys::{Array, Function, Promise, Uint8Array}; + use saorsa_dht_lookup::{ + run_iterative_lookup, IterativeLookup, LookupConfig, LookupKey, LookupNode, LookupQuery, + LookupQueryOutcome, + }; + use serde::{Deserialize, Serialize}; + use std::cell::{Cell, RefCell}; + use std::collections::HashMap; + use std::rc::Rc; + use wasm_bindgen::prelude::*; + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + #[derive(Debug, Serialize)] + struct BrowserSessionDescription { + #[serde(rename = "type")] + description_type: &'static str, + sdp: String, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(untagged)] + enum BrowserLookupEndpoint { + Structured { multiaddr: String }, + Multiaddr(String), + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + struct BrowserLookupNode { + peer_id: String, + #[serde(default)] + native_addresses: Vec, + #[serde(default)] + reliability: f64, + #[serde(default)] + webrtc_direct: Option, + } + + #[derive(Debug, Serialize)] + struct BrowserLookupBatch { + target: String, + count: usize, + iteration: usize, + candidates: Vec, + } + + #[derive(Debug, Deserialize)] + #[serde(tag = "status", rename_all = "snake_case")] + enum BrowserLookupQueryOutcome { + Succeeded { + responder: String, + #[serde(default)] + candidates: Vec, + }, + Failed { + responder: String, + }, + Unresponsive { + responder: String, + }, + } + + #[derive(Debug, Clone)] + struct BrowserLookupCandidate { + peer_id: LookupKey, + wire: BrowserLookupNode, + } + + impl LookupNode for BrowserLookupCandidate { + fn lookup_peer_id(&self) -> LookupKey { + self.peer_id + } + } + + impl BrowserLookupCandidate { + fn parse(mut wire: BrowserLookupNode) -> Result { + let peer_id = parse_lookup_key(&wire.peer_id, "peer ID")?; + wire.peer_id = hex::encode(peer_id); + Ok(Self { peer_id, wire }) + } + } + + /// Shared Saorsa iterative lookup state driven by browser WebRtcDirect. + #[wasm_bindgen(js_name = BrowserIterativeLookup)] + pub struct BrowserIterativeLookup { + lookup: IterativeLookup, + known_endpoints: HashMap, + } + + #[wasm_bindgen(js_class = BrowserIterativeLookup)] + impl BrowserIterativeLookup { + /// Construct a browser lookup using the same scheduler as native QUIC. + #[wasm_bindgen(constructor)] + pub fn new( + target: &str, + count: usize, + alpha: usize, + max_iterations: usize, + ) -> Result { + let target = parse_lookup_key(target, "lookup target")?; + let config = LookupConfig { + count, + alpha, + max_iterations, + ..LookupConfig::saorsa(count) + }; + let lookup = IterativeLookup::new(target, config) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(Self { + lookup, + known_endpoints: HashMap::new(), + }) + } + + /// Add validated bootstrap or FIND_NODE candidates. + #[wasm_bindgen(js_name = addCandidates)] + pub fn add_candidates(&mut self, nodes: JsValue) -> Result<(), JsValue> { + for candidate in parse_lookup_nodes(nodes)? { + self.add_candidate(candidate); + } + Ok(()) + } + + /// Run the complete shared Saorsa walk through a WebRtcDirect batch callback. + #[wasm_bindgen(js_name = run)] + pub async fn run(&mut self, query_batch: Function) -> Result { + let mut query = BrowserLookupQuery { + callback: query_batch, + known_endpoints: &mut self.known_endpoints, + }; + run_iterative_lookup(&mut self.lookup, &mut query) + .await + .map(|termination| format!("{termination:?}")) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Successful responders in final closest-first order. + #[wasm_bindgen(js_name = results)] + pub fn results(&self) -> Result { + let nodes = self + .lookup + .results() + .into_iter() + .map(|candidate| candidate.wire) + .collect::>(); + serde_wasm_bindgen::to_value(&nodes) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Peer IDs selected for network queries, in query order. + #[wasm_bindgen(js_name = queriedPeers)] + pub fn queried_peers(&self) -> Result { + let peers = self + .lookup + .queried_peers() + .iter() + .map(hex::encode) + .collect::>(); + serde_wasm_bindgen::to_value(&peers) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + } + + impl BrowserIterativeLookup { + fn add_candidate(&mut self, candidate: BrowserLookupCandidate) { + if let Some(candidate) = + resolve_candidate_endpoint(&mut self.known_endpoints, candidate) + { + let _ = self.lookup.add_candidate(candidate); + } + } + } + + struct BrowserLookupQuery<'a> { + callback: Function, + known_endpoints: &'a mut HashMap, + } + + impl LookupQuery for BrowserLookupQuery<'_> { + type Error = String; + + async fn query_batch( + &mut self, + target: LookupKey, + count: usize, + iteration: usize, + batch: Vec, + ) -> Result>, Self::Error> { + let request = BrowserLookupBatch { + target: hex::encode(target), + count, + iteration, + candidates: batch.into_iter().map(|candidate| candidate.wire).collect(), + }; + let request = serde_wasm_bindgen::to_value(&request) + .map_err(|error| format!("could not encode lookup batch: {error}"))?; + let returned = self + .callback + .call1(&JsValue::NULL, &request) + .map_err(js_error_message)?; + let returned = JsFuture::from(Promise::resolve(&returned)) + .await + .map_err(js_error_message)?; + let outcomes: Vec = serde_wasm_bindgen::from_value(returned) + .map_err(|error| format!("invalid lookup batch response: {error}"))?; + + outcomes + .into_iter() + .map(|outcome| match outcome { + BrowserLookupQueryOutcome::Succeeded { + responder, + candidates, + } => { + let responder = parse_lookup_key(&responder, "lookup responder") + .map_err(js_error_message)?; + let candidates = candidates + .into_iter() + .map(BrowserLookupCandidate::parse) + .collect::, _>>() + .map_err(js_error_message)? + .into_iter() + .filter_map(|candidate| { + resolve_candidate_endpoint(self.known_endpoints, candidate) + }) + .collect(); + Ok(LookupQueryOutcome::Succeeded { + responder, + candidates, + }) + } + BrowserLookupQueryOutcome::Failed { responder } => { + parse_lookup_key(&responder, "lookup responder") + .map(|responder| LookupQueryOutcome::Failed { responder }) + .map_err(js_error_message) + } + BrowserLookupQueryOutcome::Unresponsive { responder } => { + parse_lookup_key(&responder, "lookup responder") + .map(|responder| LookupQueryOutcome::Unresponsive { responder }) + .map_err(js_error_message) + } + }) + .collect() + } + } + + fn resolve_candidate_endpoint( + known_endpoints: &mut HashMap, + mut candidate: BrowserLookupCandidate, + ) -> Option { + if let Some(endpoint) = candidate.wire.webrtc_direct.clone() { + known_endpoints.insert(candidate.peer_id, endpoint); + } else if let Some(endpoint) = known_endpoints.get(&candidate.peer_id) { + candidate.wire.webrtc_direct = Some(endpoint.clone()); + } + candidate.wire.webrtc_direct.as_ref()?; + Some(candidate) + } + + fn js_error_message(value: JsValue) -> String { + value + .as_string() + .unwrap_or_else(|| format!("JavaScript lookup callback failed: {value:?}")) + } + + fn parse_lookup_nodes(value: JsValue) -> Result, JsValue> { + let nodes: Vec = serde_wasm_bindgen::from_value(value) + .map_err(|error| JsValue::from_str(&format!("invalid lookup nodes: {error}")))?; + nodes + .into_iter() + .map(BrowserLookupCandidate::parse) + .collect() + } + + fn parse_lookup_key(value: &str, label: &str) -> Result { + let value = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(value) + .map_err(|error| JsValue::from_str(&format!("invalid {label}: {error}")))?; + bytes.try_into().map_err(|bytes: Vec| { + JsValue::from_str(&format!( + "invalid {label}: expected 32 bytes, received {}", + bytes.len() + )) + }) + } + + /// Install a readable panic hook for browser developer tools. + #[wasm_bindgen(start)] + pub fn start() { + console_error_panic_hook::set_once(); + } + + /// Validate and normalize a WebRTC Direct multiaddress in shared Rust. + #[wasm_bindgen(js_name = parseWebRtcDirectMultiaddr)] + pub fn parse_webrtc_direct_multiaddr_wasm(endpoint: JsValue) -> Result { + let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let parsed = parse_webrtc_direct_multiaddr(input.multiaddr()) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + serde_wasm_bindgen::to_value(&parsed).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Decode and bound-check a complete WebRTC browser response frame. + #[wasm_bindgen(js_name = parseResponseFrame)] + pub fn parse_response_frame_wasm(frame: &[u8]) -> Result { + let parsed = + parse_response_frame(frame).map_err(|error| JsValue::from_str(&error.to_string()))?; + parsed + .serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Build the ICE-lite answer pinned by a WebRTC Direct endpoint. + #[wasm_bindgen(js_name = serverAnswerFromEndpoint)] + pub fn server_answer_from_endpoint_wasm( + endpoint: JsValue, + ice_credential: &str, + ) -> Result { + let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let endpoint = parse_webrtc_direct_multiaddr(input.multiaddr()) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let sdp = server_answer_sdp(&endpoint, ice_credential) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + serde_wasm_bindgen::to_value(&BrowserSessionDescription { + description_type: "answer", + sdp, + }) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Derive the v2 server ufrag from an unchanged browser local description. + #[wasm_bindgen(js_name = webRtcDirectV2ServerCredential)] + pub fn web_rtc_direct_v2_server_credential_wasm(local_sdp: &str) -> Result { + let password = ice_password_from_sdp(local_sdp) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + v2_server_ice_credential(&password).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Validate and normalize browser bootstrap and public-file metadata. + #[wasm_bindgen(js_name = parseBrowserManifest)] + pub fn parse_browser_manifest_wasm(value: JsValue) -> Result { + let value: serde_json::Value = serde_wasm_bindgen::from_value(value) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let manifest = + parse_browser_manifest(value).map_err(|error| JsValue::from_str(&error.to_string()))?; + serde_wasm_bindgen::to_value(&manifest) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Compute the native EVM `PaymentQuote` hash. + #[wasm_bindgen(js_name = paymentQuoteHash)] + #[must_use] + pub fn payment_quote_hash_wasm( + signed_bytes: &[u8], + public_key: &[u8], + signature: &[u8], + ) -> String { + hex::encode(payment_quote_hash(signed_bytes, public_key, signature)) + } + + /// Fully verify a storage quote before exposing it to a wallet signer. + #[wasm_bindgen(js_name = verifyStorageQuote)] + pub fn verify_storage_quote_wasm( + quote: JsValue, + expected_address: &str, + expected_peer_id: &str, + ) -> Result { + let quote: BrowserQuoteArtifact = serde_wasm_bindgen::from_value(quote) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let verified = verify_storage_quote(quote, expected_address, expected_peer_id) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + serde_wasm_bindgen::to_value(&verified) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Native `self_encryption` plus public DataMap generation. + #[wasm_bindgen(js_name = encryptPublicFile)] + pub fn encrypt_public_file_wasm(content: &[u8]) -> Result { + let encrypted = + encrypt_public_file(content).map_err(|error| JsValue::from_str(&error.to_string()))?; + serde_wasm_bindgen::to_value(&encrypted) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Incremental self-encryptor used from a worker with a synchronous file reader. + /// + /// Each call to `nextRecord` materializes at most one encrypted record. This + /// lets JavaScript persist the record before asking WASM for the next one, + /// keeping plaintext and ciphertext file-sized buffers out of the page. + #[wasm_bindgen(js_name = BrowserFileEncryptor)] + pub struct BrowserFileEncryptor { + stream: self_encryption::EncryptionStream>>, + file_size: usize, + bytes_read: Rc>, + read_error: Rc>>, + whole_file_hasher: Rc>, + data_map_records: HashMap<[u8; 32], Bytes>, + records: Vec, + data_map_record_yielded: bool, + } + + #[wasm_bindgen(js_class = BrowserFileEncryptor)] + impl BrowserFileEncryptor { + /// Create an encryptor around a synchronous `(offset, length) => Uint8Array` reader. + /// + /// Browsers expose synchronous `File` reads only inside dedicated workers, + /// so page code should construct this class there rather than on the UI thread. + #[wasm_bindgen(constructor)] + pub fn new(file_size: usize, read_chunk: Function) -> Result { + if file_size < self_encryption::MIN_ENCRYPTABLE_BYTES { + return Err(JsValue::from_str(&format!( + "self-encryption requires at least {} bytes", + self_encryption::MIN_ENCRYPTABLE_BYTES + ))); + } + if file_size > MAX_BROWSER_FILE_BYTES { + return Err(JsValue::from_str(&format!( + "browser files are limited to {MAX_BROWSER_FILE_BYTES} bytes" + ))); + } + + let bytes_read = Rc::new(Cell::new(0usize)); + let iterator_bytes_read = Rc::clone(&bytes_read); + let read_error = Rc::new(RefCell::new(None)); + let iterator_error = Rc::clone(&read_error); + let whole_file_hasher = Rc::new(RefCell::new(blake3::Hasher::new())); + let iterator_hasher = Rc::clone(&whole_file_hasher); + let iterator = std::iter::from_fn(move || { + if iterator_error.borrow().is_some() { + return None; + } + let offset = iterator_bytes_read.get(); + if offset >= file_size { + return None; + } + let length = (file_size - offset).min(self_encryption::MAX_CHUNK_SIZE); + let returned = match read_chunk.call2( + &JsValue::NULL, + &JsValue::from_f64(offset as f64), + &JsValue::from_f64(length as f64), + ) { + Ok(returned) => returned, + Err(error) => { + *iterator_error.borrow_mut() = Some(js_error_message(error)); + return None; + } + }; + if !returned.is_instance_of::() { + *iterator_error.borrow_mut() = Some(format!( + "file reader returned a non-Uint8Array at byte offset {offset}" + )); + return None; + } + let returned = Uint8Array::new(&returned); + let actual = returned.length() as usize; + if actual != length { + *iterator_error.borrow_mut() = Some(format!( + "file reader returned {actual} bytes at offset {offset}, expected {length}" + )); + return None; + } + let mut content = vec![0u8; actual]; + returned.copy_to(&mut content); + iterator_hasher.borrow_mut().update(&content); + iterator_bytes_read.set(offset + actual); + Some(Bytes::from(content)) + }); + let stream = self_encryption::stream_encrypt( + file_size, + Box::new(iterator) as Box>, + ) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + + Ok(Self { + stream, + file_size, + bytes_read, + read_error, + whole_file_hasher, + data_map_records: HashMap::new(), + records: Vec::new(), + data_map_record_yielded: false, + }) + } + + /// Produce the next encrypted record, or `undefined` once all records are staged. + #[wasm_bindgen(js_name = nextRecord)] + pub fn next_record(&mut self) -> Result { + if self.data_map_record_yielded { + return Ok(JsValue::UNDEFINED); + } + + let next = self.stream.chunks().next(); + if let Some(error) = self.read_error.borrow().as_ref() { + return Err(JsValue::from_str(error)); + } + if let Some(result) = next { + let (hash, content) = result.map_err(|error| { + JsValue::from_str(&format!("self-encryption failed: {error}")) + })?; + // Once `datamap()` becomes available, stream output consists only + // of the small encrypted child DataMaps needed to resolve the root. + if self.stream.datamap().is_some() { + self.data_map_records.insert(hash.0, content.clone()); + } + return self.serialize_record(hex::encode(hash.0), content.to_vec()); + } + + let published_data_map = self.stream.datamap().ok_or_else(|| { + JsValue::from_str("self-encryption ended before producing a DataMap") + })?; + let encoded = rmp_serde::to_vec(published_data_map).map_err(|error| { + JsValue::from_str(&format!("DataMap serialization failed: {error}")) + })?; + let address = content_address(&encoded); + self.data_map_record_yielded = true; + self.serialize_record(address, encoded) + } + + /// Return upload metadata after `nextRecord` has reached `undefined`. + pub fn finish(&self, name: &str, content_type: &str) -> Result { + if !self.data_map_record_yielded { + return Err(JsValue::from_str( + "all encrypted records must be staged before finishing", + )); + } + if self.bytes_read.get() != self.file_size { + return Err(JsValue::from_str(&format!( + "file reader supplied {} bytes, expected {}", + self.bytes_read.get(), + self.file_size + ))); + } + let published_data_map = self + .stream + .datamap() + .ok_or_else(|| JsValue::from_str("self-encryption did not produce a DataMap"))?; + let mut get_local_chunk = |address: self_encryption::XorName| { + self.data_map_records + .get(&address.0) + .cloned() + .ok_or_else(|| { + self_encryption::Error::Generic(format!( + "streaming output omitted DataMap chunk {}", + hex::encode(address.0) + )) + }) + }; + let root_data_map = self_encryption::get_root_data_map( + published_data_map.clone(), + &mut get_local_chunk, + ) + .map_err(|error| JsValue::from_str(&format!("self-encryption failed: {error}")))?; + let public_record = self.records.last().ok_or_else(|| { + JsValue::from_str("self-encryption omitted the public DataMap record") + })?; + let staged = BrowserStagedFile { + name: name.to_string(), + content_type: if content_type.is_empty() { + "application/octet-stream".to_string() + } else { + content_type.to_string() + }, + address: public_record.address.clone(), + blake3: self + .whole_file_hasher + .borrow() + .clone() + .finalize() + .to_hex() + .to_string(), + size: self.file_size, + data_map_size: public_record.size, + chunks: chunk_infos(&root_data_map), + records: self.records.clone(), + }; + serde_wasm_bindgen::to_value(&staged) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + } + + impl BrowserFileEncryptor { + fn serialize_record( + &mut self, + address: String, + content: Vec, + ) -> Result { + self.records.push(BrowserRecordInfo { + address: address.clone(), + size: content.len(), + }); + serde_wasm_bindgen::to_value(&BrowserRecord { address, content }) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + } + + /// Native BLAKE3 content address. + #[wasm_bindgen(js_name = contentAddress)] + #[must_use] + pub fn content_address_wasm(content: &[u8]) -> String { + content_address(content) + } + + /// Verify one content-addressed record with native BLAKE3. + #[wasm_bindgen(js_name = verifyRecord)] + pub fn verify_record_wasm(address: &str, content: &[u8]) -> Result { + verify_record(address, content).map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(content_address(content)) + } + + /// Decode a native public DataMap for browser-side record retrieval. + #[wasm_bindgen(js_name = decodePublicDataMap)] + pub fn decode_public_data_map_wasm(content: &[u8]) -> Result { + let chunks = super::decode_public_data_map(content) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + serde_wasm_bindgen::to_value(&chunks).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Native public DataMap decoding and whole-file reconstruction. + #[wasm_bindgen(js_name = decryptPublicFile)] + pub fn decrypt_public_file_wasm( + data_map_content: &[u8], + encrypted_contents: Array, + ) -> Result { + let encrypted_contents = encrypted_contents + .iter() + .map(|value| Uint8Array::new(&value).to_vec()) + .collect::>(); + let plaintext = decrypt_public_file(data_map_content, &encrypted_contents) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(Uint8Array::from(plaintext.as_slice())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> Vec { + "browser whole-file fixture\n".repeat(160).into_bytes() + } + + #[test] + fn native_browser_encrypt_matches_existing_wire_vector() { + let encrypted = encrypt_public_file(&fixture()).expect("encrypt fixture"); + assert_eq!( + encrypted + .chunks + .iter() + .map(|chunk| chunk.dst_hash.as_str()) + .collect::>(), + vec![ + "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9", + "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a", + "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb", + ] + ); + assert_eq!( + encrypted.address, + "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b" + ); + assert_eq!( + encrypted.blake3, + "e0e422267ac59c56bf032d6d830035d343369d20147dd5f6b63351a29b015f22" + ); + } + + #[test] + fn native_browser_round_trip_and_tamper_rejection() { + let content = fixture(); + let encrypted = encrypt_public_file(&content).expect("encrypt fixture"); + let data_map = &encrypted.records.last().expect("DataMap record").content; + let chunks = encrypted.records[..encrypted.records.len() - 1] + .iter() + .map(|record| record.content.clone()) + .collect::>(); + assert_eq!( + decrypt_public_file(data_map, &chunks).expect("decrypt fixture"), + content + ); + + let mut tampered = chunks; + tampered[0][0] ^= 1; + assert!(decrypt_public_file(data_map, &tampered).is_err()); + } + + #[test] + fn nested_data_map_round_trip() { + let size = 3 * self_encryption::MAX_CHUNK_SIZE + 1; + let content = (0..size).map(|index| index as u8).collect::>(); + let encrypted = encrypt_public_file(&content).expect("encrypt nested fixture"); + assert_eq!(encrypted.chunks.len(), 4); + assert!(encrypted.records.len() > encrypted.chunks.len() + 1); + + let data_map = &encrypted.records.last().expect("DataMap record").content; + let published: DataMap = rmp_serde::from_slice(data_map).expect("decode published map"); + assert!(published.is_child()); + + let mut required_addresses = decode_public_data_map(data_map) + .expect("decode child map") + .into_iter() + .map(|chunk| chunk.dst_hash) + .collect::>(); + required_addresses.extend(encrypted.chunks.iter().map(|chunk| chunk.dst_hash.clone())); + let records = encrypted.records[..encrypted.records.len() - 1] + .iter() + .filter(|record| required_addresses.contains(&record.address)) + .map(|record| record.content.clone()) + .collect::>(); + assert_eq!(records.len(), encrypted.records.len() - 1); + assert_eq!( + decrypt_public_file(data_map, &records).expect("decrypt nested fixture"), + content + ); + } +} diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs new file mode 100644 index 00000000..e412a9e4 --- /dev/null +++ b/ant-core/src/browser/manifest.rs @@ -0,0 +1,234 @@ +//! Cross-platform validation for browser bootstrap and public-file metadata. + +pub use super::protocol::BrowserPaymentNetwork; +use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint}; +use super::BrowserChunkInfo; +use serde::{Deserialize, Serialize}; + +/// Current browser testnet manifest version. +pub const BROWSER_MANIFEST_VERSION: u16 = 5; +const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024; +const MAX_FILE_CHUNKS: usize = 1024; + +/// A validated WebRTC Direct bootstrap endpoint. +pub type BrowserManifestEndpoint = BrowserEndpoint; + +/// Complete public-file metadata shared by native tooling and the web client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicFileDescriptor { + /// Display and save-as filename. + pub name: String, + /// Public DataMap content address. + pub address: String, + /// Plaintext file size. + pub size: usize, + /// Browser MIME type. + pub content_type: String, + /// Whole-file plaintext BLAKE3 hash. + pub blake3: String, + /// Encoded public DataMap size. + pub data_map_size: usize, + /// Self-encryption chunk descriptors. + pub chunks: Vec, + /// Minimum confirmed record replica count. + pub replicas: usize, +} + +/// Validated bootstrap, payment, and public-file description. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserManifest { + /// Manifest schema version. + pub version: u16, + /// Network instance identifier. + pub network_id: String, + /// Optional manifest creation timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// Stable WebRTC Direct bootstrap addresses. + pub endpoints: Vec, + /// Storage payment configuration. + pub payment: BrowserPaymentNetwork, + /// Known public files offered by the manifest. + #[serde(default)] + pub files: Vec, +} + +/// Manifest validation error. +#[derive(Debug, thiserror::Error)] +#[error("invalid browser manifest: {0}")] +pub struct BrowserManifestError(pub String); + +/// Decode, validate, and normalize an untrusted browser manifest. +pub fn parse_browser_manifest( + value: serde_json::Value, +) -> Result { + let mut manifest: BrowserManifest = + serde_json::from_value(value).map_err(|error| BrowserManifestError(error.to_string()))?; + if manifest.version != BROWSER_MANIFEST_VERSION { + return Err(BrowserManifestError(format!( + "unsupported browser manifest version {}", + manifest.version + ))); + } + if manifest.network_id.is_empty() { + return Err(BrowserManifestError( + "browser manifest has no network ID".to_string(), + )); + } + if manifest.endpoints.is_empty() { + return Err(BrowserManifestError( + "browser manifest contains no WebRtcDirect endpoints".to_string(), + )); + } + for endpoint in &mut manifest.endpoints { + let parsed = parse_webrtc_direct_multiaddr(&endpoint.multiaddr) + .map_err(|error| BrowserManifestError(error.to_string()))?; + endpoint.multiaddr = parsed.multiaddr; + } + manifest.payment = validate_browser_payment_network(manifest.payment)?; + for file in &mut manifest.files { + normalize_file(file)?; + } + Ok(manifest) +} + +/// Validate and normalize payment configuration supplied independently of a +/// manifest, such as to the browser network client WASM binding. +pub fn validate_browser_payment_network( + mut payment: BrowserPaymentNetwork, +) -> Result { + let mut rpc_url = url::Url::parse(&payment.rpc_url) + .map_err(|error| BrowserManifestError(format!("payment RPC URL is invalid: {error}")))?; + if !matches!(rpc_url.scheme(), "http" | "https") { + return Err(BrowserManifestError( + "payment RPC URL must use HTTP or HTTPS".to_string(), + )); + } + if !rpc_url.username().is_empty() || rpc_url.password().is_some() { + return Err(BrowserManifestError( + "payment RPC URL must not contain credentials".to_string(), + )); + } + if rpc_url.path().is_empty() { + rpc_url.set_path("/"); + } + payment.rpc_url = rpc_url.to_string(); + payment.payment_token_address = format!( + "0x{}", + normalize_hex(&payment.payment_token_address, 20).map_err(BrowserManifestError)? + ); + payment.payment_vault_address = format!( + "0x{}", + normalize_hex(&payment.payment_vault_address, 20).map_err(BrowserManifestError)? + ); + Ok(payment) +} + +fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifestError> { + if file.name.is_empty() { + return Err(BrowserManifestError( + "browser manifest file has no name".to_string(), + )); + } + file.address = normalize_hex(&file.address, 32).map_err(BrowserManifestError)?; + if !(self_encryption::MIN_ENCRYPTABLE_BYTES..=super::MAX_BROWSER_FILE_BYTES) + .contains(&file.size) + { + return Err(BrowserManifestError(format!( + "invalid public file size {}", + file.size + ))); + } + file.blake3 = normalize_hex(&file.blake3, 32).map_err(BrowserManifestError)?; + if !(1..=MAX_DATA_MAP_BYTES).contains(&file.data_map_size) { + return Err(BrowserManifestError(format!( + "invalid DataMap size {}", + file.data_map_size + ))); + } + if !(3..=MAX_FILE_CHUNKS).contains(&file.chunks.len()) { + return Err(BrowserManifestError( + "public file has an invalid self-encryption chunk list".to_string(), + )); + } + file.chunks.sort_by_key(|chunk| chunk.index); + let mut reconstructed_size = 0usize; + for (expected_index, chunk) in file.chunks.iter_mut().enumerate() { + if chunk.index != expected_index { + return Err(BrowserManifestError( + "file chunk indices must be contiguous from zero".to_string(), + )); + } + chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?; + chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?; + if chunk.src_size == 0 { + return Err(BrowserManifestError(format!( + "invalid plaintext chunk size {}", + chunk.src_size + ))); + } + reconstructed_size = reconstructed_size + .checked_add(chunk.src_size) + .ok_or_else(|| BrowserManifestError("file size overflow".to_string()))?; + } + if reconstructed_size != file.size { + return Err(BrowserManifestError(format!( + "file chunk sizes total {reconstructed_size}, expected {}", + file.size + ))); + } + if file.content_type.is_empty() { + file.content_type = "application/octet-stream".to_string(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + + fn endpoint() -> String { + let mut multihash = vec![0x12, 0x20]; + multihash.extend([0xbb; 32]); + format!( + "/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/u{}/p2p/{}", + URL_SAFE_NO_PAD.encode(multihash), + "AA".repeat(32) + ) + } + + #[test] + fn validates_and_normalizes_manifest() { + let value = serde_json::json!({ + "version": 5, + "network_id": "local-test", + "created_at": "2026-08-03T00:00:00Z", + "payment": { + "rpc_url": "http://127.0.0.1:8545", + "payment_token_address": format!("0x{}", "11".repeat(20)), + "payment_vault_address": format!("0x{}", "22".repeat(20)), + }, + "endpoints": [{ "multiaddr": endpoint() }], + "files": [{ + "name": "hello.txt", + "address": "CC".repeat(32), + "size": 12, + "content_type": "text/plain", + "blake3": "DD".repeat(32), + "data_map_size": 128, + "chunks": [ + { "index": 2, "dst_hash": "13".repeat(32), "src_hash": "23".repeat(32), "src_size": 4 }, + { "index": 0, "dst_hash": "11".repeat(32), "src_hash": "21".repeat(32), "src_size": 4 }, + { "index": 1, "dst_hash": "12".repeat(32), "src_hash": "22".repeat(32), "src_size": 4 } + ], + "replicas": 5 + }] + }); + let manifest = parse_browser_manifest(value).expect("valid manifest"); + assert_eq!(manifest.files[0].address, "cc".repeat(32)); + assert_eq!(manifest.files[0].chunks[0].index, 0); + assert_eq!(manifest.payment.rpc_url, "http://127.0.0.1:8545/"); + } +} diff --git a/ant-core/src/browser/payment.rs b/ant-core/src/browser/payment.rs new file mode 100644 index 00000000..701627da --- /dev/null +++ b/ant-core/src/browser/payment.rs @@ -0,0 +1,439 @@ +//! Verification and payment planning shared by native and browser clients. + +use super::protocol::normalize_hex; +pub use super::protocol::{BrowserCommitmentArtifact, BrowserQuoteArtifact}; +use saorsa_webrtc::{ + calculate_price_wei, commitment_hash, payment_quote_bytes_for_signing, + verify_commitment_signature, verify_ml_dsa_65, StorageCommitment, MAX_COMMITMENT_KEY_COUNT, + MAX_COMMITMENT_SIDECAR_BYTES, +}; +use serde::{Deserialize, Serialize}; + +pub use saorsa_webrtc::payment_quote_hash; + +const PAYMENT_MULTIPLIER: u128 = 3; +#[cfg(test)] +const PRICE_BASELINE_WEI: u128 = 3_906_250_000_000_000; + +/// A quote that is safe to hand to a transaction signer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifiedStorageQuote { + /// Original verified quote sent back to the selected storage nodes. + pub quote: BrowserQuoteArtifact, + /// Lowercase EVM quote hash without `0x`. + #[serde(rename = "quoteHash")] + pub quote_hash: String, + /// Checksummed-independent lowercase rewards address with `0x`. + #[serde(rename = "rewardsAddress")] + pub rewards_address: String, + /// Decimal amount paid after applying Autonomi's replication multiplier. + pub amount: String, +} + +/// Storage quote validation error. +#[derive(Debug, thiserror::Error)] +#[error("invalid storage quote: {0}")] +pub struct StorageQuoteError(pub String); + +/// Sum verified decimal quote amounts without exposing integer arithmetic to +/// JavaScript or a wallet adapter. +pub fn storage_payment_total(quotes: &[VerifiedStorageQuote]) -> Result { + quotes + .iter() + .try_fold(0u128, |total, quote| { + let amount = parse_decimal_u128("e.amount, "storage payment amount")?; + total + .checked_add(amount) + .ok_or_else(|| StorageQuoteError("storage payment total overflow".to_string())) + }) + .map(|total| total.to_string()) +} + +/// Fully verify a quote, its commitment, peer binding, price, and EVM hash. +pub fn verify_storage_quote( + mut quote: BrowserQuoteArtifact, + expected_address: &str, + expected_peer_id: &str, +) -> Result { + let expected_address = normalize_hex(expected_address, 32).map_err(StorageQuoteError)?; + let expected_peer_id = normalize_hex(expected_peer_id, 32).map_err(StorageQuoteError)?; + quote.content = normalize_hex("e.content, 32).map_err(StorageQuoteError)?; + quote.peer_id = normalize_hex("e.peer_id, 32).map_err(StorageQuoteError)?; + if quote.content != expected_address { + return Err(StorageQuoteError( + "storage quote is for a different chunk".to_string(), + )); + } + if quote.peer_id != expected_peer_id { + return Err(StorageQuoteError( + "storage quote belongs to a different WebRtcDirect peer".to_string(), + )); + } + if quote.committed_key_count > MAX_COMMITMENT_KEY_COUNT { + return Err(StorageQuoteError(format!( + "invalid committed key count {}", + quote.committed_key_count + ))); + } + let public_key = decode_unbounded_hex("e.public_key, "quote public key")?; + let signature = decode_unbounded_hex("e.signature, "quote signature")?; + if blake3::hash(&public_key).to_hex().as_str() != quote.peer_id { + return Err(StorageQuoteError( + "storage quote public key is not bound to its peer ID".to_string(), + )); + } + let price = parse_decimal_u128("e.price, "quote price")?; + let expected_price = calculate_price_wei(quote.committed_key_count); + if price != expected_price { + return Err(StorageQuoteError( + "storage quote price is not bound to its committed key count".to_string(), + )); + } + let rewards = normalize_hex("e.rewards_address, 20).map_err(StorageQuoteError)?; + quote.rewards_address.clone_from(&rewards); + let commitment_pin = quote + .commitment_pin + .as_deref() + .map(|pin| normalize_hex(pin, 32).map_err(StorageQuoteError)) + .transpose()?; + quote.commitment_pin.clone_from(&commitment_pin); + let signed_bytes = canonical_quote_bytes("e, price, &rewards, commitment_pin.as_deref())?; + if !verify_ml_dsa_65(&public_key, &signature, &signed_bytes, b"") { + return Err(StorageQuoteError( + "storage quote has an invalid ML-DSA-65 signature".to_string(), + )); + } + let quote_hash = hex::encode(payment_quote_hash(&signed_bytes, &public_key, &signature)); + if normalize_hex("e.quote_hash, 32).map_err(StorageQuoteError)? != quote_hash { + return Err(StorageQuoteError( + "storage quote hash does not match its signed fields".to_string(), + )); + } + quote.quote_hash.clone_from("e_hash); + + if quote.committed_key_count == 0 { + if quote.commitment_pin.is_some() || quote.commitment.is_some() { + return Err(StorageQuoteError( + "baseline storage quote has an incoherent commitment".to_string(), + )); + } + } else { + let pin = commitment_pin + .ok_or_else(|| StorageQuoteError("bound storage quote omitted its pin".to_string()))?; + verify_commitment( + quote.commitment.as_mut().ok_or_else(|| { + StorageQuoteError("bound quote omitted its storage commitment".to_string()) + })?, + "e.peer_id, + quote.committed_key_count, + &pin, + )?; + } + + let amount = price + .checked_mul(PAYMENT_MULTIPLIER) + .ok_or_else(|| StorageQuoteError("storage payment amount overflow".to_string()))?; + Ok(VerifiedStorageQuote { + quote, + quote_hash, + rewards_address: format!("0x{rewards}"), + amount: amount.to_string(), + }) +} + +fn canonical_quote_bytes( + quote: &BrowserQuoteArtifact, + price: u128, + rewards: &str, + commitment_pin: Option<&str>, +) -> Result, StorageQuoteError> { + let content = decode_hex_array::<32>("e.content, "quote content")?; + let rewards = decode_hex_array::<20>(rewards, "quote rewards address")?; + let commitment_pin = commitment_pin + .map(|pin| decode_hex_array::<32>(pin, "storage commitment pin")) + .transpose()?; + Ok(payment_quote_bytes_for_signing( + &content, + quote.timestamp_secs, + price, + &rewards, + quote.committed_key_count, + commitment_pin.as_ref(), + )) +} + +fn verify_commitment( + artifact: &mut BrowserCommitmentArtifact, + expected_peer_id: &str, + expected_key_count: u32, + expected_pin: &str, +) -> Result<(), StorageQuoteError> { + let encoded = decode_unbounded_hex(&artifact.encoded, "storage commitment sidecar")?; + if encoded.len() > MAX_COMMITMENT_SIDECAR_BYTES { + return Err(StorageQuoteError( + "storage commitment sidecar exceeds the protocol limit".to_string(), + )); + } + let commitment: StorageCommitment = rmp_serde::from_slice(&encoded).map_err(|error| { + StorageQuoteError(format!( + "storage commitment sidecar is not valid MessagePack: {error}" + )) + })?; + let root = normalize_hex(&artifact.root, 32).map_err(StorageQuoteError)?; + let peer_id = normalize_hex(&artifact.sender_peer_id, 32).map_err(StorageQuoteError)?; + let public_key = + decode_unbounded_hex(&artifact.sender_public_key, "storage commitment public key")?; + let signature = decode_unbounded_hex(&artifact.signature, "storage commitment signature")?; + if commitment.root != decode_hex_array::<32>(&root, "storage commitment root")? + || commitment.key_count != artifact.key_count + || commitment.sender_peer_id + != decode_hex_array::<32>(&peer_id, "storage commitment peer ID")? + || commitment.sender_public_key != public_key + || commitment.signature != signature + { + return Err(StorageQuoteError( + "storage commitment sidecar differs from the verified commitment".to_string(), + )); + } + artifact.root = root; + artifact.sender_peer_id = peer_id.clone(); + artifact.sender_public_key = hex::encode(&public_key); + artifact.signature = hex::encode(&signature); + artifact.encoded = hex::encode(&encoded); + if commitment.key_count != expected_key_count { + return Err(StorageQuoteError( + "storage commitment key count does not match quote".to_string(), + )); + } + if peer_id != expected_peer_id + || blake3::hash(&public_key).to_hex().as_str() != expected_peer_id + { + return Err(StorageQuoteError( + "storage commitment belongs to a different peer".to_string(), + )); + } + if !verify_commitment_signature(&commitment) { + return Err(StorageQuoteError( + "storage commitment has an invalid ML-DSA-65 signature".to_string(), + )); + } + if commitment_hash(&commitment).map(hex::encode).as_deref() != Some(expected_pin) { + return Err(StorageQuoteError( + "storage commitment does not resolve the quote pin".to_string(), + )); + } + Ok(()) +} + +fn parse_decimal_u128(value: &str, label: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(StorageQuoteError(format!("invalid {label}"))); + } + value + .parse::() + .map_err(|_| StorageQuoteError(format!("{label} exceeds the supported protocol range"))) +} + +fn decode_unbounded_hex(value: &str, label: &str) -> Result, StorageQuoteError> { + let value = value.strip_prefix("0x").unwrap_or(value); + if (value.len() & 1) != 0 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(StorageQuoteError(format!("invalid {label}"))); + } + hex::decode(value).map_err(|error| StorageQuoteError(format!("invalid {label}: {error}"))) +} + +fn decode_hex_array( + value: &str, + label: &str, +) -> Result<[u8; LENGTH], StorageQuoteError> { + let decoded = hex::decode(value).map_err(|error| StorageQuoteError(error.to_string()))?; + decoded.try_into().map_err(|bytes: Vec| { + StorageQuoteError(format!( + "expected {LENGTH} bytes for {label}, received {}", + bytes.len() + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ant_protocol::pqc::api::ml_dsa_65; + use saorsa_webrtc::{storage_commitment_bytes_for_signing, DOMAIN_COMMITMENT}; + + fn baseline_quote() -> (BrowserQuoteArtifact, String, String) { + let content = [0x31; 32]; + let rewards = [0x44; 20]; + let timestamp = 1_775_000_000; + let (public_key, secret_key) = ml_dsa_65().generate_keypair().expect("keypair"); + let public_key = public_key.to_bytes(); + let peer_id = blake3::hash(&public_key).to_hex().to_string(); + let mut quote = BrowserQuoteArtifact { + peer_id: peer_id.clone(), + content: hex::encode(content), + timestamp_secs: timestamp, + price: PRICE_BASELINE_WEI.to_string(), + rewards_address: hex::encode(rewards), + public_key: hex::encode(&public_key), + signature: String::new(), + committed_key_count: 0, + commitment_pin: None, + quote_hash: String::new(), + commitment: None, + }; + let payload = + canonical_quote_bytes("e, PRICE_BASELINE_WEI, &hex::encode(rewards), None) + .expect("payload"); + let signature = ml_dsa_65() + .sign(&secret_key, &payload) + .expect("signature") + .to_bytes(); + quote.signature = hex::encode(&signature); + quote.quote_hash = hex::encode(payment_quote_hash(&payload, &public_key, &signature)); + (quote, hex::encode(content), peer_id) + } + + fn bound_quote() -> (BrowserQuoteArtifact, String, String) { + let content = [0x31; 32]; + let rewards = [0x42; 20]; + let root = [0x53; 32]; + let key_count = 23; + let timestamp = 1_775_000_001; + let (public_key, secret_key) = ml_dsa_65().generate_keypair().expect("keypair"); + let public_key = public_key.to_bytes(); + let peer_id = blake3::hash(&public_key).into(); + let mut commitment = StorageCommitment { + root, + key_count, + sender_peer_id: peer_id, + sender_public_key: public_key.clone(), + signature: Vec::new(), + }; + let commitment_payload = storage_commitment_bytes_for_signing( + &commitment.root, + commitment.key_count, + &commitment.sender_peer_id, + &commitment.sender_public_key, + ); + commitment.signature = ml_dsa_65() + .sign_with_context(&secret_key, &commitment_payload, DOMAIN_COMMITMENT) + .expect("commitment signature") + .to_bytes(); + let native_commitment = ant_protocol::payment::StorageCommitment { + root: commitment.root, + key_count: commitment.key_count, + sender_peer_id: commitment.sender_peer_id, + sender_public_key: commitment.sender_public_key.clone(), + signature: commitment.signature.clone(), + }; + assert!(ant_protocol::payment::verify_commitment_signature( + &native_commitment + )); + assert_eq!( + commitment_hash(&commitment), + ant_protocol::payment::commitment_hash(&native_commitment) + ); + let encoded = rmp_serde::to_vec(&commitment).expect("MessagePack commitment"); + assert_eq!( + encoded, + rmp_serde::to_vec(&native_commitment).expect("native MessagePack commitment") + ); + let pin = hex::encode(commitment_hash(&commitment).expect("commitment hash")); + let peer_id = hex::encode(peer_id); + let price = calculate_price_wei(key_count); + assert_eq!( + ant_protocol::evm::Amount::from(price), + ant_protocol::payment::calculate_price(key_count as usize) + ); + let mut quote = BrowserQuoteArtifact { + peer_id: peer_id.clone(), + content: hex::encode(content), + timestamp_secs: timestamp, + price: price.to_string(), + rewards_address: hex::encode(rewards), + public_key: hex::encode(&public_key), + signature: String::new(), + committed_key_count: key_count, + commitment_pin: Some(pin.clone()), + quote_hash: String::new(), + commitment: Some(BrowserCommitmentArtifact { + encoded: hex::encode(encoded), + root: hex::encode(commitment.root), + key_count, + sender_peer_id: peer_id.clone(), + sender_public_key: hex::encode(&public_key), + signature: hex::encode(&commitment.signature), + }), + }; + let payload = canonical_quote_bytes("e, price, &hex::encode(rewards), Some(&pin)) + .expect("quote payload"); + let native_quote = ant_protocol::evm::PaymentQuote { + content: xor_name::XorName(content), + timestamp: std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(timestamp), + price: ant_protocol::evm::Amount::from(price), + rewards_address: ant_protocol::evm::RewardsAddress::from(rewards), + pub_key: public_key.clone(), + signature: Vec::new(), + committed_key_count: key_count, + commitment_pin: Some( + hex::decode(&pin) + .expect("pin") + .try_into() + .expect("32-byte pin"), + ), + }; + assert_eq!(payload, native_quote.bytes_for_sig()); + let signature = ml_dsa_65() + .sign(&secret_key, &payload) + .expect("quote signature") + .to_bytes(); + quote.signature = hex::encode(&signature); + quote.quote_hash = hex::encode(payment_quote_hash(&payload, &public_key, &signature)); + let native_quote = ant_protocol::evm::PaymentQuote { + signature: signature.clone(), + ..native_quote + }; + assert_eq!(quote.quote_hash, hex::encode(native_quote.hash())); + (quote, hex::encode(content), peer_id) + } + + #[test] + fn payment_hash_matches_evmlib_vector() { + assert_eq!( + hex::encode(payment_quote_hash(&[0, 1], &[2], &[3])), + "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f" + ); + } + + #[test] + fn verifies_baseline_quote_and_rejects_tampering() { + let (quote, content, peer_id) = baseline_quote(); + let verified = + verify_storage_quote(quote.clone(), &content, &peer_id).expect("valid quote"); + assert_eq!(verified.amount, (PRICE_BASELINE_WEI * 3).to_string()); + let mut tampered = quote; + tampered.price = (PRICE_BASELINE_WEI + 1).to_string(); + assert!(verify_storage_quote(tampered, &content, &peer_id).is_err()); + } + + #[test] + fn verifies_bound_commitment_and_exact_native_sidecar() { + let (quote, content, peer_id) = bound_quote(); + let verified = + verify_storage_quote(quote.clone(), &content, &peer_id).expect("valid bound quote"); + assert_eq!( + storage_payment_total(&[verified]).expect("payment total"), + (calculate_price_wei(23) * PAYMENT_MULTIPLIER).to_string() + ); + + let mut tampered = quote; + tampered.commitment.as_mut().expect("commitment").root = hex::encode([0x99; 32]); + let error = verify_storage_quote(tampered, &content, &peer_id) + .expect_err("sidecar mismatch must fail"); + assert!(error.to_string().contains("sidecar differs")); + } +} diff --git a/ant-core/src/browser/protocol.rs b/ant-core/src/browser/protocol.rs new file mode 100644 index 00000000..70e1cbe4 --- /dev/null +++ b/ant-core/src/browser/protocol.rs @@ -0,0 +1,17 @@ +//! Browser-facing WebRTC Direct wire profile. +//! +//! The complete application contract lives in `saorsa-webrtc`; this module is +//! retained as a compatibility re-export for existing `ant-core` callers. + +pub use saorsa_webrtc::{ + decode_hex, encode_request_frame, encode_response_frame, ice_password_from_sdp, normalize_hex, + parse_request_frame, parse_request_header, parse_response_frame, parse_webrtc_direct_multiaddr, + response_frame_length, server_answer_sdp, v2_server_ice_credential, validate_hello_metadata, + BrowserCommitmentArtifact, BrowserEndpoint, BrowserEndpointInput, BrowserHello, BrowserNode, + BrowserPaymentNetwork, BrowserProtocolError, BrowserQuoteArtifact, BrowserRequest, + BrowserRequestBody, BrowserRequestFrame, BrowserResponse, BrowserResponseBody, + BrowserResponseFrame, BrowserResponseStatus, WebRtcDirectEndpoint, BROWSER_PROTOCOL_NAME, + BROWSER_PROTOCOL_VERSION, MAX_BROWSER_FRAME_BYTES, MAX_BROWSER_HEADER_BYTES, + MAX_BROWSER_RECORD_BYTES, MAX_BROWSER_RESPONSE_BYTES, MAX_WEBRTC_DIRECT_MULTIADDR_LENGTH, + WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, +}; diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs new file mode 100644 index 00000000..7ba2ec46 --- /dev/null +++ b/ant-core/src/browser/wasm_transport.rs @@ -0,0 +1,2801 @@ +//! `web-sys` WebRTC Direct transport and typed node operations. + +use super::manifest::{ + validate_browser_payment_network, BrowserPaymentNetwork, PublicFileDescriptor, +}; +use super::payment::{ + storage_payment_total, verify_storage_quote, BrowserQuoteArtifact, VerifiedStorageQuote, +}; +use super::protocol::{ + encode_request_frame, ice_password_from_sdp, parse_response_frame, + parse_webrtc_direct_multiaddr, server_answer_sdp, v2_server_ice_credential, + validate_hello_metadata, BrowserEndpoint, BrowserEndpointInput, BrowserHello, BrowserNode, + BrowserRequest, BrowserRequestBody, BrowserResponseBody, BrowserResponseFrame, + BrowserResponseStatus, WebRtcDirectEndpoint, MAX_BROWSER_RECORD_BYTES, + MAX_BROWSER_RESPONSE_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, +}; +use super::{BrowserRecord, BrowserRecordInfo, BrowserStagedFile}; +use crate::client_engine::adaptive::{ + observe_op, AdaptiveConfig, AdaptiveController, ChannelStart, Outcome, +}; +use futures_channel::{mpsc, oneshot}; +use futures_util::{ + future::{join_all, select, Either}, + lock::Mutex, + stream::{self, FuturesUnordered, StreamExt as _}, +}; +use gloo_timers::future::TimeoutFuture; +use js_sys::{Array, ArrayBuffer, Promise, Uint8Array}; +use saorsa_dht_lookup::{ + collect_after_first_with_grace, run_iterative_lookup, xor_distance, IterativeLookup, + LookupConfig, LookupKey, LookupNode, LookupQuery, LookupQueryOutcome, +}; +use saorsa_webrtc::{ + decode_pq_frame, encode_pq_frame, pq_frame_length, transfer_timeout, PqClientHandshake, + PqSession, CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE, PQ_ENCRYPTED_OVERHEAD_BYTES, + PQ_SERVER_ACCEPT_BYTES, +}; +use serde::{Deserialize, Serialize}; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::ops::Deref; +use std::rc::Rc; +use std::time::Duration; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + Event, MessageEvent, RtcConfiguration, RtcDataChannel, RtcDataChannelInit, RtcDataChannelState, + RtcDataChannelType, RtcPeerConnection, RtcSdpType, RtcSessionDescriptionInit, +}; + +const REQUEST_TIMEOUT_MS: u32 = 10_000; +const MAX_BUFFERED_AMOUNT: u32 = 2 * 1024 * 1024; +const DEFAULT_MAX_POOLED_CLIENTS: usize = 32; +const DEFAULT_LOOKUP_K: usize = 20; +const DEFAULT_LOOKUP_ALPHA: usize = 3; +const DEFAULT_MAX_LOOKUP_ITERATIONS: usize = 20; +const LOOKUP_GRACE_TIMEOUT_MS: u32 = 5_000; +const ENDPOINT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30 * 60); +const MAX_BROWSER_ROUTING_ENTRIES: usize = 256; +const MAX_BROWSER_ENDPOINT_FAILURES: usize = 256; +const DEFAULT_BROWSER_QUOTE_CONCURRENCY: usize = 4; +const MAX_DOWNLOAD_CONCURRENCY: usize = 6; +const MAX_BROWSER_RANGE_BYTES: usize = 4 * 1024 * 1024; +const MAX_RANGE_CACHE_BYTES: usize = 32 * 1024 * 1024; + +type ResponseInbox = Rc, String>>>>; + +#[derive(Debug, Serialize)] +struct BrowserLookupResult { + nodes: Vec, + queried: Vec, + failures: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct BrowserLookupFailure { + #[serde(rename = "peerId")] + peer_id: String, + message: String, +} + +#[derive(Debug, Clone)] +struct BrowserLookupCandidate { + peer_id: LookupKey, + wire: BrowserNode, +} + +impl LookupNode for BrowserLookupCandidate { + fn lookup_peer_id(&self) -> LookupKey { + self.peer_id + } +} + +impl BrowserLookupCandidate { + fn parse(mut wire: BrowserNode) -> Result { + let peer_id = parse_lookup_key(&wire.peer_id, "peer ID")?; + wire.peer_id = hex::encode(peer_id); + Ok(Self { peer_id, wire }) + } +} + +struct PoolEntry { + client: Rc, + last_used: u64, +} + +struct BrowserClientPool { + max_clients: usize, + clients: RefCell>, + clock: Cell, + availability: Rc, + availability_rx: Mutex>, +} + +struct BrowserClientLease { + client: Rc, + availability: Rc, +} + +struct PoolAvailability { + closed: Cell, + sender: mpsc::Sender<()>, +} + +impl PoolAvailability { + fn notify_one(&self) { + if self.closed.get() { + return; + } + // The capacity-one channel coalesces repeated lease drops. Normal use + // can retain at most one wake token, including when a blocked client + // future is canceled before it consumes the notification. + let mut sender = self.sender.clone(); + let _ = sender.try_send(()); + } + + fn close(&self) { + self.closed.set(true); + // Wake the one receiver that may currently hold the async mutex. Any + // additional waiters observe `closed` when they acquire that mutex. + let mut sender = self.sender.clone(); + let _ = sender.try_send(()); + } +} + +impl Deref for BrowserClientLease { + type Target = BrowserNodeClientCore; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +impl Drop for BrowserClientLease { + fn drop(&mut self) { + self.availability.notify_one(); + } +} + +impl BrowserClientPool { + fn new(max_clients: usize) -> Result { + if max_clients == 0 { + return Err("WebRTC client pool size must be a positive integer".to_string()); + } + let (availability_tx, availability_rx) = mpsc::channel(1); + Ok(Self { + max_clients, + clients: RefCell::new(HashMap::new()), + clock: Cell::new(0), + availability: Rc::new(PoolAvailability { + closed: Cell::new(false), + sender: availability_tx, + }), + availability_rx: Mutex::new(availability_rx), + }) + } + + async fn wait_for_availability(&self) -> Result<(), String> { + if self.availability.closed.get() { + return Err("WebRTC client pool is closed".to_string()); + } + let mut receiver = self.availability_rx.lock().await; + if self.availability.closed.get() { + return Err("WebRTC client pool is closed".to_string()); + } + if receiver.next().await.is_none() || self.availability.closed.get() { + return Err("WebRTC client pool closed while waiting for capacity".to_string()); + } + Ok(()) + } + + async fn client(&self, endpoint: &BrowserEndpoint) -> Result { + let endpoint = parse_webrtc_direct_multiaddr(&endpoint.multiaddr) + .map_err(|error| error.to_string())?; + let key = endpoint.multiaddr.clone(); + loop { + if self.availability.closed.get() { + return Err("WebRTC client pool is closed".to_string()); + } + let now = self.clock.get().wrapping_add(1); + self.clock.set(now); + let client = { + let mut clients = self.clients.borrow_mut(); + if let Some(entry) = clients.get_mut(&key) { + entry.last_used = now; + Some(Rc::clone(&entry.client)) + } else { + if clients.len() >= self.max_clients { + let evict = clients + .iter() + .filter(|(_, entry)| Rc::strong_count(&entry.client) == 1) + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| key.clone()); + if let Some(evict) = evict { + if let Some(entry) = clients.remove(&evict) { + entry.client.close(); + } + } + } + if clients.len() < self.max_clients { + let client = Rc::new(BrowserNodeClientCore::new(endpoint.clone())); + clients.insert( + key.clone(), + PoolEntry { + client: Rc::clone(&client), + last_used: now, + }, + ); + Some(client) + } else { + None + } + } + }; + if let Some(client) = client { + return Ok(BrowserClientLease { + client, + availability: Rc::clone(&self.availability), + }); + } + self.wait_for_availability().await?; + } + } + + fn close(&self) { + self.availability.close(); + for (_, entry) in self.clients.borrow_mut().drain() { + entry.client.close(); + } + } +} + +#[derive(Debug, Serialize)] +struct BrowserChunk { + #[serde(with = "serde_bytes")] + content: Vec, + hash: String, +} + +#[derive(Debug, Serialize)] +struct BrowserQuoteResponse { + quote: BrowserQuoteArtifact, + #[serde(rename = "alreadyStored")] + already_stored: bool, +} + +#[derive(Debug, Serialize)] +struct BrowserPutResponse { + address: String, + #[serde(rename = "alreadyStored")] + already_stored: bool, +} + +struct Connection { + peer_connection: RtcPeerConnection, + data_channel: RtcDataChannel, + inbox: ResponseInbox, + pq_session: RefCell>, + _on_message: Closure, + _on_error: Closure, + _on_close: Closure, + _on_open: Closure, +} + +impl Connection { + async fn open(endpoint: &WebRtcDirectEndpoint) -> Result { + let configuration = RtcConfiguration::new(); + configuration.set_ice_servers(&Array::new()); + let peer_connection = + RtcPeerConnection::new_with_configuration(&configuration).map_err(js_error_message)?; + let channel_configuration = RtcDataChannelInit::new(); + channel_configuration.set_ordered(true); + let data_channel = peer_connection.create_data_channel_with_data_channel_dict( + WEBRTC_DIRECT_DATA_CHANNEL, + &channel_configuration, + ); + data_channel.set_binary_type(RtcDataChannelType::Arraybuffer); + + let (inbox_tx, inbox_rx) = mpsc::unbounded::, String>>(); + let message_tx = inbox_tx.clone(); + let on_message = Closure::::new(move |event: MessageEvent| { + let data = event.data(); + let result = if data.is_instance_of::() || ArrayBuffer::is_view(&data) { + Ok(Uint8Array::new(&data).to_vec()) + } else { + Err("node sent a non-binary DataChannel message".to_string()) + }; + let _ = message_tx.unbounded_send(result); + }); + data_channel.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + + let error_tx = inbox_tx.clone(); + let on_error = Closure::::new(move |_event: Event| { + let _ = error_tx.unbounded_send(Err("WebRTC DataChannel failed".to_string())); + }); + data_channel.set_onerror(Some(on_error.as_ref().unchecked_ref())); + let close_tx = inbox_tx; + let on_close = Closure::::new(move |_event: Event| { + let _ = close_tx.unbounded_send(Err("WebRTC DataChannel closed".to_string())); + }); + data_channel.set_onclose(Some(on_close.as_ref().unchecked_ref())); + + let (open_tx, open_rx) = oneshot::channel::<()>(); + let open_tx = Rc::new(RefCell::new(Some(open_tx))); + let open_sender = Rc::clone(&open_tx); + let on_open = Closure::::new(move |_event: Event| { + if let Some(sender) = open_sender.borrow_mut().take() { + let _ = sender.send(()); + } + }); + data_channel.set_onopen(Some(on_open.as_ref().unchecked_ref())); + + // Own the browser objects and every installed callback before the + // first await. Any setup error now runs `Drop`, detaches the callbacks, + // and closes the half-open peer connection deterministically. + let connection = Self { + peer_connection, + data_channel, + inbox: Rc::new(Mutex::new(inbox_rx)), + pq_session: RefCell::new(None), + _on_message: on_message, + _on_error: on_error, + _on_close: on_close, + _on_open: on_open, + }; + + let offer = JsFuture::from(connection.peer_connection.create_offer()) + .await + .map_err(js_error_message)?; + // `RTCSessionDescriptionInit` is a Web IDL dictionary, not a branded + // interface. Chromium returns a plain object here, so `dyn_into` can + // reject a perfectly valid offer because there is no `instanceof` + // identity to test. Read the dictionary member structurally instead. + let offer_sdp = js_sys::Reflect::get(&offer, &JsValue::from_str("sdp")) + .map_err(js_error_message)? + .as_string() + .filter(|sdp| !sdp.is_empty()) + .ok_or_else(|| "browser created an empty WebRTC offer".to_string())?; + let local = RtcSessionDescriptionInit::new(RtcSdpType::Offer); + local.set_sdp(&offer_sdp); + JsFuture::from(connection.peer_connection.set_local_description(&local)) + .await + .map_err(js_error_message)?; + let local_sdp = connection + .peer_connection + .local_description() + .ok_or_else(|| "browser did not retain its local WebRTC offer".to_string())? + .sdp(); + let client_pwd = ice_password_from_sdp(&local_sdp).map_err(|error| error.to_string())?; + let server_credential = + v2_server_ice_credential(&client_pwd).map_err(|error| error.to_string())?; + let answer_sdp = + server_answer_sdp(endpoint, &server_credential).map_err(|error| error.to_string())?; + let remote = RtcSessionDescriptionInit::new(RtcSdpType::Answer); + remote.set_sdp(&answer_sdp); + JsFuture::from(connection.peer_connection.set_remote_description(&remote)) + .await + .map_err(js_error_message)?; + + timeout( + async move { + open_rx + .await + .map_err(|_| "WebRTC DataChannel closed before opening".to_string()) + }, + "WebRTC DataChannel opening timed out", + ) + .await?; + connection.data_channel.set_onopen(None); + + let session = establish_pq_session(&connection, endpoint).await?; + connection.pq_session.replace(Some(session)); + + Ok(connection) + } + + fn close(self) { + drop(self); + } +} + +impl Drop for Connection { + fn drop(&mut self) { + self.data_channel.set_onmessage(None); + self.data_channel.set_onerror(None); + self.data_channel.set_onclose(None); + self.data_channel.set_onopen(None); + self.data_channel.set_onbufferedamountlow(None); + self.data_channel.close(); + self.peer_connection.close(); + } +} + +pub(super) struct BrowserNodeClientCore { + endpoint: WebRtcDirectEndpoint, + connection: RefCell>, + request_lock: Mutex<()>, + next_request_id: Cell, + hello: RefCell>, + peer_id: RefCell>, +} + +impl BrowserNodeClientCore { + pub(super) fn new(endpoint: WebRtcDirectEndpoint) -> Self { + Self { + endpoint, + connection: RefCell::new(None), + request_lock: Mutex::new(()), + next_request_id: Cell::new(1), + hello: RefCell::new(None), + peer_id: RefCell::new(None), + } + } + + pub(super) fn peer_id(&self) -> Option { + self.peer_id.borrow().clone() + } + + async fn ensure_connected(&self) -> Result<(), String> { + let open = self.connection.borrow().as_ref().is_some_and(|connection| { + connection.data_channel.ready_state() == RtcDataChannelState::Open + }); + if open { + return Ok(()); + } + self.close(); + let connection = Connection::open(&self.endpoint).await?; + self.connection.replace(Some(connection)); + Ok(()) + } + + async fn request( + &self, + body: BrowserRequestBody, + content: &[u8], + ) -> Result { + let _guard = self.request_lock.lock().await; + self.ensure_connected().await?; + let request_id = self.next_request_id.get(); + self.next_request_id.set(request_id.wrapping_add(1).max(1)); + let request = BrowserRequest::new(request_id, body, content.len()); + let plaintext = + encode_request_frame(&request, content).map_err(|error| error.to_string())?; + let frame = { + let connection = self.connection.borrow(); + let connection = connection + .as_ref() + .ok_or_else(|| "WebRTC DataChannel is not connected".to_string())?; + let encrypted = connection + .pq_session + .borrow_mut() + .as_mut() + .ok_or_else(|| "WebRTC PQ session is not established".to_string())? + .seal(&plaintext) + .map_err(|error| error.to_string())?; + encode_pq_frame(&encrypted).map_err(|error| error.to_string())? + }; + let transfer_timeout_ms = transfer_timeout_ms(frame.len()); + let channel = { + let connection = self.connection.borrow(); + connection + .as_ref() + .map(|connection| connection.data_channel.clone()) + }; + let Some(channel) = channel else { + self.close(); + return Err("WebRTC DataChannel is not connected".to_string()); + }; + let send_result = send_data_channel_frame(&channel, &frame, transfer_timeout_ms).await; + if let Err(error) = send_result { + self.close(); + return Err(error); + } + let receiver = { + let connection = self.connection.borrow(); + connection + .as_ref() + .map(|connection| Rc::clone(&connection.inbox)) + }; + let Some(receiver) = receiver else { + self.close(); + return Err("WebRTC response inbox is unavailable".to_string()); + }; + let encrypted_response = match read_pq_payload( + receiver, + MAX_BROWSER_RESPONSE_BYTES + PQ_ENCRYPTED_OVERHEAD_BYTES, + transfer_timeout_ms, + ) + .await + { + Ok(response) => response, + Err(error) => { + self.close(); + return Err(error); + } + }; + let decrypt_result = { + let connection = self.connection.borrow(); + let Some(connection) = connection.as_ref() else { + return Err("WebRTC DataChannel is not connected".to_string()); + }; + let mut pq_session = connection.pq_session.borrow_mut(); + pq_session + .as_mut() + .ok_or_else(|| "WebRTC PQ session is not established".to_string())? + .open(&encrypted_response) + .map_err(|error| error.to_string()) + }; + let plaintext_response = match decrypt_result { + Ok(response) => response, + Err(error) => { + self.close(); + return Err(error); + } + }; + let response = match parse_response_frame(&plaintext_response) { + Ok(response) => response, + Err(error) => { + self.close(); + return Err(error.to_string()); + } + }; + if response.header.request_id != request_id { + let error = format!( + "response ID {} does not match request {request_id}", + response.header.request_id + ); + self.close(); + return Err(error); + } + if response.header.status == BrowserResponseStatus::Error { + let (authentication_required, error) = match &response.header.body { + BrowserResponseBody::Error { code, message } => { + (code == "authentication_required", message.clone()) + } + _ => (false, "node returned an invalid error response".to_string()), + }; + if authentication_required { + self.close(); + } + return Err(error); + } + Ok(response) + } + + pub(super) async fn hello(&self) -> Result { + if let Some(hello) = self.hello.borrow().clone() { + if self.connection.borrow().as_ref().is_some_and(|connection| { + connection.data_channel.ready_state() == RtcDataChannelState::Open + }) { + return Ok(hello); + } + } + let response = self.request(BrowserRequestBody::Hello, &[]).await?; + let BrowserResponseBody::Hello { + protocol, + peer_id, + max_chunk_size, + endpoint, + payment, + capabilities, + } = response.header.body + else { + self.close(); + return Err("expected a HELLO response".to_string()); + }; + let hello = BrowserHello { + response_type: "hello".to_string(), + protocol, + peer_id, + endpoint, + max_chunk_size, + capabilities, + payment, + }; + let peer_id = match validate_hello_metadata(&hello, &self.endpoint) { + Ok(peer_id) => peer_id, + Err(error) => { + self.close(); + return Err(error.to_string()); + } + }; + self.peer_id.replace(Some(peer_id)); + self.hello.replace(Some(hello.clone())); + Ok(hello) + } + + pub(super) async fn find_node( + &self, + target: &str, + count: usize, + ) -> Result, String> { + let target = super::protocol::normalize_hex(target, 32)?; + let response = self + .request( + BrowserRequestBody::FindNode { + target: target.clone(), + count: Some(count), + }, + &[], + ) + .await?; + let BrowserResponseBody::Nodes { + target: response_target, + nodes, + } = response.header.body + else { + return Err("expected a NODES response".to_string()); + }; + if response_target.to_ascii_lowercase() != target { + return Err("node returned results for a different lookup target".to_string()); + } + for node in &nodes { + let peer_id = super::protocol::normalize_hex(&node.peer_id, 32)?; + if let Some(endpoint) = &node.webrtc_direct { + let endpoint = parse_webrtc_direct_multiaddr(&endpoint.multiaddr) + .map_err(|error| error.to_string())?; + if endpoint.peer_id != peer_id { + return Err(format!("node {peer_id} advertised another peer's endpoint")); + } + } + } + Ok(nodes) + } + + pub(super) async fn get_chunk(&self, address: &str) -> Result<(Vec, String), String> { + let address = super::protocol::normalize_hex(address, 32)?; + let response = self + .request( + BrowserRequestBody::GetChunk { + address: address.clone(), + }, + &[], + ) + .await?; + if response.header.status == BrowserResponseStatus::NotFound { + return Err(format!("chunk {address} was not found on this node")); + } + let BrowserResponseBody::Chunk { + address: response_address, + size, + } = response.header.body + else { + return Err("expected a CHUNK response".to_string()); + }; + if response_address.to_ascii_lowercase() != address { + return Err("node returned a different chunk address".to_string()); + } + if size != response.content.len() { + return Err("chunk metadata size does not match its content".to_string()); + } + super::verify_record(&address, &response.content).map_err(|error| error.to_string())?; + Ok((response.content, address)) + } + + pub(super) async fn quote_chunk( + &self, + address: &str, + size: usize, + ) -> Result<(BrowserQuoteArtifact, bool), String> { + let address = super::protocol::normalize_hex(address, 32)?; + if size > super::protocol::MAX_BROWSER_RECORD_BYTES { + return Err(format!("invalid chunk size {size}")); + } + let response = self + .request( + BrowserRequestBody::QuoteChunk { + address: address.clone(), + size: u64::try_from(size).map_err(|_| format!("invalid chunk size {size}"))?, + }, + &[], + ) + .await?; + let BrowserResponseBody::StorageQuote { + address: response_address, + already_stored, + quote, + } = response.header.body + else { + return Err("expected a STORAGE_QUOTE response".to_string()); + }; + if response_address.to_ascii_lowercase() != address { + return Err("node returned a quote for a different chunk address".to_string()); + } + Ok((quote, already_stored)) + } + + pub(super) async fn put_chunk( + &self, + address: &str, + content: &[u8], + quote: BrowserQuoteArtifact, + transaction_hash: &str, + ) -> Result<(String, bool), String> { + let address = super::protocol::normalize_hex(address, 32)?; + let transaction_hash = super::protocol::normalize_hex(transaction_hash, 32)?; + super::verify_record(&address, content).map_err(|error| error.to_string())?; + let response = self + .request( + BrowserRequestBody::PutChunk { + address: address.clone(), + quote: Box::new(quote), + transaction_hash, + }, + content, + ) + .await?; + let BrowserResponseBody::ChunkStored { + address: response_address, + already_stored, + } = response.header.body + else { + return Err("expected a CHUNK_STORED response".to_string()); + }; + if response_address.to_ascii_lowercase() != address { + return Err("node stored a different chunk address".to_string()); + } + Ok((address, already_stored)) + } + + pub(super) fn close(&self) { + if let Some(connection) = self.connection.borrow_mut().take() { + connection.close(); + } + self.hello.borrow_mut().take(); + self.peer_id.borrow_mut().take(); + } +} + +struct BrowserNetworkCore { + seeds: Vec, + pool: Rc, + routing: Rc>>, + failed_endpoints: Rc>>, +} + +impl BrowserNetworkCore { + fn new(seeds: Vec) -> Result { + if seeds.is_empty() { + return Err("at least one seed endpoint is required".to_string()); + } + let seeds = seeds + .into_iter() + .map(|seed| { + parse_webrtc_direct_multiaddr(&seed.multiaddr) + .map(|endpoint| BrowserEndpoint { + multiaddr: endpoint.multiaddr, + }) + .map_err(|error| error.to_string()) + }) + .collect::, _>>()?; + Ok(Self { + seeds, + pool: Rc::new(BrowserClientPool::new(DEFAULT_MAX_POOLED_CLIENTS)?), + routing: Rc::new(RefCell::new(HashMap::new())), + failed_endpoints: Rc::new(RefCell::new( + crate::client_engine::EndpointFailureCache::new( + ENDPOINT_FAILURE_COOLDOWN, + MAX_BROWSER_ENDPOINT_FAILURES, + ), + )), + }) + } + + async fn find_closest( + &self, + target: &str, + progress: &ProgressReporter, + ) -> Result { + let target_key = parse_lookup_key(target, "lookup target")?; + let failures = Rc::new(RefCell::new(Vec::new())); + let seed_futures = self.seeds.iter().cloned().map(|endpoint| { + let pool = Rc::clone(&self.pool); + let failures = Rc::clone(&failures); + let progress = progress.clone(); + async move { + let seed_name = endpoint.multiaddr.clone(); + let result = async { + let client = pool.client(&endpoint).await?; + let hello = client.hello().await?; + progress.report(&format!("Connected seed {}", hello.peer_id)); + BrowserLookupCandidate::parse(BrowserNode { + peer_id: hello.peer_id, + native_addresses: Vec::new(), + reliability: 1.0, + webrtc_direct: Some(hello.endpoint), + }) + } + .await; + match result { + Ok(candidate) => Some(candidate), + Err(error) => { + progress.report(&format!("Seed {seed_name} failed: {error}")); + failures.borrow_mut().push(BrowserLookupFailure { + peer_id: seed_name, + message: error, + }); + None + } + } + } + }); + let mut initial_candidates = self.routing.borrow().values().cloned().collect::>(); + if initial_candidates.is_empty() { + initial_candidates = join_all(seed_futures) + .await + .into_iter() + .flatten() + .collect::>(); + } + if initial_candidates.is_empty() { + let detail = failures + .borrow() + .iter() + .map(|failure| failure.message.as_str()) + .collect::>() + .join("; "); + return Err(format!( + "could not connect to any WebRtcDirect seed: {detail}" + )); + } + + let config = LookupConfig { + count: DEFAULT_LOOKUP_K, + alpha: DEFAULT_LOOKUP_ALPHA, + max_iterations: DEFAULT_MAX_LOOKUP_ITERATIONS, + ..LookupConfig::saorsa(DEFAULT_LOOKUP_K) + }; + let mut lookup = + IterativeLookup::new(target_key, config).map_err(|error| error.to_string())?; + let mut known_endpoints = self + .routing + .borrow() + .iter() + .filter_map(|(peer, candidate)| { + candidate + .wire + .webrtc_direct + .clone() + .map(|endpoint| (*peer, endpoint)) + }) + .collect::>(); + for candidate in initial_candidates { + if let Some(endpoint) = candidate.wire.webrtc_direct.clone() { + known_endpoints.insert(candidate.peer_id, endpoint); + self.routing + .borrow_mut() + .insert(candidate.peer_id, candidate.clone()); + let _ = lookup.add_candidate(candidate); + } + } + let mut query = BrowserNetworkLookupQuery { + pool: Rc::clone(&self.pool), + progress: progress.clone(), + failures: Rc::clone(&failures), + known_endpoints, + routing: Rc::clone(&self.routing), + failed_endpoints: Rc::clone(&self.failed_endpoints), + }; + run_iterative_lookup(&mut lookup, &mut query) + .await + .map_err(|error| error.to_string())?; + let mut routes = self.routing.borrow_mut(); + if routes.len() > MAX_BROWSER_ROUTING_ENTRIES { + let mut peers = routes.keys().copied().collect::>(); + peers.sort_by_key(|peer| xor_distance(peer, &target_key)); + for peer in peers.into_iter().skip(MAX_BROWSER_ROUTING_ENTRIES) { + routes.remove(&peer); + } + } + drop(routes); + let nodes = lookup + .results() + .into_iter() + .map(|candidate| candidate.wire) + .collect(); + let queried = lookup.queried_peers().iter().map(hex::encode).collect(); + let failures = failures.borrow().clone(); + Ok(BrowserLookupResult { + nodes, + queried, + failures, + }) + } + + async fn get_chunk_from_closest( + &self, + address: &str, + progress: &ProgressReporter, + ) -> Result<(Vec, BrowserNode), String> { + let address = super::protocol::normalize_hex(address, 32)?; + let lookup = self.find_closest(&address, progress).await?; + let mut failures = Vec::new(); + for node in lookup.nodes { + let Some(endpoint) = node.webrtc_direct.as_ref() else { + continue; + }; + progress.report(&format!("Requesting {address} from {}", node.peer_id)); + let result = async { + let client = self.pool.client(endpoint).await?; + client.hello().await?; + client.get_chunk(&address).await + } + .await; + match result { + Ok((content, _)) => return Ok((content, node)), + Err(error) => { + progress.report(&format!( + "Node {} did not return the file: {error}", + node.peer_id + )); + failures.push(format!("{}: {error}", node.peer_id)); + } + } + } + Err(format!( + "no closest WebRtcDirect node returned chunk {address}{}", + if failures.is_empty() { + String::new() + } else { + format!(" ({})", failures.join("; ")) + } + )) + } +} + +struct BrowserNetworkLookupQuery { + pool: Rc, + progress: ProgressReporter, + failures: Rc>>, + known_endpoints: HashMap, + routing: Rc>>, + failed_endpoints: Rc>>, +} + +impl LookupQuery for BrowserNetworkLookupQuery { + type Error = String; + + async fn is_candidate_eligible( + &mut self, + candidate: &BrowserLookupCandidate, + ) -> Result { + let Some(endpoint) = candidate.wire.webrtc_direct.as_ref() else { + return Ok(false); + }; + Ok(!self + .failed_endpoints + .borrow_mut() + .is_suppressed(&candidate.peer_id, &endpoint.multiaddr)) + } + + async fn query_batch( + &mut self, + target: LookupKey, + count: usize, + iteration: usize, + batch: Vec, + ) -> Result>, Self::Error> { + let target = hex::encode(target); + let attempted = batch + .iter() + .filter_map(|candidate| { + candidate + .wire + .webrtc_direct + .as_ref() + .map(|endpoint| (candidate.peer_id, endpoint.multiaddr.clone())) + }) + .collect::>(); + let futures: FuturesUnordered<_> = batch + .into_iter() + .map(|candidate| { + let pool = Rc::clone(&self.pool); + let progress = self.progress.clone(); + let failures = Rc::clone(&self.failures); + let failed_endpoints = Rc::clone(&self.failed_endpoints); + let target = target.clone(); + async move { + let responder = candidate.peer_id; + let peer_id = candidate.wire.peer_id.clone(); + let failed_endpoint = candidate + .wire + .webrtc_direct + .as_ref() + .map(|endpoint| endpoint.multiaddr.clone()); + let result = async { + let endpoint = candidate.wire.webrtc_direct.as_ref().ok_or_else(|| { + "lookup candidate has no WebRTC Direct endpoint".to_string() + })?; + let client = pool.client(endpoint).await?; + client.hello().await?; + client.find_node(&target, count).await + } + .await; + match result { + Ok(nodes) => { + failed_endpoints.borrow_mut().record_success(&responder); + progress.report(&format!( + "Iteration {iteration}: {peer_id} returned {} nodes", + nodes.len() + )); + let candidates = nodes + .into_iter() + .filter_map(|wire| match BrowserLookupCandidate::parse(wire) { + Ok(candidate) => Some(candidate), + Err(error) => { + progress.report(&format!( + "Ignoring invalid candidate from {peer_id}: {error}" + )); + None + } + }) + .collect(); + LookupQueryOutcome::Succeeded { + responder, + candidates, + } + } + Err(error) => { + if let Some(endpoint) = failed_endpoint { + failed_endpoints + .borrow_mut() + .record_failure(responder, endpoint); + } + progress.report(&format!("Query {peer_id} failed: {error}")); + failures.borrow_mut().push(BrowserLookupFailure { + peer_id, + message: error, + }); + LookupQueryOutcome::Failed { responder } + } + } + } + }) + .collect(); + let mut outcomes = + collect_after_first_with_grace(futures, || TimeoutFuture::new(LOOKUP_GRACE_TIMEOUT_MS)) + .await; + let responded = outcomes + .iter() + .map(|outcome| *outcome.responder()) + .collect::>(); + for (peer, endpoint) in attempted { + if !responded.contains(&peer) { + self.failed_endpoints + .borrow_mut() + .record_failure(peer, endpoint); + let peer_id = hex::encode(peer); + let message = "did not respond before the lookup grace period".to_string(); + self.progress + .report(&format!("Query {peer_id} failed: {message}")); + self.failures + .borrow_mut() + .push(BrowserLookupFailure { peer_id, message }); + } + } + for outcome in &mut outcomes { + if let LookupQueryOutcome::Succeeded { candidates, .. } = outcome { + candidates.retain_mut(|candidate| { + if let Some(endpoint) = candidate.wire.webrtc_direct.clone() { + self.known_endpoints.insert(candidate.peer_id, endpoint); + } else if let Some(endpoint) = self.known_endpoints.get(&candidate.peer_id) { + candidate.wire.webrtc_direct = Some(endpoint.clone()); + } + if candidate.wire.webrtc_direct.is_some() { + self.routing + .borrow_mut() + .insert(candidate.peer_id, candidate.clone()); + true + } else { + false + } + }); + } + } + Ok(outcomes) + } +} + +#[derive(Clone, Default)] +struct ProgressReporter(Option); + +impl ProgressReporter { + fn from_js(value: Option) -> Self { + Self(value) + } + + fn report(&self, message: &str) { + if let Some(callback) = &self.0 { + let _ = callback.call1(&JsValue::NULL, &JsValue::from_str(message)); + } + } +} + +fn parse_lookup_key(value: &str, label: &str) -> Result { + let value = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(value).map_err(|error| format!("invalid {label}: {error}"))?; + bytes.try_into().map_err(|bytes: Vec| { + format!( + "invalid {label}: expected 32 bytes, received {}", + bytes.len() + ) + }) +} + +#[derive(Debug, Serialize)] +struct BrowserDownloadResult { + #[serde(with = "serde_bytes")] + content: Vec, + hash: String, + file: PublicFileDescriptor, + #[serde(rename = "dataMapNode")] + data_map_node: BrowserNode, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum BrowserPublicFileInput { + Descriptor(PublicFileDescriptor), + Address(String), +} + +impl BrowserPublicFileInput { + fn into_address_and_descriptor(self) -> (String, Option) { + match self { + Self::Descriptor(file) => (file.address.clone(), Some(file)), + Self::Address(address) => (address, None), + } + } +} + +struct ResolvedBrowserPublicFile { + file: PublicFileDescriptor, + expected_hash: Option, + data_map_node: BrowserNode, + root_data_map: self_encryption::DataMap, +} + +#[derive(Clone)] +struct StoreTarget { + peer_id: String, + endpoint: BrowserEndpoint, +} + +struct PreparedRecord { + record: UploadRecord, + already_stored: bool, + targets: Vec, + verified: Option, +} + +struct UploadRecord { + address: String, + size: usize, + content: Option>>, +} + +impl From for UploadRecord { + fn from(record: BrowserRecord) -> Self { + let size = record.content.len(); + Self { + address: record.address, + size, + content: Some(Rc::new(record.content)), + } + } +} + +impl From for UploadRecord { + fn from(record: BrowserRecordInfo) -> Self { + Self { + address: record.address, + size: record.size, + content: None, + } + } +} + +struct PendingStoreRecord<'a> { + index: usize, + record: &'a PreparedRecord, + successful_peers: HashSet, +} + +struct StoreAttemptError { + successful_peers: HashSet, + message: String, +} + +impl StoreAttemptError { + fn new(successful_peers: HashSet, message: impl Into) -> Self { + Self { + successful_peers, + message: message.into(), + } + } +} + +#[derive(Debug, Deserialize)] +struct BrowserPaymentSubmission { + #[serde(rename = "transactionHash")] + transaction_hash: Option, + #[serde(rename = "totalAmount")] + total_amount: String, +} + +#[derive(Debug, Serialize)] +struct BrowserUploadResult { + file: PublicFileDescriptor, + #[serde(rename = "transactionHash", skip_serializing_if = "Option::is_none")] + transaction_hash: Option, + #[serde(rename = "storageCostAtto")] + storage_cost_atto: String, + records: usize, +} + +struct BrowserStoredRecords { + payment: BrowserPaymentSubmission, + replicas: usize, + records: usize, +} + +#[derive(Clone, Copy)] +struct BrowserStoreContext<'a> { + payment_network: &'a BrowserPaymentNetwork, + transaction_hash: Option<&'a str>, + load_record: Option<&'a js_sys::Function>, + progress: &'a ProgressReporter, +} + +struct CachedRangeRecord { + content: bytes::Bytes, + last_used: u64, +} + +#[derive(Default)] +struct BrowserRangeCache { + entries: HashMap<[u8; 32], CachedRangeRecord>, + total_bytes: usize, + clock: u64, +} + +impl BrowserRangeCache { + fn contains(&self, address: &[u8; 32]) -> bool { + self.entries.contains_key(address) + } + + fn get(&mut self, address: &[u8; 32]) -> Option { + self.clock = self.clock.wrapping_add(1); + let entry = self.entries.get_mut(address)?; + entry.last_used = self.clock; + Some(entry.content.clone()) + } + + fn insert(&mut self, address: [u8; 32], content: Vec) { + self.clock = self.clock.wrapping_add(1); + let content = bytes::Bytes::from(content); + if let Some(previous) = self.entries.remove(&address) { + self.total_bytes = self.total_bytes.saturating_sub(previous.content.len()); + } + self.total_bytes = self.total_bytes.saturating_add(content.len()); + self.entries.insert( + address, + CachedRangeRecord { + content, + last_used: self.clock, + }, + ); + while self.total_bytes > MAX_RANGE_CACHE_BYTES && self.entries.len() > 1 { + let Some(oldest) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(address, _)| *address) + else { + break; + }; + if let Some(removed) = self.entries.remove(&oldest) { + self.total_bytes = self.total_bytes.saturating_sub(removed.content.len()); + } + } + } + + fn clear(&mut self) { + self.entries.clear(); + self.total_bytes = 0; + } +} + +/// Random-access public-file reader for media playback and bounded downloads. +#[wasm_bindgen(js_name = BrowserFileReader)] +pub struct BrowserFileReader { + inner: Rc, + file: PublicFileDescriptor, + root_data_map: self_encryption::DataMap, + cache: RefCell, + progress: ProgressReporter, + closed: Cell, +} + +#[wasm_bindgen(js_class = BrowserFileReader)] +impl BrowserFileReader { + /// Plaintext file size in bytes. + #[wasm_bindgen(getter)] + pub fn size(&self) -> usize { + self.file.size + } + + /// Browser MIME type advertised by the file descriptor. + #[wasm_bindgen(getter, js_name = contentType)] + pub fn content_type(&self) -> String { + self.file.content_type.clone() + } + + /// Display filename advertised by the file descriptor. + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.file.name.clone() + } + + /// Fetch and decrypt one plaintext byte range without reconstructing the file. + #[wasm_bindgen(js_name = readRange)] + pub async fn read_range(&self, start: usize, length: usize) -> Result { + let content = self + .read_range_inner(start, length) + .await + .map_err(|error| JsValue::from_str(&error))?; + Ok(Uint8Array::from(content.as_slice())) + } + + /// Release cached encrypted records held for playback read-ahead and seeks. + pub fn close(&self) { + self.closed.set(true); + self.cache.borrow_mut().clear(); + } +} + +impl BrowserFileReader { + async fn read_range_inner(&self, start: usize, length: usize) -> Result, String> { + if self.closed.get() { + return Err("browser file reader is closed".to_string()); + } + if length > MAX_BROWSER_RANGE_BYTES { + return Err(format!( + "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" + )); + } + if length == 0 || start >= self.file.size { + return Ok(Vec::new()); + } + let end = start.saturating_add(length).min(self.file.size); + let required = required_range_records(&self.root_data_map, start, end)?; + if required.is_empty() { + return Err("DataMap contains no records for the requested range".to_string()); + } + + let missing = { + let cache = self.cache.borrow(); + required + .iter() + .filter(|(_, address)| !cache.contains(address)) + .copied() + .collect::>() + }; + if !missing.is_empty() { + let downloads = stream::iter(missing) + .map(|(index, address)| { + let inner = Rc::clone(&self.inner); + let progress = self.progress.clone(); + async move { + let encoded = hex::encode(address); + progress.report(&format!( + "Streaming encrypted chunk {} ({encoded})", + index + 1 + )); + inner + .get_chunk_from_closest(&encoded, &progress) + .await + .map(|(content, _)| (address, content)) + } + }) + .buffer_unordered(MAX_DOWNLOAD_CONCURRENCY) + .collect::>() + .await; + let mut cache = self.cache.borrow_mut(); + for download in downloads { + let (address, content) = download?; + cache.insert(address, content); + } + } + + let available = { + let mut cache = self.cache.borrow_mut(); + required + .iter() + .map(|(_, address)| { + cache + .get(address) + .map(|content| (*address, content)) + .ok_or_else(|| { + format!("streaming cache omitted record {}", hex::encode(address)) + }) + }) + .collect::, _>>()? + }; + let fetch_cached = |requested: &[(usize, self_encryption::XorName)]| { + requested + .iter() + .map(|(index, address)| { + available + .get(&address.0) + .cloned() + .map(|content| (*index, content)) + .ok_or_else(|| { + self_encryption::Error::Generic(format!( + "streaming range omitted record {}", + hex::encode(address.0) + )) + }) + }) + .collect::, _>>() + }; + let stream = self_encryption::streaming_decrypt_with_batch_size( + &self.root_data_map, + fetch_cached, + required.len(), + ) + .map_err(|error| format!("could not initialize range decryption: {error}"))?; + let plaintext = stream + .get_range(start, end - start) + .map_err(|error| format!("could not decrypt requested range: {error}"))?; + if plaintext.len() != end - start { + return Err(format!( + "range decryption returned {} bytes, expected {}", + plaintext.len(), + end - start + )); + } + Ok(plaintext.to_vec()) + } +} + +fn required_range_records( + data_map: &self_encryption::DataMap, + start: usize, + end: usize, +) -> Result, String> { + let mut infos = data_map.infos().to_vec(); + infos.sort_by_key(|info| info.index); + let mut cursor = 0usize; + let mut required = Vec::new(); + for info in infos { + let chunk_end = cursor + .checked_add(info.src_size) + .ok_or_else(|| "DataMap plaintext size overflow".to_string())?; + if cursor < end && chunk_end > start { + required.push((info.index, info.dst_hash.0)); + } + cursor = chunk_end; + } + Ok(required) +} + +/// Stateful Autonomi browser client sharing Rust lookup and data workflows. +#[wasm_bindgen(js_name = BrowserNetworkClient)] +pub struct BrowserNetworkClient { + inner: Rc, + controller: AdaptiveController, +} + +#[wasm_bindgen(js_class = BrowserNetworkClient)] +impl BrowserNetworkClient { + /// Construct a reusable client around stable WebRTC Direct seed addresses. + #[wasm_bindgen(constructor)] + pub fn new(endpoints: JsValue) -> Result { + let endpoints: Vec = serde_wasm_bindgen::from_value(endpoints) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let endpoints = endpoints + .into_iter() + .map(|endpoint| BrowserEndpoint { + multiaddr: endpoint.multiaddr().to_string(), + }) + .collect(); + let inner = + BrowserNetworkCore::new(endpoints).map_err(|error| JsValue::from_str(&error))?; + Ok(Self { + inner: Rc::new(inner), + controller: AdaptiveController::new(ChannelStart::default(), AdaptiveConfig::default()), + }) + } + + /// Run Saorsa's iterative closest-node lookup over Rust-owned DataChannels. + #[wasm_bindgen(js_name = findClosest)] + pub async fn find_closest( + &self, + target: &str, + on_progress: Option, + ) -> Result { + let progress = ProgressReporter::from_js(on_progress); + let result = self + .inner + .find_closest(target, &progress) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Download and reconstruct a complete public Autonomi file. + #[wasm_bindgen(js_name = downloadPublicFile)] + pub async fn download_public_file( + &self, + file: JsValue, + concurrency: usize, + on_progress: Option, + ) -> Result { + let file: BrowserPublicFileInput = serde_wasm_bindgen::from_value(file) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let progress = ProgressReporter::from_js(on_progress); + let result = self + .download_public_file_inner(file, concurrency, &progress) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Resolve and validate a public file for random-access range reads. + #[wasm_bindgen(js_name = openPublicFile)] + pub async fn open_public_file( + &self, + file: JsValue, + on_progress: Option, + ) -> Result { + let file: BrowserPublicFileInput = serde_wasm_bindgen::from_value(file) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let progress = ProgressReporter::from_js(on_progress); + self.open_public_file_inner(file, progress) + .await + .map_err(|error| JsValue::from_str(&error)) + } + + /// Self-encrypt, quote, pay through a wallet callback, and store a public file. + #[wasm_bindgen(js_name = uploadPublicFile)] + pub async fn upload_public_file( + &self, + content: &[u8], + name: &str, + content_type: &str, + payment_network: JsValue, + pay_for_quotes: js_sys::Function, + on_progress: Option, + ) -> Result { + let payment_network: BrowserPaymentNetwork = + serde_wasm_bindgen::from_value(payment_network) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let payment_network = validate_browser_payment_network(payment_network) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let progress = ProgressReporter::from_js(on_progress); + let result = self + .upload_public_file_inner( + content, + name, + content_type, + payment_network, + &pay_for_quotes, + &progress, + ) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Quote, pay for, and upload records produced by `BrowserFileEncryptor`. + /// + /// Record bytes are requested lazily from the asynchronous JavaScript + /// callback, allowing the page to keep them in IndexedDB rather than WASM. + #[wasm_bindgen(js_name = uploadStagedPublicFile)] + pub async fn upload_staged_public_file( + &self, + staged: JsValue, + payment_network: JsValue, + load_record: js_sys::Function, + pay_for_quotes: js_sys::Function, + on_progress: Option, + ) -> Result { + let staged: BrowserStagedFile = serde_wasm_bindgen::from_value(staged) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let payment_network: BrowserPaymentNetwork = + serde_wasm_bindgen::from_value(payment_network) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let payment_network = validate_browser_payment_network(payment_network) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let progress = ProgressReporter::from_js(on_progress); + let result = self + .upload_staged_public_file_inner( + staged, + payment_network, + &load_record, + &pay_for_quotes, + &progress, + ) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Close all pooled WebRTC associations. + pub fn close(&self) { + self.inner.pool.close(); + } +} + +impl BrowserNetworkClient { + async fn open_public_file_inner( + &self, + file: BrowserPublicFileInput, + progress: ProgressReporter, + ) -> Result { + let resolved = self.resolve_public_file(file, &progress).await?; + let file = resolved.file; + progress.report(&format!( + "Ready to stream {} ({} bytes, {} chunks)", + file.name, + file.size, + file.chunks.len() + )); + Ok(BrowserFileReader { + inner: Rc::clone(&self.inner), + file, + root_data_map: resolved.root_data_map, + cache: RefCell::new(BrowserRangeCache::default()), + progress, + closed: Cell::new(false), + }) + } + + async fn download_public_file_inner( + &self, + file: BrowserPublicFileInput, + concurrency: usize, + progress: &ProgressReporter, + ) -> Result { + if concurrency == 0 { + return Err("download concurrency must be a positive integer".to_string()); + } + let concurrency = concurrency.min(MAX_DOWNLOAD_CONCURRENCY); + let mut resolved = self.resolve_public_file(file, progress).await?; + let total = resolved.file.chunks.len(); + let downloads = stream::iter(resolved.file.chunks.iter().cloned().enumerate()) + .map(|(position, chunk)| { + let inner = Rc::clone(&self.inner); + let progress = progress.clone(); + async move { + progress.report(&format!( + "Fetching encrypted file chunk {}/{} ({})", + position + 1, + total, + chunk.dst_hash + )); + inner + .get_chunk_from_closest(&chunk.dst_hash, &progress) + .await + .map(|(content, _)| (position, content)) + } + }) + .buffer_unordered(concurrency) + .collect::>() + .await; + let mut encrypted_chunks = Vec::with_capacity(total); + for download in downloads { + encrypted_chunks.push(download?); + } + encrypted_chunks.sort_by_key(|(position, _)| *position); + let encrypted_chunks = encrypted_chunks + .into_iter() + .map(|(_, content)| content) + .collect::>(); + progress.report(&format!( + "Reconstructing {} with native ant-core WASM", + resolved.file.name + )); + let encrypted_chunks = encrypted_chunks + .into_iter() + .map(|content| self_encryption::EncryptedChunk { + content: bytes::Bytes::from(content), + }) + .collect::>(); + let content = self_encryption::decrypt(&resolved.root_data_map, &encrypted_chunks) + .map_err(|error| format!("could not reconstruct public file: {error}"))? + .to_vec(); + if content.len() != resolved.file.size { + return Err(format!( + "reconstructed file has {} bytes, expected {}", + content.len(), + resolved.file.size + )); + } + let hash = hex::encode(blake3::hash(&content).as_bytes()); + if let Some(expected_hash) = resolved.expected_hash.as_ref() { + super::verify_record(expected_hash, &content).map_err(|error| error.to_string())?; + } + resolved.file.blake3 = hash.clone(); + progress.report(&format!( + "Verified complete {} as {hash}", + resolved.file.name + )); + Ok(BrowserDownloadResult { + content, + hash, + file: resolved.file, + data_map_node: resolved.data_map_node, + }) + } + + async fn resolve_public_file( + &self, + file: BrowserPublicFileInput, + progress: &ProgressReporter, + ) -> Result { + let (address, descriptor) = file.into_address_and_descriptor(); + let address = super::protocol::normalize_hex(&address, 32)?; + progress.report(&format!("Fetching public DataMap {address}")); + let (encoded_data_map, data_map_node) = self + .inner + .get_chunk_from_closest(&address, progress) + .await?; + progress.report(&format!( + "Verified public DataMap ({} bytes)", + encoded_data_map.len() + )); + let published_data_map: self_encryption::DataMap = rmp_serde::from_slice(&encoded_data_map) + .map_err(|error| format!("could not decode public DataMap: {error}"))?; + let root_data_map = if published_data_map.is_child() { + let child_infos = published_data_map.infos().to_vec(); + let downloads = stream::iter(child_infos.iter().cloned()) + .map(|info| { + let inner = Rc::clone(&self.inner); + let progress = progress.clone(); + async move { + let address = hex::encode(info.dst_hash.0); + progress.report(&format!( + "Resolving nested DataMap record {}", + info.index + 1 + )); + inner + .get_chunk_from_closest(&address, &progress) + .await + .map(|(content, _)| (info.dst_hash.0, bytes::Bytes::from(content))) + } + }) + .buffer_unordered(MAX_DOWNLOAD_CONCURRENCY) + .collect::>() + .await; + let mut child_records = HashMap::with_capacity(downloads.len()); + for download in downloads { + let (address, content) = download?; + child_records.insert(address, content); + } + let mut get_child = |address: self_encryption::XorName| { + child_records.get(&address.0).cloned().ok_or_else(|| { + self_encryption::Error::Generic(format!( + "nested DataMap resolution requested unavailable record {}", + hex::encode(address.0) + )) + }) + }; + self_encryption::get_root_data_map(published_data_map, &mut get_child) + .map_err(|error| format!("could not resolve root DataMap: {error}"))? + } else { + published_data_map + }; + + let mut actual_chunks = super::chunk_infos(&root_data_map); + actual_chunks.sort_by_key(|chunk| chunk.index); + if actual_chunks.len() < 3 { + return Err("ant-core returned an invalid public DataMap".to_string()); + } + let resolved_size = actual_chunks.iter().try_fold(0usize, |total, chunk| { + total + .checked_add(chunk.src_size) + .ok_or_else(|| "resolved public file size overflow".to_string()) + })?; + if !(self_encryption::MIN_ENCRYPTABLE_BYTES..=super::MAX_BROWSER_FILE_BYTES) + .contains(&resolved_size) + { + return Err(format!("invalid public file size {resolved_size}")); + } + + let (file, expected_hash) = if let Some(mut file) = descriptor { + file.address = super::protocol::normalize_hex(&file.address, 32)?; + file.blake3 = super::protocol::normalize_hex(&file.blake3, 32)?; + if file.name.is_empty() { + return Err("public file has no name".to_string()); + } + if file.data_map_size != encoded_data_map.len() { + return Err(format!( + "public DataMap has {} bytes, expected {}", + encoded_data_map.len(), + file.data_map_size + )); + } + let mut expected_chunks = file + .chunks + .iter() + .map(|chunk| super::BrowserChunkInfo { + index: chunk.index, + dst_hash: chunk.dst_hash.to_ascii_lowercase(), + src_hash: chunk.src_hash.to_ascii_lowercase(), + src_size: chunk.src_size, + }) + .collect::>(); + expected_chunks.sort_by_key(|chunk| chunk.index); + if actual_chunks != expected_chunks { + return Err( + "resolved root DataMap does not match the public file descriptor".to_string(), + ); + } + if resolved_size != file.size { + return Err(format!( + "resolved public file has {resolved_size} bytes, expected {}", + file.size + )); + } + file.content_type = normalized_content_type(&file.content_type); + file.chunks = actual_chunks; + let expected_hash = file.blake3.clone(); + (file, Some(expected_hash)) + } else { + ( + PublicFileDescriptor { + name: fallback_public_file_name(&address), + address, + size: resolved_size, + content_type: "application/octet-stream".to_string(), + blake3: String::new(), + data_map_size: encoded_data_map.len(), + chunks: actual_chunks, + replicas: 0, + }, + None, + ) + }; + + Ok(ResolvedBrowserPublicFile { + file, + expected_hash, + data_map_node, + root_data_map, + }) + } + + async fn upload_public_file_inner( + &self, + content: &[u8], + name: &str, + content_type: &str, + payment_network: BrowserPaymentNetwork, + pay_for_quotes: &js_sys::Function, + progress: &ProgressReporter, + ) -> Result { + if name.is_empty() { + return Err("upload file has no name".to_string()); + } + progress.report(&format!( + "Self-encrypting {name} with native ant-core WASM ({} bytes)", + content.len() + )); + let encrypted = super::encrypt_public_file(content).map_err(|error| error.to_string())?; + let records = encrypted + .records + .into_iter() + .map(UploadRecord::from) + .collect::>(); + let stored = self + .prepare_pay_and_store_records( + records, + &payment_network, + None, + pay_for_quotes, + progress, + ) + .await?; + let descriptor = PublicFileDescriptor { + name: name.to_string(), + address: encrypted.address, + size: content.len(), + content_type: normalized_content_type(content_type), + blake3: encrypted.blake3, + data_map_size: encrypted.data_map_size, + chunks: encrypted.chunks, + replicas: stored.replicas, + }; + Ok(BrowserUploadResult { + file: descriptor, + transaction_hash: stored.payment.transaction_hash, + storage_cost_atto: stored.payment.total_amount, + records: stored.records, + }) + } + + async fn upload_staged_public_file_inner( + &self, + mut staged: BrowserStagedFile, + payment_network: BrowserPaymentNetwork, + load_record: &js_sys::Function, + pay_for_quotes: &js_sys::Function, + progress: &ProgressReporter, + ) -> Result { + validate_staged_file(&mut staged)?; + progress.report(&format!( + "Preparing paid upload for staged {} ({} bytes, {} records)", + staged.name, + staged.size, + staged.records.len() + )); + let records = staged + .records + .into_iter() + .map(UploadRecord::from) + .collect::>(); + let stored = self + .prepare_pay_and_store_records( + records, + &payment_network, + Some(load_record), + pay_for_quotes, + progress, + ) + .await?; + let descriptor = PublicFileDescriptor { + name: staged.name, + address: staged.address, + size: staged.size, + content_type: staged.content_type, + blake3: staged.blake3, + data_map_size: staged.data_map_size, + chunks: staged.chunks, + replicas: stored.replicas, + }; + Ok(BrowserUploadResult { + file: descriptor, + transaction_hash: stored.payment.transaction_hash, + storage_cost_atto: stored.payment.total_amount, + records: stored.records, + }) + } + + async fn prepare_pay_and_store_records( + &self, + records: Vec, + payment_network: &BrowserPaymentNetwork, + load_record: Option<&js_sys::Function>, + pay_for_quotes: &js_sys::Function, + progress: &ProgressReporter, + ) -> Result { + let record_count = records.len(); + let mut records = records.into_iter().enumerate(); + let mut prepared = Vec::with_capacity(record_count); + if let Some((index, record)) = records.next() { + progress.report(&format!("Preparing record {}/{}", index + 1, record_count)); + prepared.push(( + index, + self.prepare_record(record, payment_network, progress) + .await?, + )); + } + let payment_network_ref = payment_network; + let remaining = records.map(|(index, record)| async move { + progress.report(&format!("Preparing record {}/{}", index + 1, record_count)); + self.prepare_record(record, payment_network_ref, progress) + .await + .map(|prepared| (index, prepared)) + }); + let remaining = + crate::client_engine::bounded_unordered(remaining, DEFAULT_BROWSER_QUOTE_CONCURRENCY) + .collect::>() + .await; + for result in remaining { + prepared.push(result?); + } + prepared.sort_by_key(|(index, _)| *index); + let prepared = prepared + .into_iter() + .map(|(_, record)| record) + .collect::>(); + let verified_quotes = prepared + .iter() + .filter_map(|record| record.verified.clone()) + .collect::>(); + let expected_total = + storage_payment_total(&verified_quotes).map_err(|error| error.to_string())?; + let mut payment = if verified_quotes.is_empty() { + BrowserPaymentSubmission { + transaction_hash: None, + total_amount: "0".to_string(), + } + } else { + invoke_payment(pay_for_quotes, payment_network, &verified_quotes).await? + }; + if !verified_quotes.is_empty() && payment.transaction_hash.is_none() { + return Err("wallet callback returned no storage payment transaction".to_string()); + } + if payment.total_amount != expected_total { + return Err(format!( + "wallet callback reported payment total {}, expected {expected_total}", + payment.total_amount + )); + } + if let Some(transaction_hash) = payment.transaction_hash.as_mut() { + *transaction_hash = super::protocol::normalize_hex(transaction_hash, 32)?; + } + + let replicas = self + .store_prepared_records( + &prepared, + payment_network, + payment.transaction_hash.as_deref(), + load_record, + progress, + ) + .await?; + Ok(BrowserStoredRecords { + payment, + replicas, + records: prepared.len(), + }) + } + + async fn prepare_record( + &self, + record: UploadRecord, + payment_network: &BrowserPaymentNetwork, + progress: &ProgressReporter, + ) -> Result { + progress.report(&format!("Finding closest nodes for {}", record.address)); + let lookup = self.inner.find_closest(&record.address, progress).await?; + let targets = lookup + .nodes + .into_iter() + .filter_map(|node| { + node.webrtc_direct.map(|endpoint| StoreTarget { + peer_id: node.peer_id, + endpoint, + }) + }) + .take(CLOSE_GROUP_SIZE) + .collect::>(); + if targets.is_empty() { + return Err( + "closest-node lookup returned no WebRTC Direct storage targets".to_string(), + ); + } + let mut failures = Vec::new(); + for target in &targets { + let result = async { + let client = self.inner.pool.client(&target.endpoint).await?; + let hello = client.hello().await?; + assert_upload_node(&hello, payment_network)?; + let (quote, already_stored) = + client.quote_chunk(&record.address, record.size).await?; + let verified = verify_storage_quote(quote, &record.address, &target.peer_id) + .map_err(|error| error.to_string())?; + Ok::<_, String>((already_stored, verified)) + } + .await; + match result { + Ok((true, _)) => { + progress.report(&format!( + "Chunk {} is already stored; skipping payment", + record.address + )); + return Ok(PreparedRecord { + record, + already_stored: true, + targets, + verified: None, + }); + } + Ok((false, verified)) => { + progress.report(&format!( + "Verified storage quote {} from {}", + verified.quote_hash, target.peer_id + )); + let mut ordered_targets = Vec::with_capacity(targets.len()); + ordered_targets.push(target.clone()); + ordered_targets.extend( + targets + .iter() + .filter(|candidate| candidate.peer_id != target.peer_id) + .cloned(), + ); + return Ok(PreparedRecord { + record, + already_stored: false, + targets: ordered_targets, + verified: Some(verified), + }); + } + Err(error) => failures.push(format!("{}: {error}", target.peer_id)), + } + } + Err(format!( + "no closest node supplied a valid quote ({})", + failures.join("; ") + )) + } + + /// Store every paid record with the same adaptive, byte-bounded retry + /// rounds used by the native client. + async fn store_prepared_records( + &self, + prepared: &[PreparedRecord], + payment_network: &BrowserPaymentNetwork, + transaction_hash: Option<&str>, + load_record: Option<&js_sys::Function>, + progress: &ProgressReporter, + ) -> Result { + let record_count = prepared.len(); + let max_record_bytes = prepared + .iter() + .map(|record| record.record.size) + .max() + .unwrap_or(0); + let byte_bound = crate::client_engine::store_byte_bound(max_record_bytes); + let mut to_retry = prepared + .iter() + .enumerate() + .map(|(index, record)| PendingStoreRecord { + index, + record, + successful_peers: HashSet::new(), + }) + .collect::>(); + let mut replicas = usize::MAX; + let context = BrowserStoreContext { + payment_network, + transaction_hash, + load_record, + progress, + }; + + for attempt in 0..=crate::client_engine::STORE_MAX_RETRIES { + if attempt > 0 { + let delay = crate::client_engine::store_retry_delay(attempt); + progress.report(&format!( + "Retrying {} record(s), attempt {attempt}/{}", + to_retry.len(), + crate::client_engine::STORE_MAX_RETRIES + )); + TimeoutFuture::new(u32::try_from(delay.as_millis()).unwrap_or(u32::MAX)).await; + } + + let op_limiter = self.controller.store.clone(); + let cap_limiter = op_limiter.clone(); + let results = crate::client_engine::rolling_unordered( + to_retry, + |pending| { + let PendingStoreRecord { + index, + record, + successful_peers, + } = pending; + let limiter = op_limiter.clone(); + async move { + progress.report(&format!( + "Storing record {}/{} (attempt {}/{})", + index + 1, + record_count, + attempt + 1, + crate::client_engine::STORE_MAX_RETRIES + 1 + )); + let result = observe_op( + &limiter, + || self.store_prepared_once(index, record, &context, successful_peers), + |error| classify_browser_store_error(&error.message), + ) + .await; + ((index, record), result) + } + }, + || cap_limiter.current().min(byte_bound), + ) + .await; + + let mut failed = Vec::new(); + for ((index, record), result) in results { + match result { + Ok(stored) => replicas = replicas.min(stored), + Err(error) => failed.push(( + PendingStoreRecord { + index, + record, + successful_peers: error.successful_peers, + }, + error.message, + )), + } + } + if failed.is_empty() { + return Ok(if replicas == usize::MAX { 0 } else { replicas }); + } + if attempt == crate::client_engine::STORE_MAX_RETRIES { + let failed_count = failed.len(); + let details = failed + .into_iter() + .map(|(pending, error)| { + format!("record {}/{}: {error}", pending.index + 1, record_count) + }) + .collect::>() + .join("; "); + return Err(format!( + "{} paid record(s) failed after {} attempts: {details}", + failed_count, + crate::client_engine::STORE_MAX_RETRIES + 1 + )); + } + to_retry = failed.into_iter().map(|(pending, _)| pending).collect(); + } + + Err("record store retry loop ended unexpectedly".to_string()) + } + + /// Store one record to a close-group majority, advancing through the rest + /// of the ordered K=7 target set only when an initial target fails. + async fn store_prepared_once( + &self, + record_index: usize, + prepared: &PreparedRecord, + context: &BrowserStoreContext<'_>, + mut successful_peers: HashSet, + ) -> Result { + if prepared.already_stored { + return Ok(1); + } + let Some(transaction_hash) = context.transaction_hash else { + return Err(StoreAttemptError::new( + successful_peers, + "paid record has no transaction hash", + )); + }; + let transaction_hash = transaction_hash.to_string(); + let Some(verified) = prepared.verified.as_ref() else { + return Err(StoreAttemptError::new( + successful_peers, + "paid record has no verified quote", + )); + }; + let record = load_upload_record(record_index, &prepared.record, context.load_record) + .await + .map_err(|error| StoreAttemptError::new(successful_peers.clone(), error))?; + let required = CLOSE_GROUP_MAJORITY.saturating_sub(successful_peers.len()); + let outcome = crate::client_engine::quorum_with_fallback( + prepared + .targets + .iter() + .filter(|target| !successful_peers.contains(&target.peer_id)) + .cloned(), + required, + |target| { + let pool = Rc::clone(&self.inner.pool); + let record = Rc::clone(&record); + let quote = verified.quote.clone(); + let payment_network = context.payment_network.clone(); + let transaction_hash = transaction_hash.clone(); + let progress = context.progress.clone(); + async move { + let client = pool.client(&target.endpoint).await?; + let hello = client.hello().await?; + assert_upload_node(&hello, &payment_network)?; + let (_, already_stored) = client + .put_chunk( + &prepared.record.address, + record.as_slice(), + quote, + &transaction_hash, + ) + .await?; + if already_stored { + progress.report(&format!( + "Already stored on {}: {}", + target.peer_id, prepared.record.address + )); + } else { + progress.report(&format!( + "Stored {} on {}", + prepared.record.address, target.peer_id + )); + } + Ok::<(), String>(()) + } + }, + ) + .await; + debug_assert_eq!(outcome.successes, outcome.successful_targets.len()); + for target in outcome.successful_targets { + successful_peers.insert(target.peer_id); + } + let failures = outcome + .failures + .into_iter() + .map(|(target, error)| { + context + .progress + .report(&format!("Store target {} failed: {error}", target.peer_id)); + format!("{}: {error}", target.peer_id) + }) + .collect::>(); + if !outcome.reached || successful_peers.len() < CLOSE_GROUP_MAJORITY { + let replicas = successful_peers.len(); + return Err(StoreAttemptError::new( + successful_peers, + format!( + "stored on {} peers, need {CLOSE_GROUP_MAJORITY}; failures: {}", + replicas, + failures.join("; ") + ), + )); + } + Ok(successful_peers.len()) + } +} + +fn normalized_content_type(content_type: &str) -> String { + if content_type.is_empty() { + "application/octet-stream".to_string() + } else { + content_type.to_string() + } +} + +fn fallback_public_file_name(address: &str) -> String { + format!("public-file-{}.bin", &address[..16]) +} + +fn validate_staged_file(staged: &mut BrowserStagedFile) -> Result<(), String> { + if staged.name.is_empty() { + return Err("upload file has no name".to_string()); + } + staged.content_type = normalized_content_type(&staged.content_type); + if staged.size < self_encryption::MIN_ENCRYPTABLE_BYTES + || staged.size > super::MAX_BROWSER_FILE_BYTES + { + return Err(format!("invalid staged file size {}", staged.size)); + } + if staged.records.is_empty() { + return Err("staged upload contains no records".to_string()); + } + // A 1 GB self-encrypted file currently needs only a few hundred records. + // Keep malformed JavaScript metadata from creating unbounded quote work. + if staged.records.len() > 4096 { + return Err("staged upload contains too many records".to_string()); + } + + staged.address = super::protocol::normalize_hex(&staged.address, 32)?; + staged.blake3 = super::protocol::normalize_hex(&staged.blake3, 32)?; + for record in &mut staged.records { + record.address = super::protocol::normalize_hex(&record.address, 32)?; + if record.size == 0 || record.size > MAX_BROWSER_RECORD_BYTES { + return Err(format!( + "staged record {} has invalid size {}", + record.address, record.size + )); + } + } + let public_data_map = staged + .records + .last() + .ok_or_else(|| "staged upload contains no public DataMap".to_string())?; + if public_data_map.address != staged.address || public_data_map.size != staged.data_map_size { + return Err("staged public DataMap metadata does not match its record".to_string()); + } + for chunk in &mut staged.chunks { + chunk.dst_hash = super::protocol::normalize_hex(&chunk.dst_hash, 32)?; + chunk.src_hash = super::protocol::normalize_hex(&chunk.src_hash, 32)?; + } + Ok(()) +} + +async fn load_upload_record( + index: usize, + record: &UploadRecord, + loader: Option<&js_sys::Function>, +) -> Result>, String> { + let content = if let Some(content) = &record.content { + Rc::clone(content) + } else { + let loader = loader.ok_or_else(|| "staged record loader is unavailable".to_string())?; + let returned = loader + .call3( + &JsValue::NULL, + &JsValue::from_f64(index as f64), + &JsValue::from_str(&record.address), + &JsValue::from_f64(record.size as f64), + ) + .map_err(js_error_message)?; + let returned = JsFuture::from(Promise::resolve(&returned)) + .await + .map_err(js_error_message)?; + if !returned.is_instance_of::() { + return Err(format!( + "staged record loader returned a non-Uint8Array for record {}", + index + 1 + )); + } + let returned = Uint8Array::new(&returned); + if returned.length() as usize != record.size { + return Err(format!( + "staged record {} has {} bytes, expected {}", + index + 1, + returned.length(), + record.size + )); + } + let mut content = vec![0u8; record.size]; + returned.copy_to(&mut content); + Rc::new(content) + }; + super::verify_record(&record.address, content.as_slice()).map_err(|error| error.to_string())?; + Ok(content) +} + +fn classify_browser_store_error(error: &str) -> Outcome { + let error = error.to_ascii_lowercase(); + if error.contains("timed out") || error.contains("timeout") { + Outcome::Timeout + } else if error.contains("webrtc") + || error.contains("datachannel") + || error.contains("ice") + || error.contains("connect") + || error.contains("closed") + || error.contains("invalid state") + { + Outcome::NetworkError + } else { + Outcome::ApplicationError + } +} + +async fn invoke_payment( + callback: &js_sys::Function, + payment_network: &BrowserPaymentNetwork, + quotes: &[VerifiedStorageQuote], +) -> Result { + let payment_network = + serde_wasm_bindgen::to_value(payment_network).map_err(|error| error.to_string())?; + let quotes = serde_wasm_bindgen::to_value(quotes).map_err(|error| error.to_string())?; + let returned = callback + .call2(&JsValue::NULL, &payment_network, "es) + .map_err(js_error_message)?; + let returned = JsFuture::from(Promise::resolve(&returned)) + .await + .map_err(js_error_message)?; + serde_wasm_bindgen::from_value(returned) + .map_err(|error| format!("wallet callback returned an invalid payment result: {error}")) +} + +fn assert_upload_node( + hello: &BrowserHello, + expected: &BrowserPaymentNetwork, +) -> Result<(), String> { + if !hello + .capabilities + .iter() + .any(|value| value == "quote_chunk") + || !hello.capabilities.iter().any(|value| value == "put_chunk") + { + return Err("node does not advertise paid browser uploads".to_string()); + } + let advertised = &hello.payment; + let advertised_rpc = url::Url::parse(&advertised.rpc_url) + .map_err(|error| format!("node advertises invalid payment RPC URL: {error}"))?; + let expected_rpc = url::Url::parse(&expected.rpc_url) + .map_err(|error| format!("manifest has invalid payment RPC URL: {error}"))?; + if advertised_rpc != expected_rpc + || !advertised + .payment_token_address + .eq_ignore_ascii_case(&expected.payment_token_address) + || !advertised + .payment_vault_address + .eq_ignore_ascii_case(&expected.payment_vault_address) + { + return Err("node advertises a different payment network than the manifest".to_string()); + } + Ok(()) +} + +/// One authenticated browser-to-node WebRTC Direct client implemented in Rust. +#[wasm_bindgen(js_name = BrowserNodeClient)] +pub struct BrowserNodeClient { + inner: Rc, +} + +#[wasm_bindgen(js_class = BrowserNodeClient)] +impl BrowserNodeClient { + /// Construct a client from a raw or structured WebRTC Direct endpoint. + #[wasm_bindgen(constructor)] + pub fn new(endpoint: JsValue) -> Result { + let endpoint: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let endpoint = parse_webrtc_direct_multiaddr(endpoint.multiaddr()) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(Self { + inner: Rc::new(BrowserNodeClientCore::new(endpoint)), + }) + } + + /// Authenticated peer ID, when HELLO has completed. + #[wasm_bindgen(getter, js_name = peerId)] + pub fn peer_id(&self) -> Option { + self.inner.peer_id() + } + + /// Open the direct DataChannel without issuing an application request. + pub async fn connect(&self) -> Result<(), JsValue> { + let _guard = self.inner.request_lock.lock().await; + self.inner + .ensure_connected() + .await + .map_err(|error| JsValue::from_str(&error)) + } + + /// Authenticate the connected node. + pub async fn hello(&self) -> Result { + let hello = self + .inner + .hello() + .await + .map_err(|error| JsValue::from_str(&error))?; + hello + .serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Request nodes closest to a 32-byte target. + #[wasm_bindgen(js_name = findNode)] + pub async fn find_node(&self, target: &str, count: usize) -> Result { + let nodes = self + .inner + .find_node(target, count) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&nodes).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Retrieve and BLAKE3-verify one content-addressed record. + #[wasm_bindgen(js_name = getChunk)] + pub async fn get_chunk(&self, address: &str) -> Result { + let (content, hash) = self + .inner + .get_chunk(address) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&BrowserChunk { content, hash }) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Request a signed storage quote. + #[wasm_bindgen(js_name = quoteChunk)] + pub async fn quote_chunk(&self, address: &str, size: usize) -> Result { + let (quote, already_stored) = self + .inner + .quote_chunk(address, size) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&BrowserQuoteResponse { + quote, + already_stored, + }) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Store a paid content-addressed record. + #[wasm_bindgen(js_name = putChunk)] + pub async fn put_chunk( + &self, + address: &str, + content: &[u8], + quote: JsValue, + transaction_hash: &str, + ) -> Result { + let quote: BrowserQuoteArtifact = serde_wasm_bindgen::from_value(quote) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let (address, already_stored) = self + .inner + .put_chunk(address, content, quote, transaction_hash) + .await + .map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&BrowserPutResponse { + address, + already_stored, + }) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Close the DataChannel and peer connection. + pub fn close(&self) { + self.inner.close(); + } +} + +async fn establish_pq_session( + connection: &Connection, + endpoint: &WebRtcDirectEndpoint, +) -> Result { + let expected_peer_id: [u8; 32] = hex::decode(&endpoint.peer_id) + .map_err(|error| format!("invalid endpoint peer ID: {error}"))? + .try_into() + .map_err(|peer_id: Vec| { + format!("endpoint peer ID is {} bytes; expected 32", peer_id.len()) + })?; + let (handshake, client_hello) = + PqClientHandshake::start().map_err(|error| error.to_string())?; + let client_hello = encode_pq_frame(&client_hello).map_err(|error| error.to_string())?; + send_data_channel_frame(&connection.data_channel, &client_hello, REQUEST_TIMEOUT_MS).await?; + let server_accept = read_pq_payload( + Rc::clone(&connection.inbox), + PQ_SERVER_ACCEPT_BYTES, + REQUEST_TIMEOUT_MS, + ) + .await?; + handshake + .finish(&server_accept, &expected_peer_id) + .map_err(|error| error.to_string()) +} + +async fn send_data_channel_frame( + channel: &RtcDataChannel, + frame: &[u8], + timeout_ms: u32, +) -> Result<(), String> { + let send_deadline_ms = js_sys::Date::now() + f64::from(timeout_ms); + for message in frame.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + wait_for_capacity(channel, remaining_timeout_ms(send_deadline_ms)).await?; + channel + .send_with_u8_array(message) + .map_err(js_error_message)?; + } + Ok(()) +} + +async fn read_pq_payload( + receiver: ResponseInbox, + max_payload_bytes: usize, + initial_timeout_ms: u32, +) -> Result, String> { + let mut frame = Vec::with_capacity(8 * 1024); + let mut expected_length = None; + let response_started_ms = js_sys::Date::now(); + let mut response_deadline_ms = response_started_ms + f64::from(initial_timeout_ms); + loop { + let remaining_ms = remaining_timeout_ms(response_deadline_ms); + let next = timeout_with_ms( + async { Ok(receiver.lock().await.next().await) }, + "WebRTC request timed out", + remaining_ms, + ) + .await?; + let message = next + .ok_or_else(|| "response ended before its declared frame was complete".to_string())??; + let next_length = frame + .len() + .checked_add(message.len()) + .ok_or_else(|| "response length overflow".to_string())?; + let max_frame_bytes = max_payload_bytes + .checked_add(4) + .ok_or_else(|| "PQ frame limit overflow".to_string())?; + if next_length > max_frame_bytes { + return Err(format!( + "PQ frame exceeded the {max_payload_bytes}-byte payload limit" + )); + } + frame.extend_from_slice(&message); + if expected_length.is_none() { + expected_length = + pq_frame_length(&frame, max_payload_bytes).map_err(|error| error.to_string())?; + if let Some(expected) = expected_length { + response_deadline_ms = response_deadline_ms + .max(response_started_ms + f64::from(transfer_timeout_ms(expected))); + } + } + if let Some(expected) = expected_length { + if frame.len() > expected { + return Err("PQ frame contains bytes after its declared payload".to_string()); + } + if frame.len() == expected { + return decode_pq_frame(&frame, max_payload_bytes) + .map_err(|error| error.to_string()); + } + } + } +} + +async fn wait_for_capacity(channel: &RtcDataChannel, timeout_ms: u32) -> Result<(), String> { + if channel.ready_state() != RtcDataChannelState::Open { + return Err("WebRTC DataChannel closed while draining".to_string()); + } + if channel.buffered_amount() <= MAX_BUFFERED_AMOUNT { + return Ok(()); + } + channel.set_buffered_amount_low_threshold(MAX_BUFFERED_AMOUNT / 2); + let (sender, receiver) = oneshot::channel::<()>(); + let sender = Rc::new(RefCell::new(Some(sender))); + let ready_sender = Rc::clone(&sender); + let on_ready = Closure::::new(move |_event: Event| { + if let Some(sender) = ready_sender.borrow_mut().take() { + let _ = sender.send(()); + } + }); + channel.set_onbufferedamountlow(Some(on_ready.as_ref().unchecked_ref())); + // The buffer can cross the threshold between the first check and callback + // installation. Re-check after installing it so that race cannot turn a + // completed drain into a full transfer-timeout wait. + if channel.buffered_amount() <= MAX_BUFFERED_AMOUNT { + if let Some(sender) = sender.borrow_mut().take() { + let _ = sender.send(()); + } + } + let result = timeout_with_ms( + async move { + receiver + .await + .map_err(|_| "WebRTC DataChannel closed while draining".to_string()) + }, + "WebRTC DataChannel drain timed out", + timeout_ms, + ) + .await; + channel.set_onbufferedamountlow(None); + drop(on_ready); + result +} + +async fn timeout(future: F, message: &'static str) -> Result +where + F: Future>, +{ + timeout_with_ms(future, message, REQUEST_TIMEOUT_MS).await +} + +async fn timeout_with_ms( + future: F, + message: &'static str, + timeout_ms: u32, +) -> Result +where + F: Future>, +{ + let operation = Box::pin(future); + let timer = Box::pin(TimeoutFuture::new(timeout_ms)); + match select(operation, timer).await { + Either::Left((result, _)) => result, + Either::Right(((), _)) => Err(message.to_string()), + } +} + +fn transfer_timeout_ms(content_bytes: usize) -> u32 { + u32::try_from(transfer_timeout(content_bytes).as_millis()).unwrap_or(u32::MAX) +} + +fn remaining_timeout_ms(deadline_ms: f64) -> u32 { + let remaining_ms = (deadline_ms - js_sys::Date::now()).ceil(); + if !remaining_ms.is_finite() || remaining_ms <= 0.0 { + 0 + } else if remaining_ms >= f64::from(u32::MAX) { + u32::MAX + } else { + remaining_ms as u32 + } +} + +fn js_error_message(value: JsValue) -> String { + value + .as_string() + .or_else(|| { + js_sys::Reflect::get(&value, &JsValue::from_str("message")) + .ok()? + .as_string() + }) + .unwrap_or_else(|| format!("browser WebRTC operation failed: {value:?}")) +} diff --git a/ant-core/src/client_engine.rs b/ant-core/src/client_engine.rs new file mode 100644 index 00000000..d9a5ff96 --- /dev/null +++ b/ant-core/src/client_engine.rs @@ -0,0 +1,387 @@ +//! Runtime-neutral scheduling and session state shared by native and browser clients. + +use futures_util::{stream, stream::FuturesUnordered, Stream, StreamExt as _}; +#[cfg(any(feature = "browser-wasm", test))] +use std::collections::HashMap; +use std::future::Future; +#[cfg(any(feature = "browser-wasm", test))] +use std::hash::Hash; +use std::time::Duration; +#[cfg(any(feature = "browser-wasm", test))] +use web_time::Instant; + +#[cfg_attr( + all(feature = "browser-wasm", not(feature = "native")), + allow(dead_code) +)] +#[path = "data/client/adaptive.rs"] +pub(crate) mod adaptive; + +/// Maximum combined source-record bytes scheduled for concurrent storage. +/// +/// A record is sent to several close-group peers, so its actual wire footprint +/// is larger than its source body. Keeping the shared budget expressed in +/// source bytes lets native QUIC and browser WebRTC use the same conservative +/// scheduling policy without coupling it to either transport. +pub(crate) const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024; + +/// Number of whole-record store retries after the first attempt. +pub(crate) const STORE_MAX_RETRIES: u32 = 3; + +/// Initial delay for exponential whole-record store retries. +pub(crate) const STORE_RETRY_BASE_DELAY_MS: u64 = 500; + +/// Outcome of a quorum operation over an ordered target set. +#[derive(Debug)] +pub(crate) struct QuorumOutcome { + pub(crate) successes: usize, + pub(crate) successful_targets: Vec, + pub(crate) failures: Vec<(T, E)>, + pub(crate) reached: bool, +} + +/// Run an operation against the first `required` targets concurrently, using +/// later targets one-for-one as fallbacks when an attempt fails. +/// +/// The function returns as soon as quorum is reached and drops any remaining +/// in-flight work. This is the transport-neutral close-group delivery policy +/// shared by native QUIC and browser WebRTC uploads. +pub(crate) async fn quorum_with_fallback( + targets: impl IntoIterator, + required: usize, + operation: F, +) -> QuorumOutcome +where + T: Clone, + F: Fn(T) -> Fut, + Fut: Future>, +{ + if required == 0 { + return QuorumOutcome { + successes: 0, + successful_targets: Vec::new(), + failures: Vec::new(), + reached: true, + }; + } + + let mut targets = targets.into_iter(); + let launch = |target: T| { + let future = operation(target.clone()); + async move { (target, future.await) } + }; + let mut in_flight = FuturesUnordered::new(); + for target in targets.by_ref().take(required) { + in_flight.push(launch(target)); + } + + let mut successes = 0usize; + let mut successful_targets = Vec::with_capacity(required); + let mut failures = Vec::new(); + while let Some((target, result)) = in_flight.next().await { + match result { + Ok(_) => { + successes += 1; + successful_targets.push(target); + if successes >= required { + return QuorumOutcome { + successes, + successful_targets, + failures, + reached: true, + }; + } + } + Err(error) => { + failures.push((target, error)); + if let Some(fallback) = targets.next() { + in_flight.push(launch(fallback)); + } + } + } + } + + QuorumOutcome { + successes, + successful_targets, + failures, + reached: false, + } +} + +/// Run all items with a rolling concurrency window whose cap is re-read after +/// every completion. +/// +/// Unlike [`bounded_unordered`], this collects every result and therefore fits +/// retry rounds: one failed item does not prevent untouched siblings from +/// being attempted. The cap callback lets callers combine the shared adaptive +/// limiter with a payload-byte ceiling. +pub(crate) async fn rolling_unordered( + items: I, + mut operation: F, + current_cap: C, +) -> Vec +where + I: IntoIterator, + F: FnMut(I::Item) -> Fut, + Fut: Future, + C: Fn() -> usize, +{ + let mut items = items.into_iter(); + let mut in_flight = FuturesUnordered::new(); + let mut results = Vec::new(); + loop { + let cap = current_cap().max(1); + while in_flight.len() < cap { + match items.next() { + Some(item) => in_flight.push(operation(item)), + None => break, + } + } + let Some(result) = in_flight.next().await else { + break; + }; + results.push(result); + } + results +} + +/// Limit concurrent record stores by the shared source-body byte budget. +#[must_use] +pub(crate) fn store_byte_bound(max_record_bytes: usize) -> usize { + STORE_INFLIGHT_BYTE_BUDGET + .checked_div(max_record_bytes) + .map_or(usize::MAX, |bound| bound.max(1)) +} + +/// Exponential delay for retry round `attempt`, where attempt 1 is the first +/// retry after the initial operation. +#[must_use] +pub(crate) fn store_retry_delay(attempt: u32) -> Duration { + Duration::from_millis(STORE_RETRY_BASE_DELAY_MS * 2u64.pow(attempt.saturating_sub(1))) +} + +/// Run futures with a bounded rolling concurrency window. +/// +/// This is the common scheduling primitive behind native upload waves and the +/// browser upload pipeline. Callers retain responsibility for classifying +/// results and adapting the next window's limit. +pub(crate) fn bounded_unordered( + futures: I, + concurrency: usize, +) -> impl Stream +where + I: IntoIterator, + F: Future, +{ + stream::iter(futures).buffer_unordered(concurrency.max(1)) +} + +#[cfg(any(feature = "browser-wasm", test))] +#[derive(Debug, Clone)] +struct FailureRecord { + endpoint: String, + failed_at: Instant, +} + +/// Runtime-neutral negative cache for transport endpoints. +/// +/// Entries are keyed by authenticated peer identity and also retain the exact +/// endpoint that failed. A peer is immediately eligible again when it +/// republishes a different endpoint, while repeated use of the same dead +/// address is suppressed for the configured cooldown. +#[cfg(any(feature = "browser-wasm", test))] +#[derive(Debug)] +pub(crate) struct EndpointFailureCache { + cooldown: Duration, + max_entries: usize, + entries: HashMap, +} + +#[cfg(any(feature = "browser-wasm", test))] +impl EndpointFailureCache +where + K: Clone + Eq + Hash, +{ + pub(crate) fn new(cooldown: Duration, max_entries: usize) -> Self { + Self { + cooldown, + max_entries: max_entries.max(1), + entries: HashMap::new(), + } + } + + pub(crate) fn is_suppressed(&mut self, peer: &K, endpoint: &str) -> bool { + let Some(record) = self.entries.get(peer) else { + return false; + }; + if record.endpoint != endpoint || record.failed_at.elapsed() >= self.cooldown { + self.entries.remove(peer); + return false; + } + true + } + + pub(crate) fn record_failure(&mut self, peer: K, endpoint: String) { + if !self.entries.contains_key(&peer) && self.entries.len() >= self.max_entries { + let oldest = self + .entries + .iter() + .max_by_key(|(_, record)| record.failed_at.elapsed()) + .map(|(peer, _)| peer.clone()); + if let Some(oldest) = oldest { + self.entries.remove(&oldest); + } + } + self.entries.insert( + peer, + FailureRecord { + endpoint, + failed_at: Instant::now(), + }, + ); + } + + pub(crate) fn record_success(&mut self, peer: &K) { + self.entries.remove(peer); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::rc::Rc; + + #[test] + fn changed_endpoint_bypasses_failure_cooldown() { + let mut cache = EndpointFailureCache::new(Duration::from_secs(60), 8); + cache.record_failure(7_u8, "old".to_string()); + assert!(cache.is_suppressed(&7, "old")); + assert!(!cache.is_suppressed(&7, "new")); + assert!(!cache.is_suppressed(&7, "old")); + } + + #[test] + fn success_clears_failure() { + let mut cache = EndpointFailureCache::new(Duration::from_secs(60), 8); + cache.record_failure(7_u8, "endpoint".to_string()); + cache.record_success(&7); + assert!(!cache.is_suppressed(&7, "endpoint")); + } + + #[test] + fn failure_cache_evicts_oldest_entry_at_capacity() { + let mut cache = EndpointFailureCache::new(Duration::from_secs(60), 1); + cache.record_failure(7_u8, "first".to_string()); + cache.record_failure(8_u8, "second".to_string()); + + assert!(!cache.is_suppressed(&7, "first")); + assert!(cache.is_suppressed(&8, "second")); + } + + #[test] + fn bounded_scheduler_keeps_all_outputs() { + let outputs = futures::executor::block_on(async { + bounded_unordered( + [ + futures_util::future::ready(1_u8), + futures_util::future::ready(2_u8), + ], + 0, + ) + .collect::>() + .await + }); + assert_eq!(outputs.len(), 2); + assert!(outputs.contains(&1)); + assert!(outputs.contains(&2)); + } + + #[test] + fn quorum_starts_only_the_required_targets() { + let launched = Rc::new(Cell::new(0usize)); + let outcome = futures::executor::block_on({ + let launched = Rc::clone(&launched); + async move { + quorum_with_fallback(0_u8..7, 4, move |_| { + launched.set(launched.get() + 1); + futures_util::future::ready(Ok::<(), ()>(())) + }) + .await + } + }); + + assert!(outcome.reached); + assert_eq!(outcome.successes, 4); + let mut successful_targets = outcome.successful_targets; + successful_targets.sort_unstable(); + assert_eq!(successful_targets, vec![0, 1, 2, 3]); + assert!(outcome.failures.is_empty()); + assert_eq!(launched.get(), 4); + } + + #[test] + fn quorum_advances_through_fallbacks_after_failures() { + let launched = Rc::new(Cell::new(0usize)); + let outcome = futures::executor::block_on({ + let launched = Rc::clone(&launched); + async move { + quorum_with_fallback(0_u8..7, 4, move |target| { + launched.set(launched.get() + 1); + futures_util::future::ready(if target < 2 { Err(target) } else { Ok(()) }) + }) + .await + } + }); + + assert!(outcome.reached); + assert_eq!(outcome.successes, 4); + let mut successful_targets = outcome.successful_targets; + successful_targets.sort_unstable(); + assert_eq!(successful_targets, vec![2, 3, 4, 5]); + assert_eq!(outcome.failures.len(), 2); + assert_eq!(launched.get(), 6); + } + + #[test] + fn quorum_reports_exhausted_target_set() { + let outcome = futures::executor::block_on(async { + quorum_with_fallback(0_u8..7, 4, |target| { + futures_util::future::ready(Err::<(), _>(target)) + }) + .await + }); + + assert!(!outcome.reached); + assert_eq!(outcome.successes, 0); + assert!(outcome.successful_targets.is_empty()); + assert_eq!(outcome.failures.len(), 7); + } + + #[test] + fn quorum_reports_partial_success_targets_when_exhausted() { + let outcome = futures::executor::block_on(async { + quorum_with_fallback(0_u8..7, 4, |target| { + futures_util::future::ready(if target < 3 { Ok(()) } else { Err(target) }) + }) + .await + }); + + assert!(!outcome.reached); + assert_eq!(outcome.successes, 3); + let mut successful_targets = outcome.successful_targets; + successful_targets.sort_unstable(); + assert_eq!(successful_targets, vec![0, 1, 2]); + assert_eq!(outcome.failures.len(), 4); + } + + #[test] + fn store_byte_bound_and_retry_schedule_match_native_policy() { + assert_eq!(store_byte_bound(4 * 1024 * 1024), 16); + assert_eq!(store_byte_bound(0), usize::MAX); + assert_eq!(store_retry_delay(1), Duration::from_millis(500)); + assert_eq!(store_retry_delay(2), Duration::from_secs(1)); + assert_eq!(store_retry_delay(3), Duration::from_secs(2)); + } +} diff --git a/ant-core/src/data/client/adaptive.rs b/ant-core/src/data/client/adaptive.rs index cc4e5153..a586f33b 100644 --- a/ant-core/src/data/client/adaptive.rs +++ b/ant-core/src/data/client/adaptive.rs @@ -47,18 +47,24 @@ //! removed from saorsa-core; this controller only tunes client //! concurrency. -use futures::stream::{self, FuturesUnordered, StreamExt}; +use futures_util::stream::{self, FuturesUnordered, StreamExt}; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; +#[cfg(feature = "native")] use std::path::{Path, PathBuf}; +#[cfg(feature = "native")] use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; -use std::time::{Duration, Instant}; -use tracing::{debug, warn}; +use std::time::Duration; +use tracing::debug; +#[cfg(feature = "native")] +use tracing::warn; +use web_time::Instant; /// Process-monotonic counter for unique snapshot temp filenames. /// Combined with PID + nanosecond timestamp, makes collision /// effectively impossible across concurrent save_snapshot calls. +#[cfg(feature = "native")] static SAVE_COUNTER: AtomicU64 = AtomicU64::new(0); /// Fetch starts at the residential-saturation floor validated in @@ -1480,20 +1486,25 @@ where /// can evolve the controller without crashing on stale files — an /// unknown future schema version simply causes a silent fallback to /// cold defaults. +#[cfg(feature = "native")] #[derive(Debug, Clone, Serialize, Deserialize)] struct PersistedState { schema: u32, channels: ChannelStart, } +#[cfg(feature = "native")] const PERSIST_SCHEMA: u32 = 2; +#[cfg(feature = "native")] const PERSIST_SCHEMA_AIMD_FETCH: u32 = 1; +#[cfg(feature = "native")] const PERSIST_FILENAME: &str = "client_adaptive.json"; /// Default persistence path: `/client_adaptive.json`. Falls /// back to `None` if the platform data dir is not resolvable; in that /// case the controller still works, it just won't persist. #[must_use] +#[cfg(feature = "native")] pub fn default_persist_path() -> Option { crate::config::data_dir() .ok() @@ -1506,6 +1517,7 @@ pub fn default_persist_path() -> Option { /// effort — never propagate errors that would block the user's /// operation. #[must_use] +#[cfg(feature = "native")] pub fn load_snapshot(path: &Path) -> Option { let bytes = std::fs::read(path).ok()?; let state: PersistedState = match serde_json::from_slice(&bytes) { @@ -1541,6 +1553,7 @@ pub fn load_snapshot(path: &Path) -> Option { /// Save a snapshot to disk atomically (write to `.tmp`, then /// rename). Best effort — failures are logged at warn and discarded. +#[cfg(feature = "native")] pub fn save_snapshot(path: &Path, channels: ChannelStart) { let state = PersistedState { schema: PERSIST_SCHEMA, @@ -1602,6 +1615,7 @@ pub fn save_snapshot(path: &Path, channels: ChannelStart) { /// /// Used by `Client::drop` so a stalled filesystem cannot block /// process shutdown indefinitely. +#[cfg(feature = "native")] pub fn save_snapshot_with_timeout(path: PathBuf, channels: ChannelStart, timeout: Duration) { let handle = std::thread::spawn(move || { save_snapshot(&path, channels); diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index 9a62b4c4..b1b73d43 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -20,7 +20,7 @@ use ant_protocol::payment::{ use ant_protocol::transport::{MultiAddr, PeerId}; use ant_protocol::{compute_address, XorName, CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK}; use bytes::Bytes; -use futures::stream::{self, FuturesUnordered, StreamExt}; +use futures::stream::StreamExt; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use tokio::sync::mpsc; @@ -29,13 +29,6 @@ use tracing::{debug, info, warn}; /// Number of chunks per payment wave. const PAYMENT_WAVE_SIZE: usize = 64; -/// Soft ceiling on the combined body size of chunks stored concurrently in a -/// single wave. Caps store concurrency for large chunks so the send path's -/// per-peer body buffers can't pin multiple GB at once (see V2-461). At ~4 MB -/// chunks this permits ~16 concurrent stores; small chunks hit the chunk-count -/// / adaptive limits instead and are unaffected. -const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024; - /// Variable-size single-node payment plan for a chunk. /// /// The shared `ant-protocol::payment::SingleNodePayment` helper still models @@ -785,8 +778,8 @@ impl Client { // See PERF-RESULTS.md — measured ~30% slowdown when // cap > batch size on quoting workloads (live mainnet). let quote_concurrency = quote_limiter.current().min(chunk_count.max(1)); - let mut quote_stream = stream::iter(chunks_with_addr) - .map(|(content, address)| { + let mut quote_stream = crate::client_engine::bounded_unordered( + chunks_with_addr.into_iter().map(|(content, address)| { let limiter = quote_limiter.clone(); async move { let result = observe_op( @@ -797,8 +790,9 @@ impl Client { .await; (address, result) } - }) - .buffer_unordered(quote_concurrency); + }), + quote_concurrency, + ); let mut prepared = Vec::with_capacity(chunk_count); let mut already_stored = Vec::new(); @@ -848,9 +842,6 @@ impl Client { stored_before: usize, total_chunks: usize, ) -> WaveResult { - const MAX_RETRIES: u32 = 3; - const BASE_DELAY_MS: u64 = 500; - let mut stored = Vec::new(); let mut to_retry = paid_chunks; @@ -874,20 +865,18 @@ impl Client { let max_chunk_bytes = to_retry.iter().map(|c| c.content.len()).max().unwrap_or(0); // `checked_div` yields `None` only when `max_chunk_bytes == 0` (an // empty/zero-length wave), in which case there is no byte limit. - let byte_bound = STORE_INFLIGHT_BYTE_BUDGET - .checked_div(max_chunk_bytes) - .map_or(usize::MAX, |n| n.max(1)); + let byte_bound = crate::client_engine::store_byte_bound(max_chunk_bytes); let mut chunk_attempts_total: usize = 0; let mut store_durations_ms: Vec = Vec::new(); let mut retries_per_chunk: Vec = Vec::new(); - for attempt in 0..=MAX_RETRIES { + for attempt in 0..=crate::client_engine::STORE_MAX_RETRIES { if attempt > 0 { - let delay = Duration::from_millis(BASE_DELAY_MS * 2u64.pow(attempt - 1)); - tokio::time::sleep(delay).await; + tokio::time::sleep(crate::client_engine::store_retry_delay(attempt)).await; info!( - "Retry attempt {attempt}/{MAX_RETRIES} for {} chunks", + "Retry attempt {attempt}/{} for {} chunks", + crate::client_engine::STORE_MAX_RETRIES, to_retry.len() ); } @@ -923,21 +912,12 @@ impl Client { (chunk_clone, result) } }; - let mut chunk_iter = to_retry.into_iter(); - let mut in_flight = FuturesUnordered::new(); - let mut failed_this_round = Vec::new(); - loop { - let slots = store_limiter.current().min(byte_bound).max(1); - while in_flight.len() < slots { - match chunk_iter.next() { - Some(chunk) => in_flight.push(make_store(chunk)), - None => break, - } - } - let Some((chunk, result)) = in_flight.next().await else { - break; - }; + let results = crate::client_engine::rolling_unordered(to_retry, make_store, || { + store_limiter.current().min(byte_bound) + }) + .await; + for (chunk, result) in results { match result { Ok(name) => { let duration_ms = first_seen @@ -974,7 +954,7 @@ impl Client { return result; } - if attempt == MAX_RETRIES { + if attempt == crate::client_engine::STORE_MAX_RETRIES { let failed = failed_this_round .into_iter() .map(|(c, e)| (c.address, e)) diff --git a/ant-core/src/data/client/chunk.rs b/ant-core/src/data/client/chunk.rs index 4f9fd338..e6b7f0c5 100644 --- a/ant-core/src/data/client/chunk.rs +++ b/ant-core/src/data/client/chunk.rs @@ -16,7 +16,7 @@ use ant_protocol::{ ProofType, ProtocolError, XorName, CLOSE_GROUP_MAJORITY, }; use bytes::Bytes; -use futures::stream::{self, FuturesUnordered, StreamExt}; +use futures::stream::{self, StreamExt}; use std::collections::HashMap; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; @@ -409,21 +409,17 @@ impl Client { ) -> Result { let address = compute_address(&content); - let initial_count = peers.len().min(CLOSE_GROUP_MAJORITY); - let (initial_peers, fallback_peers) = peers.split_at(initial_count); - let mut fallback_iter = fallback_peers.iter(); - - let mut put_futures = FuturesUnordered::new(); - for (peer_id, addrs) in initial_peers { - put_futures.push(self.spawn_chunk_put( - content.clone(), - proof.clone(), - *peer_id, - addrs.clone(), - )); - } - - let mut success_count = 0usize; + let outcome = crate::client_engine::quorum_with_fallback( + peers.iter().cloned(), + CLOSE_GROUP_MAJORITY, + |(peer_id, addrs)| { + let content = content.clone(); + let proof = proof.clone(); + async move { self.spawn_chunk_put(content, proof, peer_id, addrs).await.1 } + }, + ) + .await; + let success_count = outcome.successes; let mut failures: Vec = Vec::new(); // Tally the *cause* of each failure. The store AIMD limiter must only be // pushed down by a transport shortfall (V2-468): a node that responds — @@ -439,55 +435,33 @@ impl Client { let mut dial = 0usize; let mut first_app_rejection: Option = None; - while let Some((peer_id, result)) = put_futures.next().await { - match result { - Ok(_) => { - success_count += 1; - if success_count >= CLOSE_GROUP_MAJORITY { - debug!( - "Chunk {} stored on {success_count} peers (majority reached)", - hex::encode(address) - ); - return Ok(address); - } - } - Err(e) => { - warn!("Failed to store chunk on {peer_id}: {e}"); - failures.push(format!("{peer_id}: {e}")); - match classify_put_failure(&e) { - PutRejection::Full => full += 1, - PutRejection::PriceFloor => price_floor += 1, - PutRejection::OtherRemote => other_remote += 1, - PutRejection::Timeout => timeout += 1, - PutRejection::Dial => dial += 1, - } - // An application-level decline is `RemotePut` (a structured - // node rejection) or `Error::Payment` (`PaymentRequired`): - // capture the first so an all-application shortfall surfaces - // as `ApplicationError`, not `InsufficientPeers` - // (`NetworkError`), and never suppresses the limiter. - if matches!(e, Error::RemotePut { .. } | Error::Payment(_)) - && first_app_rejection.is_none() - { - first_app_rejection = Some(e); - } - - // Advance to the next peer in the put-target set, reusing - // the same proof. - if let Some((fb_peer, fb_addrs)) = fallback_iter.next() { - debug!( - "Falling back to peer {fb_peer} for chunk {}", - hex::encode(address) - ); - put_futures.push(self.spawn_chunk_put( - content.clone(), - proof.clone(), - *fb_peer, - fb_addrs.clone(), - )); - } - } + for ((peer_id, _), error) in outcome.failures { + warn!("Failed to store chunk on {peer_id}: {error}"); + failures.push(format!("{peer_id}: {error}")); + match classify_put_failure(&error) { + PutRejection::Full => full += 1, + PutRejection::PriceFloor => price_floor += 1, + PutRejection::OtherRemote => other_remote += 1, + PutRejection::Timeout => timeout += 1, + PutRejection::Dial => dial += 1, } + // An application-level decline is `RemotePut` (a structured node + // rejection) or `Error::Payment` (`PaymentRequired`): capture the + // first so an all-application shortfall surfaces as + // `ApplicationError`, not `InsufficientPeers` (`NetworkError`). + if matches!(error, Error::RemotePut { .. } | Error::Payment(_)) + && first_app_rejection.is_none() + { + first_app_rejection = Some(error); + } + } + + if outcome.reached { + debug!( + "Chunk {} stored on {success_count} peers (majority reached)", + hex::encode(address) + ); + return Ok(address); } // Quorum not reached. A timeout-bearing shortfall is genuine local diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index ba5fbf9a..5cd38d60 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -310,8 +310,8 @@ impl Client { let quote_limiter = self.controller().quote.clone(); let quote_concurrency = quote_limiter.current().min(chunk_count.max(1)); let results: Vec<([u8; 32], Result>)> = - futures::stream::iter(chunks_with_addr) - .map(|(content, address)| { + crate::client_engine::bounded_unordered( + chunks_with_addr.into_iter().map(|(content, address)| { let limiter = quote_limiter.clone(); async move { let result = observe_op( @@ -322,10 +322,11 @@ impl Client { .await; (address, result) } - }) - .buffer_unordered(quote_concurrency) - .collect() - .await; + }), + quote_concurrency, + ) + .collect() + .await; let mut prepared_chunks = Vec::with_capacity(results.len()); let mut already_stored_addresses = Vec::new(); diff --git a/ant-core/src/data/client/file.rs b/ant-core/src/data/client/file.rs index 42bc60dd..ac0c127b 100644 --- a/ant-core/src/data/client/file.rs +++ b/ant-core/src/data/client/file.rs @@ -29,7 +29,7 @@ use ant_protocol::transport::{MultiAddr, PeerId}; use ant_protocol::{compute_address, XorName as ChunkAddress, DATA_TYPE_CHUNK}; use bytes::Bytes; use fs2::FileExt; -use futures::stream::{self, StreamExt}; +use futures::stream::StreamExt; use self_encryption::{ get_root_data_map_parallel, stream_decrypt_batch_size, stream_encrypt, streaming_decrypt_with_batch_size, DataMap, @@ -1687,8 +1687,8 @@ impl Client { // a progress bar through the slow quote phase. let quote_limiter = self.controller().quote.clone(); let quote_concurrency = quote_limiter.current().min(chunk_count.max(1)); - let mut quote_stream = stream::iter(chunks_with_addr) - .map(|(content, address)| { + let mut quote_stream = crate::client_engine::bounded_unordered( + chunks_with_addr.into_iter().map(|(content, address)| { let limiter = quote_limiter.clone(); async move { let result = observe_op( @@ -1699,8 +1699,9 @@ impl Client { .await; (address, result) } - }) - .buffer_unordered(quote_concurrency); + }), + quote_concurrency, + ); let mut prepared_chunks = Vec::with_capacity(chunk_count); let mut already_stored = Vec::new(); diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index 2973c7ea..2ded76b5 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -26,7 +26,7 @@ use ant_protocol::{ MerkleCandidateQuoteRequest, MerkleCandidateQuoteResponse, }; use bytes::Bytes; -use futures::stream::{self, FuturesUnordered, StreamExt}; +use futures::stream::{FuturesUnordered, StreamExt}; use rand::Rng; use std::collections::{HashMap, VecDeque}; use std::time::Duration; @@ -545,23 +545,27 @@ impl Client { let quote_limiter = self.controller().quote.clone(); let quote_concurrency = quote_limiter.current().min(total_chunks.max(1)); - let mut check_stream = stream::iter(chunks.into_iter().enumerate()) - .map(|(index, (address, data_size))| { - let limiter = quote_limiter.clone(); - async move { - let result = observe_op( - &limiter, - || async move { - self.chunk_already_stored_for_merkle(&address, data_type, data_size) - .await - }, - classify_error, - ) - .await; - (index, address, data_size, result) - } - }) - .buffer_unordered(quote_concurrency); + let mut check_stream = crate::client_engine::bounded_unordered( + chunks + .into_iter() + .enumerate() + .map(|(index, (address, data_size))| { + let limiter = quote_limiter.clone(); + async move { + let result = observe_op( + &limiter, + || async move { + self.chunk_already_stored_for_merkle(&address, data_type, data_size) + .await + }, + classify_error, + ) + .await; + (index, address, data_size, result) + } + }), + quote_concurrency, + ); let mut already_stored: Vec<(usize, [u8; 32])> = Vec::new(); let mut to_upload: Vec<(usize, [u8; 32], u64)> = Vec::new(); diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index 7a56edff..d627f81c 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -3,7 +3,9 @@ //! Provides high-level APIs for storing and retrieving data //! on the Autonomi decentralized network. -pub mod adaptive; +pub mod adaptive { + pub use crate::client_engine::adaptive::*; +} pub mod batch; pub mod cache; pub(crate) mod cached_merkle; diff --git a/ant-core/src/lib.rs b/ant-core/src/lib.rs index eab436cd..e028a561 100644 --- a/ant-core/src/lib.rs +++ b/ant-core/src/lib.rs @@ -1,6 +1,21 @@ +/// Cross-platform Autonomi client logic and browser WASM bindings. +pub mod browser; + +#[cfg(any(feature = "native", feature = "browser-wasm"))] +mod client_engine; + +#[cfg(feature = "native")] pub mod config; +#[cfg(feature = "native")] pub mod data; +#[cfg(feature = "native")] pub mod datamap_file; +#[cfg(feature = "native")] pub mod error; +#[cfg(feature = "native")] pub mod node; +#[cfg(feature = "native")] pub mod update; + +#[cfg(all(target_arch = "wasm32", not(feature = "browser-wasm")))] +compile_error!("WASM builds of ant-core require the `browser-wasm` feature"); diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md new file mode 100644 index 00000000..0560769b --- /dev/null +++ b/ant-core/wasm-tests/README.md @@ -0,0 +1,15 @@ +# Generated WASM smoke tests + +These tests exercise the JavaScript bindings emitted from `ant-core` without +providing a browser application or SDK. Build the package and run the tests +from the `ant-core` directory: + +```bash +wasm-pack build --target web --out-dir wasm-tests/pkg --release . \ + --no-default-features --features browser-wasm +node --import ./wasm-tests/setup-wasm.mjs \ + --test ./wasm-tests/*.test.mjs +``` + +The generated `pkg/` directory is ignored. Browser-native integration, +examples, and end-to-end browser tests belong to `ant-client-browser-sdk`. diff --git a/ant-core/wasm-tests/manifest.test.mjs b/ant-core/wasm-tests/manifest.test.mjs new file mode 100644 index 00000000..1ba749ab --- /dev/null +++ b/ant-core/wasm-tests/manifest.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseBrowserManifest } from "./pkg/ant_core.js"; + +test("browser manifest validates and normalizes endpoints and files", () => { + const manifest = parseBrowserManifest({ + version: 5, + network_id: "local-test", + created_at: "2026-08-03T00:00:00Z", + payment: paymentNetwork(), + endpoints: [ + { + multiaddr: webrtc_directMultiaddr("AA".repeat(32), 0xbb), + }, + ], + files: [ + { + name: "hello.txt", + address: "CC".repeat(32), + size: 12, + content_type: "text/plain", + blake3: "DD".repeat(32), + data_map_size: 128, + chunks: [ + { index: 2, dst_hash: "13".repeat(32), src_hash: "23".repeat(32), src_size: 4 }, + { index: 0, dst_hash: "11".repeat(32), src_hash: "21".repeat(32), src_size: 4 }, + { index: 1, dst_hash: "12".repeat(32), src_hash: "22".repeat(32), src_size: 4 }, + ], + replicas: 5, + }, + ], + }); + + assert.equal( + manifest.endpoints[0].multiaddr, + webrtc_directMultiaddr("aa".repeat(32), 0xbb), + ); + assert.equal(manifest.files[0].address, "cc".repeat(32)); + assert.equal(manifest.files[0].blake3, "dd".repeat(32)); + assert.deepEqual( + manifest.files[0].chunks.map((chunk) => chunk.index), + [0, 1, 2], + ); + assert.equal(manifest.files[0].replicas, 5); + assert.deepEqual(manifest.payment, paymentNetwork()); +}); + +test("browser manifest rejects missing endpoints and malformed multiaddresses", () => { + assert.throws( + () => + parseBrowserManifest({ + version: 5, + network_id: "test", + payment: paymentNetwork(), + endpoints: [], + }), + /no WebRtcDirect endpoints/, + ); + assert.throws( + () => + parseBrowserManifest({ + version: 5, + network_id: "test", + payment: paymentNetwork(), + endpoints: [ + { + multiaddr: + "/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/uAA/p2p/wrong", + }, + ], + }), + /multihash|hexadecimal|Expected 32 bytes/, + ); +}); + +test("browser manifest requires public payment contract configuration", () => { + const endpoint = { multiaddr: webrtc_directMultiaddr("aa".repeat(32), 0xbb) }; + assert.throws( + () => parseBrowserManifest({ version: 5, network_id: "test", endpoints: [endpoint] }), + /missing field.*payment|payment network/i, + ); + assert.throws( + () => + parseBrowserManifest({ + version: 5, + network_id: "test", + endpoints: [endpoint], + payment: { ...paymentNetwork(), rpc_url: "file:///tmp/anvil" }, + }), + /HTTP or HTTPS/, + ); +}); + +function paymentNetwork() { + return { + rpc_url: "http://127.0.0.1:8545/", + payment_token_address: `0x${"11".repeat(20)}`, + payment_vault_address: `0x${"22".repeat(20)}`, + }; +} + +function webrtc_directMultiaddr(peerId, certificateByte) { + const multihash = Uint8Array.from([ + 0x12, + 0x20, + ...Array(32).fill(certificateByte), + ]); + const certhash = `u${Buffer.from(multihash).toString("base64url")}`; + return `/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/${certhash}/p2p/${peerId}`; +} diff --git a/ant-core/wasm-tests/protocol.test.mjs b/ant-core/wasm-tests/protocol.test.mjs new file mode 100644 index 00000000..3f616ae8 --- /dev/null +++ b/ant-core/wasm-tests/protocol.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BrowserNetworkClient, + BrowserNodeClient, + parseResponseFrame, + parseWebRtcDirectMultiaddr, + paymentQuoteHash, + serverAnswerFromEndpoint, + webRtcDirectV2ServerCredential, +} from "./pkg/ant_core.js"; + +test("Rust/WASM parses stable certificate-pinned WebRTC Direct addresses", () => { + const peerId = "ab".repeat(32); + const multiaddr = webRtcDirectMultiaddr(peerId, 0x11); + const parsed = parseWebRtcDirectMultiaddr(multiaddr); + + assert.equal(parsed.hostProtocol, "ip4"); + assert.equal(parsed.host, "127.0.0.1"); + assert.equal(parsed.port, 24000); + assert.equal(parsed.peerId, peerId); + assert.deepEqual([...parsed.certificateHash], Array(32).fill(0x11)); + assert.throws( + () => + parseWebRtcDirectMultiaddr( + `/dns/node.example/udp/24000/webrtc-direct/certhash/${certificateMultihash(0x11)}/p2p/${peerId}`, + ), + /literal IP/, + ); + + const node = new BrowserNodeClient(multiaddr); + node.close(); + const network = new BrowserNetworkClient([{ multiaddr }]); + network.close(); +}); + +test("Rust/WASM validates response framing with a raw binary body", () => { + const header = new TextEncoder().encode( + JSON.stringify({ + version: 4, + request_id: 9, + status: "ok", + content_length: 3, + type: "chunk", + address: "11".repeat(32), + size: 3, + }), + ); + const frame = new Uint8Array(4 + header.length + 3); + new DataView(frame.buffer).setUint32(0, header.length, false); + frame.set(header, 4); + frame.set([1, 2, 3], 4 + header.length); + + const parsed = parseResponseFrame(frame); + assert.equal(parsed.header.request_id, 9); + assert.deepEqual([...parsed.content], [1, 2, 3]); +}); + +test("Rust/WASM synthesizes a pinned v2 answer without mutating local SDP", () => { + const endpoint = webRtcDirectMultiaddr("ab".repeat(32), 0x11); + const offerSdp = + "v=0\r\na=ice-ufrag:browserUfrag\r\na=ice-pwd:browserClientPassword1234\r\n"; + const credential = webRtcDirectV2ServerCredential(offerSdp); + const answer = serverAnswerFromEndpoint(endpoint, credential); + + assert.equal( + credential, + "saorsa+webrtc+v2/browserClientPassword1234", + ); + assert.equal(answer.type, "answer"); + assert.match(answer.sdp, /a=ice-lite/); + assert.match( + answer.sdp, + /m=application 24000 UDP\/DTLS\/SCTP webrtc-datachannel/, + ); + assert.match(answer.sdp, /a=fingerprint:sha-256 11:11:11:11/); + assert.match( + answer.sdp, + new RegExp(`a=ice-ufrag:${escapeRegex(credential)}`), + ); + assert.match(answer.sdp, new RegExp(`a=ice-pwd:${escapeRegex(credential)}`)); + assert.match(offerSdp, /a=ice-ufrag:browserUfrag/); + assert.match(offerSdp, /a=ice-pwd:browserClientPassword1234/); +}); + +test("Rust/WASM uses the native EVM PaymentQuote Keccak hash", () => { + assert.equal( + paymentQuoteHash(Uint8Array.of(0, 1), Uint8Array.of(2), Uint8Array.of(3)), + "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f", + ); +}); + +function certificateMultihash(byte) { + const multihash = Uint8Array.from([0x12, 0x20, ...Array(32).fill(byte)]); + return `u${Buffer.from(multihash).toString("base64url")}`; +} + +function webRtcDirectMultiaddr(peerId, certificateByte) { + return `/ip4/127.0.0.1/udp/24000/webrtc-direct/certhash/${certificateMultihash(certificateByte)}/p2p/${peerId}`; +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/ant-core/wasm-tests/setup-wasm.mjs b/ant-core/wasm-tests/setup-wasm.mjs new file mode 100644 index 00000000..ee494ea8 --- /dev/null +++ b/ant-core/wasm-tests/setup-wasm.mjs @@ -0,0 +1,5 @@ +import { readFile } from "node:fs/promises"; +import initAntCore from "./pkg/ant_core.js"; + +const wasm = await readFile(new URL("./pkg/ant_core_bg.wasm", import.meta.url)); +await initAntCore({ module_or_path: wasm }); diff --git a/ant-core/wasm-tests/wasm.test.mjs b/ant-core/wasm-tests/wasm.test.mjs new file mode 100644 index 00000000..b79113a8 --- /dev/null +++ b/ant-core/wasm-tests/wasm.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BrowserFileEncryptor, + BrowserIterativeLookup, + decodePublicDataMap, + decryptPublicFile, + encryptPublicFile, + verifyRecord, +} from "./pkg/ant_core.js"; + +const EXPECTED_CHUNK_ADDRESSES = [ + "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9", + "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a", + "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb", +]; + +function lookupNode(lastByte, stringEndpoint = false) { + return { + peer_id: `${"00".repeat(31)}${lastByte.toString(16).padStart(2, "0")}`, + native_addresses: [], + reliability: 1, + webrtc_direct: stringEndpoint ? `/test/${lastByte}` : { multiaddr: `/test/${lastByte}` }, + }; +} + +test("generated WASM drives Saorsa's complete shared iterative lookup", async () => { + const lookup = new BrowserIterativeLookup("00".repeat(32), 2, 2, 20); + lookup.addCandidates([lookupNode(3), lookupNode(1), lookupNode(2, true)]); + const batches = []; + const termination = await lookup.run(async ({ iteration, candidates }) => { + batches.push(candidates.map((node) => node.peer_id)); + if (iteration === 1) { + assert.equal(candidates[1].webrtc_direct, "/test/2"); + return [ + { + status: "succeeded", + responder: candidates[0].peer_id, + candidates: [lookupNode(0)], + }, + { status: "failed", responder: candidates[1].peer_id }, + ]; + } + return candidates.map((candidate) => ({ + status: "succeeded", + responder: candidate.peer_id, + candidates: [], + })); + }); + + assert.equal(termination, "Exhausted"); + assert.deepEqual(batches, [ + [lookupNode(1).peer_id, lookupNode(2).peer_id], + [lookupNode(0).peer_id, lookupNode(3).peer_id], + ]); + assert.deepEqual( + lookup.results().map((node) => node.peer_id), + [lookupNode(0).peer_id, lookupNode(1).peer_id], + ); + assert.deepEqual(lookup.queriedPeers(), [ + lookupNode(1).peer_id, + lookupNode(2).peer_id, + lookupNode(0).peer_id, + lookupNode(3).peer_id, + ]); +}); + +test("generated ant-core WASM matches the native self-encryption vector", () => { + const content = new TextEncoder().encode("browser whole-file fixture\n".repeat(160)); + const encrypted = encryptPublicFile(content); + + assert.equal( + encrypted.address, + "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b", + ); + assert.equal( + encrypted.blake3, + "e0e422267ac59c56bf032d6d830035d343369d20147dd5f6b63351a29b015f22", + ); + assert.deepEqual( + encrypted.chunks.map((chunk) => chunk.dst_hash), + EXPECTED_CHUNK_ADDRESSES, + ); + assert.equal(encrypted.records.length, 4); + + for (const record of encrypted.records) { + assert(record.content instanceof Uint8Array); + assert.equal(verifyRecord(record.address, record.content), record.address); + } + + const dataMap = encrypted.records.at(-1).content; + const chunks = encrypted.records.slice(0, -1).map((record) => record.content); + assert.deepEqual( + decodePublicDataMap(dataMap).map((chunk) => chunk.dst_hash), + EXPECTED_CHUNK_ADDRESSES, + ); + assert.deepEqual(decryptPublicFile(dataMap, chunks), content); + + const tampered = chunks.map((chunk) => chunk.slice()); + tampered[0][0] ^= 1; + assert.throws( + () => decryptPublicFile(dataMap, tampered), + /record may be missing or corrupt/, + ); +}); + +test("generated ant-core WASM supports nested DataMaps", () => { + const maxChunkSize = 4_190_208; + const content = new Uint8Array(3 * maxChunkSize + 1); + for (let index = 0; index < content.length; index += 1) { + content[index] = index; + } + + const encrypted = encryptPublicFile(content); + assert.equal(encrypted.chunks.length, 4); + assert.equal(decodePublicDataMap(encrypted.records.at(-1).content).length, 3); + assert(encrypted.records.length > encrypted.chunks.length + 1); + + const decrypted = decryptPublicFile( + encrypted.records.at(-1).content, + encrypted.records.slice(0, -1).map((record) => record.content), + ); + assert.equal(decrypted.length, content.length); + assert.equal(verifyRecord(encrypted.blake3, decrypted), encrypted.blake3); +}); + +test("streaming WASM encryption emits one externally stageable record at a time", () => { + const maxChunkSize = 4_190_208; + const content = new Uint8Array(3 * maxChunkSize + 1); + for (let index = 0; index < content.length; index += 1) { + content[index] = index; + } + + let reads = 0; + let largestRead = 0; + const encryptor = new BrowserFileEncryptor(content.length, (offset, length) => { + reads += 1; + largestRead = Math.max(largestRead, length); + return content.slice(offset, offset + length); + }); + assert.equal(reads, 0, "construction must not eagerly read the file"); + const records = [encryptor.nextRecord()]; + assert(reads > 0 && reads < Math.ceil(content.length / maxChunkSize)); + while (true) { + const record = encryptor.nextRecord(); + if (record === undefined) break; + records.push(record); + } + const staged = encryptor.finish("streamed.bin", "application/octet-stream"); + encryptor.free(); + + assert.equal(staged.size, content.length); + assert(largestRead <= maxChunkSize); + assert.equal(staged.name, "streamed.bin"); + assert.equal(staged.content_type, "application/octet-stream"); + assert.equal(staged.records.length, records.length); + assert.equal(staged.address, records.at(-1).address); + assert.equal(staged.data_map_size, records.at(-1).content.length); + assert.equal(verifyRecord(staged.blake3, content), staged.blake3); + assert.deepEqual( + staged.records, + records.map((record) => ({ + address: record.address, + size: record.content.length, + })), + ); + assert.equal(decodePublicDataMap(records.at(-1).content).length, 3); + assert.equal(staged.chunks.length, 4); + assert.deepEqual( + decryptPublicFile( + records.at(-1).content, + records.slice(0, -1).map((record) => record.content), + ), + content, + ); +}); diff --git a/docs/adr/ADR-0003-direct-browser-read-client.md b/docs/adr/ADR-0003-direct-browser-read-client.md new file mode 100644 index 00000000..6bee79a3 --- /dev/null +++ b/docs/adr/ADR-0003-direct-browser-read-client.md @@ -0,0 +1,190 @@ +# ADR-0003: Direct browser immutable-data client over WebRTC Direct + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Last amended:** 2026-09-03 +- **Decision owners:** +- **Reviewers:** +- **Supersedes:** none +- **Superseded by:** none +- **Related:** ant-node ADR-0009; Saorsa WebRTC Direct transport; ant-client-browser-sdk + +## Context + +Browser applications must perform closest-node lookup, immutable-data transfer, +quote verification, payment planning, and storage without routing file bytes +through an HTTP application gateway. A gateway would become an availability, +privacy, and bandwidth chokepoint. + +Browsers cannot use the native Saorsa QUIC transport, but they can connect to +Saorsa WebRTC Direct listeners through ICE-lite, certificate-pinned DTLS, SCTP, +and reliable ordered DataChannels. The reusable Autonomi behavior belongs in +`ant-core`; browser application integration belongs in the separate +`ant-client-browser-sdk` project. + +## Decision Drivers + +- File bytes must travel directly between storage nodes and the browser. +- The client must perform iterative XOR lookup instead of delegating it to a + gateway. +- Compatibility-sensitive networking, self-encryption, quote verification, + payment planning, and storage policy must be implemented in shared Rust. +- Existing native `ant-core` and `ant-cli` callers must remain source-compatible. +- Browser UI, wallet, file, worker, IndexedDB, and service-worker choices must + not become part of the low-level Rust crate. +- Generated WASM bindings must be tested independently of any particular SDK or + demo application. + +## Considered Options + +1. **Use the daemon REST API as a data gateway.** Rejected because lookup and + file bytes would no longer be direct. +2. **Compile the complete native client unchanged to WebAssembly.** Rejected for + now because its Tokio, filesystem, native QUIC, daemon, and native EVM + dependencies are not browser-compatible. +3. **Keep an application and JavaScript protocol implementation in this + repository.** Rejected because the newer browser SDK owns that layer and a + second application implementation would drift. +4. **Expose a Rust/WASM client core and keep browser integration in the SDK + (chosen).** + +## Decision + +`ant-core` retains its default `native` feature. Building for +`wasm32-unknown-unknown` with `--no-default-features --features browser-wasm` +selects a browser-safe dependency graph and exports the low-level browser API +through `wasm-bindgen`. Native callers and `ant-cli` continue to use the +existing native facade and QUIC transport. + +### Responsibilities of ant-core + +The Rust/WASM implementation owns: + +- canonical WebRTC Direct multiaddress parsing, including the literal IP, UDP + port, DTLS certificate fingerprint, and expected ANT peer ID; +- browser `RTCPeerConnection` and ordered `RTCDataChannel` management through + `web-sys`, including framing, fragmentation, backpressure, deadlines, and + bounded connection reuse; +- authenticated protocol-v4 session establishment using ephemeral ML-KEM-768, + ML-DSA-65 transcript authentication, peer-ID/public-key binding, independent + direction keys, and ordered ChaCha20-Poly1305 records from `ant-protocol`; +- iterative closest-node lookup through Saorsa's shared + transport-independent lookup runner; +- authenticated discovery of additional WebRTC Direct node addresses; +- public DataMap resolution, nested DataMap handling, chunk retrieval, + reconstruction, range decryption, and BLAKE3 verification; +- incremental self-encryption and content-addressed record generation using the + same `self_encryption` implementation and MessagePack DataMap representation + as the native client; +- signed storage-quote and commitment verification, price calculation, and + payment-plan construction using portable `ant-protocol` types; +- paid record upload with the shared adaptive scheduler, bounded in-flight + bytes, close-group quorum, fallback targets, and whole-record retries; and +- the bounded `BrowserFileReader` used by range-oriented consumers. + +`ant-core` may call narrow JavaScript callbacks to obtain file ranges, load or +discard externally staged encrypted records, report progress, and submit an +already verified payment plan. Those callbacks expose browser capabilities; +they do not reimplement Autonomi protocol behavior. + +### Responsibilities of ant-client-browser-sdk + +The separate SDK owns: + +- packaging and initializing the generated WASM module; +- the public TypeScript API and stable application-facing errors/events; +- `File` and `Blob` handling, Web Workers, and IndexedDB upload staging; +- wallet-independent payment-provider interfaces and Ethers or Wagmi/Viem + adapters; +- browser save flows; +- the same-origin service-worker bridge for media-element byte ranges; +- runnable examples and demo user interfaces; and +- TypeScript, bundler, and real-browser end-to-end tests. + +This repository does not ship a browser demo or duplicate those adapters. It +keeps only a small generated-WASM smoke-test harness under +`ant-core/wasm-tests/`. The harness validates that `wasm-pack` output loads in +JavaScript and preserves key native/WASM compatibility vectors. + +### Bootstrap and protocol compatibility + +A bootstrap endpoint is one canonical address of the form +`/ip4|ip6//udp//webrtc-direct/certhash//p2p/`. +The endpoint binds transport location, accepted DTLS certificate, and expected +ANT identity. DNS endpoints, port zero, malformed hashes, and ambiguous address +components are rejected. + +A local testnet manifest is optional bootstrap metadata, not a data gateway or +download authorization source. A client can start from one complete endpoint, +authenticate it, obtain public payment configuration, discover peers, and +resolve any public DataMap address from the network. Production bootstrap +distribution and certificate-rotation recovery remain operational concerns +described by ant-node ADR-0009. + +Browser protocol v4 requires a matching v4 node listener. Plaintext v3 and +encrypted v4 deliberately fail closed. This browser wire change does not alter +native QUIC, stored chunk/DataMap formats, quote commitments, payment proofs, or +public file addresses. + +## Consequences + +### Positive + +- Native and browser clients share self-encryption, DataMaps, lookup behavior, + quote verification, payment planning, transfer scheduling, and retry policy. +- Browser applications do not need to reproduce Autonomi networking or + cryptography in JavaScript. +- The `ant-client` PR remains focused on the reusable Rust library rather than + embedding a competing application and SDK. +- SDK UI and wallet integrations can evolve independently of the native CLI. +- Generated bindings are still exercised directly, catching failures that a + Rust-only WASM target check would miss. + +### Negative / Trade-offs + +- `ant-core` and `ant-client-browser-sdk` releases must remain compatible, and + the SDK must regenerate its bundled WASM when the low-level API changes. +- Browser-only failures involving workers, IndexedDB, wallets, service workers, + and media elements are detected in the SDK rather than this repository. +- Complete-file downloads remain memory-bound; range reading is the bounded + path for large media and range-oriented formats. +- WebRTC exposes transport metadata, lengths, and timing. The application-layer + post-quantum session protects RPC and chunk plaintext against later + compromise of only the classical DTLS key exchange; it does not make ICE, + DTLS, SCTP, or the browser WebRTC implementation post-quantum secure. +- Nodes must preserve their DTLS certificate because changing it invalidates + certificate-pinned endpoints. + +### Operational + +- WebRTC and service-worker consumers require a secure browser context; + localhost qualifies for development. +- Browser protocol v4 clients and node listeners must be deployed together. +- The SDK owns real-browser compatibility testing for current Chrome, Firefox, + and Safari. + +## Validation + +This repository validates: + +- Rust unit tests for browser manifests, framing, payment verification, + self-encryption, DataMap handling, lookup, and transfer policy; +- `cargo check` and `cargo clippy` for `wasm32-unknown-unknown` with only the + `browser-wasm` feature; +- a release `wasm-pack` build; +- JavaScript loading of the generated module; +- fixed native/WASM vectors for WebRTC addresses, response framing, EVM quote + hashing, self-encryption, nested DataMaps, streaming encryption, + reconstruction, and tamper rejection; and +- coordinated node integration tests for encrypted session establishment, + lookup, public download, signed quotes, paid upload, and record read-back. + +The browser SDK separately validates its TypeScript API, wallet adapters, +worker and IndexedDB staging, save behavior, service-worker range bridge, +examples, and live browser flows. + +## Notes for AI-assisted work + +AI tools may help draft this ADR, but **must not mark it Accepted without human +review**. Accepted ADRs are immutable: create a new superseding ADR rather than +editing an Accepted ADR.