diff --git a/CLAUDE.md b/CLAUDE.md index 074178ab..6bd598e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ ant-core/src/ │ ├── payment.rs # pay_for_storage, approve_token_spend │ ├── quote.rs # get_store_quotes from network peers │ ├── merkle.rs # Merkle batch payment (PaymentMode enum) +│ ├── pointer.rs # pointer_create/update/get/resolve (ADR-0016) │ └── cache.rs # In-memory LRU chunk cache └── node/ # Node management ├── mod.rs # add_nodes, remove_node, reset @@ -108,6 +109,7 @@ ant-cli/src/ ├── data/ │ ├── file.rs # ant file upload/download │ ├── chunk.rs # ant chunk put/get + │ ├── pointer.rs # ant pointer keygen/address/create/update/get/resolve │ └── wallet.rs # ant wallet address/balance └── node/ ├── mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3cb00df2..ff18fb66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,9 +825,11 @@ dependencies = [ "colored", "hex", "indicatif", + "rand 0.8.6", "rmp-serde", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-appender", @@ -909,8 +911,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a35999e85e8c5e854fbc36a0da1089812406407c19212532cd19b86991add3" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=7353c9ea2740de33495e78a3c36ff75b75116d4b#7353c9ea2740de33495e78a3c36ff75b75116d4b" dependencies = [ "ant-protocol", "bao", @@ -962,8 +963,7 @@ dependencies = [ [[package]] name = "ant-protocol" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7b4fbfd0a8b397d0dc1a0dc7308308242911147224c4e1a56130168e7ad48f" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=d11d1010d93958fee6e63e4aace8770f39a0678b#d11d1010d93958fee6e63e4aace8770f39a0678b" dependencies = [ "blake3", "bytes", @@ -3456,7 +3456,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.57.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec8f5ce5..1040b45b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,8 @@ members = ["ant-core", "ant-cli"] resolver = "2" +[patch.crates-io] +# The published ant-protocol 3.0.0 has no `Pointer` record or wire messages +# (ADR-0016). Pin the pointer revision until a release includes it. +# Versions are bumped at release; this immutable revision remains 3.0.0. +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "d11d1010d93958fee6e63e4aace8770f39a0678b" } diff --git a/README.md b/README.md index 335fcda2..ea1a7ade 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,15 @@ There is no browser-specific recovery probe or relaxed payment threshold. The requested responder count must be satisfied before quote witnessing and storage-majority checks proceed. +Pointers work from the browser too: `getPointer`, `resolvePointer`, +`createPointer` and `updatePointer` on `BrowserNetworkClient`, and +`pointerAddress(seed)` offline. They run the native client's own read quorum, +corroboration and write quorum, and pay each state through the same wallet +callback file uploads take. The owner key is passed as its 32-byte seed. Only +nodes that advertise `pointer_protocol` are asked. A write can hand the paid +record and proof to an `onPaid` callback; `storePaidPointer` stores them later +without paying again. + Browser protocol v5 and browser manifest v6 advertise only the payment chain ID and token/vault addresses. RPC providers belong to the application or wallet; the node's verification RPC URL is never sent to the browser. Paid uploads @@ -292,6 +301,56 @@ $ ant chunk get a1b2c3d4... -o output.bin |------|-------------| | `-o, --output ` | Write to file instead of stdout. | +### `ant pointer` — Pointer Operations + +A pointer is a mutable reference to a chunk or to another pointer, signed by an +owner key that can never change. Every state is paid for; reads are free. The +owner key is kept as a 32-byte seed in a file only its owner can read (`0600` +on Unix; on Windows it takes its directory's permissions, so keep it in your +user profile). Losing it means the pointer can never be updated again. + +#### `ant pointer keygen --output ` + +Create a new owner key and print the address of the pointer it controls. Never +overwrites an existing file. + +``` +$ ant pointer keygen --output owner.key +Owner key written to owner.key +9f3c... +``` + +#### `ant pointer address --key ` + +Print the address of the pointer a key controls. Needs no network. + +#### `ant pointer create --key [--kind chunk|pointer]` + +Create the pointer, pointing at `TARGET` (hex). Requires `SECRET_KEY` to pay. +Refused before paying if the pointer already exists. + +#### `ant pointer update --key [--kind chunk|pointer]` + +Point it at `TARGET`, one past the counter the network serves (or create it). +Requires `SECRET_KEY` to pay for the new state. + +#### `ant pointer get
` + +Print a pointer's counter, target and state. + +``` +$ ant pointer get 9f3c... +address: 9f3c... +counter: 1 +kind: chunk +target: a1b2... +state_id: 5e7d... +``` + +#### `ant pointer resolve
` + +Follow a chain of pointers to the target at its end. + ### `ant wallet` — Wallet Operations Inspect the EVM wallet derived from `SECRET_KEY`. diff --git a/ant-cli/Cargo.toml b/ant-cli/Cargo.toml index f17a090f..0181412d 100644 --- a/ant-cli/Cargo.toml +++ b/ant-cli/Cargo.toml @@ -19,6 +19,7 @@ clap = { version = "4", features = ["derive", "env"] } colored = "3.1.1" hex = "0.4" indicatif = "0.17" +rand = "0.8" rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -26,3 +27,6 @@ tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/ant-cli/src/cli.rs b/ant-cli/src/cli.rs index 77c4e146..6757e6cf 100644 --- a/ant-cli/src/cli.rs +++ b/ant-cli/src/cli.rs @@ -2,7 +2,7 @@ use ant_core::data::MultiAddr; use clap::{ArgAction, Parser, Subcommand}; use std::path::PathBuf; -use crate::commands::data::{ChunkAction, FileAction, WalletAction}; +use crate::commands::data::{ChunkAction, FileAction, PointerAction, WalletAction}; use crate::commands::node::NodeCommand; use crate::commands::update::UpdateArgs; @@ -110,6 +110,11 @@ pub enum Commands { #[command(subcommand)] action: ChunkAction, }, + /// Pointer operations (owner-signed mutable references, paid per update) + Pointer { + #[command(subcommand)] + action: PointerAction, + }, /// Update the ant binary to the latest version Update(UpdateArgs), } diff --git a/ant-cli/src/commands/data/mod.rs b/ant-cli/src/commands/data/mod.rs index 7c8a1c96..b626754b 100644 --- a/ant-cli/src/commands/data/mod.rs +++ b/ant-cli/src/commands/data/mod.rs @@ -1,7 +1,9 @@ pub mod chunk; pub mod file; +pub mod pointer; pub mod wallet; pub use chunk::ChunkAction; pub use file::FileAction; +pub use pointer::PointerAction; pub use wallet::WalletAction; diff --git a/ant-cli/src/commands/data/pointer.rs b/ant-cli/src/commands/data/pointer.rs new file mode 100644 index 00000000..51e38959 --- /dev/null +++ b/ant-cli/src/commands/data/pointer.rs @@ -0,0 +1,371 @@ +//! Pointer commands (ADR-0016 in `ant-node`). +//! +//! A pointer is owned by an ML-DSA-65 key for life, so the key is the one thing +//! these commands ask the user to keep. It is kept as the 32-byte FIPS 204 seed +//! the key pair derives from, hex-encoded in a file only its owner can read. + +use std::fs::OpenOptions; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use ant_core::data::{ + ml_dsa_65, pointer_address, Client, MlDsaPublicKey, MlDsaSecretKey, Pointer, PointerTarget, + PointerTargetKind, XorName, +}; +use clap::{Subcommand, ValueEnum}; +use rand::rngs::OsRng; +use rand::RngCore; +use serde_json::json; +use tracing::info; + +use super::chunk::parse_address; + +/// Length of an owner key seed in bytes. +const SEED_LEN: usize = 32; + +/// What a pointer's target is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum TargetKind { + /// A chunk, such as a file's data map. + Chunk, + /// Another pointer, which is how a pointer is handed over. + Pointer, +} + +impl From for PointerTargetKind { + fn from(kind: TargetKind) -> Self { + match kind { + TargetKind::Chunk => Self::Chunk, + TargetKind::Pointer => Self::Pointer, + } + } +} + +/// Pointer subcommands. +#[derive(Subcommand, Debug)] +pub enum PointerAction { + /// Create a new owner key. A pointer's owner can never change, so keep it. + Keygen { + /// File to write the key to. Refuses to overwrite an existing file. + #[arg(long, short)] + output: PathBuf, + }, + /// Print the address of the pointer a key owns. + Address { + /// Owner key file. + #[arg(long, short)] + key: PathBuf, + }, + /// Create the pointer a key owns, pointing at TARGET. Pays for it. + /// + /// Refused, before paying, if the pointer already exists. + Create { + /// Owner key file. + #[arg(long, short)] + key: PathBuf, + /// Hex-encoded target address (64 hex chars). + target: String, + /// What the target is. + #[arg(long, value_enum, default_value = "chunk")] + kind: TargetKind, + }, + /// Point the pointer a key owns at TARGET. Pays for the new state. + Update { + /// Owner key file. + #[arg(long, short)] + key: PathBuf, + /// Hex-encoded target address (64 hex chars). + target: String, + /// What the target is. + #[arg(long, value_enum, default_value = "chunk")] + kind: TargetKind, + }, + /// Read a pointer. + Get { + /// Hex-encoded pointer address (64 hex chars). + address: String, + }, + /// Follow a chain of pointers to the target at its end. + Resolve { + /// Hex-encoded pointer address (64 hex chars). + address: String, + }, +} + +impl PointerAction { + /// Whether this action pays, and so needs a wallet. + pub fn needs_wallet(&self) -> bool { + matches!(self, Self::Create { .. } | Self::Update { .. }) + } + + /// Whether this action talks to the network at all. + pub fn needs_network(&self) -> bool { + !matches!(self, Self::Keygen { .. } | Self::Address { .. }) + } + + /// Run a command that needs no network. + pub fn execute_offline(self, json: bool) -> anyhow::Result<()> { + match self { + Self::Keygen { output } => { + let mut seed = [0u8; SEED_LEN]; + OsRng + .try_fill_bytes(&mut seed) + .map_err(|e| anyhow::anyhow!("No system randomness for a new key: {e}"))?; + write_key(&output, &seed)?; + let (owner, _) = ml_dsa_65().generate_keypair_from_seed(&seed); + let address = hex::encode(pointer_address(&owner)); + let path = output.display(); + if json { + println!("{}", json!({ "key": path.to_string(), "address": address })); + } else { + println!("Owner key written to {path}"); + println!("{address}"); + } + } + Self::Address { key } => { + let (owner, _) = read_key(&key)?; + let address = hex::encode(pointer_address(&owner)); + if json { + println!("{}", json!({ "address": address })); + } else { + println!("{address}"); + } + } + other => anyhow::bail!("{other:?} needs the network"), + } + Ok(()) + } + + /// Run a command against the network. + pub async fn execute(self, client: &Client, json: bool) -> anyhow::Result<()> { + match self { + Self::Keygen { .. } | Self::Address { .. } => self.execute_offline(json)?, + Self::Create { key, target, kind } => { + let (owner, secret) = read_key(&key)?; + let target = PointerTarget::new(kind.into(), parse_address(&target)?); + info!("Creating pointer"); + let address = client + .pointer_create(&secret, &owner, target) + .await + .map_err(|e| anyhow::anyhow!("Pointer create failed: {e}"))?; + print_address(&address, json); + } + Self::Update { key, target, kind } => { + let (owner, secret) = read_key(&key)?; + let target = PointerTarget::new(kind.into(), parse_address(&target)?); + info!("Updating pointer"); + let address = client + .pointer_update(&secret, &owner, target) + .await + .map_err(|e| anyhow::anyhow!("Pointer update failed: {e}"))?; + print_address(&address, json); + } + Self::Get { address } => { + let at = parse_address(&address)?; + let record = client + .pointer_get(&at) + .await + .map_err(|e| anyhow::anyhow!("Pointer get failed: {e}"))? + .ok_or_else(|| anyhow::anyhow!("No pointer at {address}"))?; + print_record(&record, json); + } + Self::Resolve { address } => { + let at = parse_address(&address)?; + let target = client + .pointer_resolve(&at) + .await + .map_err(|e| anyhow::anyhow!("Pointer resolve failed: {e}"))?; + let (kind, target) = describe_target(&target); + if json { + println!("{}", json!({ "kind": kind, "target": target })); + } else { + println!("{target}"); + } + } + } + Ok(()) + } +} + +fn print_address(address: &XorName, json: bool) { + let address = hex::encode(address); + if json { + println!("{}", json!({ "address": address })); + } else { + println!("{address}"); + } +} + +fn print_record(record: &Pointer, json: bool) { + let address = hex::encode(record.address()); + let state = hex::encode(record.state_id()); + let counter = record.counter(); + let (kind, target) = describe_target(&record.target()); + if json { + println!( + "{}", + json!({ + "address": address, + "counter": counter, + "kind": kind, + "target": target, + "state_id": state, + }) + ); + } else { + println!("address: {address}"); + println!("counter: {counter}"); + println!("kind: {kind}"); + println!("target: {target}"); + println!("state_id: {state}"); + } +} + +/// A target's kind and hex address, for printing. A tag this build does not +/// know is printed as its number rather than hidden. +fn describe_target(target: &PointerTarget) -> (String, String) { + let kind = match target.kind() { + Some(PointerTargetKind::Chunk) => "chunk".to_string(), + Some(PointerTargetKind::Pointer) => "pointer".to_string(), + None => { + let tag = target.kind_tag(); + format!("unknown({tag})") + } + }; + (kind, hex::encode(target.address)) +} + +/// Write a new key file readable only by its owner, refusing to replace one: +/// a lost owner key is a pointer that can never be updated again. +/// +/// On Unix the file is created `0600`. Elsewhere it takes the permissions of +/// the directory it is written to, so keep it somewhere only you can read, +/// such as your user profile. +fn write_key(path: &Path, seed: &[u8; SEED_LEN]) -> anyhow::Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let display = path.display(); + let mut file = options + .open(path) + .map_err(|e| anyhow::anyhow!("Cannot create key file {display}: {e}"))?; + let encoded = hex::encode(seed); + writeln!(file, "{encoded}")?; + file.sync_all()?; + // The file's name is only durable once its directory is: without this a + // crash just after "written" could lose a key a pointer now depends on. + #[cfg(unix)] + { + let parent = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::File::open(parent)?.sync_all()?; + } + Ok(()) +} + +/// Read a key file back into the owner's key pair. +fn read_key(path: &Path) -> anyhow::Result<(MlDsaPublicKey, MlDsaSecretKey)> { + let display = path.display(); + let text = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Cannot read key file {display}: {e}"))?; + let bytes = hex::decode(text.trim()) + .map_err(|e| anyhow::anyhow!("Key file {display} is not hex: {e}"))?; + let seed: [u8; SEED_LEN] = bytes.try_into().map_err(|bytes: Vec| { + let len = bytes.len(); + anyhow::anyhow!("Key file {display} holds {len} bytes, expected {SEED_LEN}") + })?; + Ok(ml_dsa_65().generate_keypair_from_seed(&seed)) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Debug, Parser)] + struct TestPointerCli { + #[command(subcommand)] + action: PointerAction, + } + + #[test] + fn a_key_file_reads_back_to_the_same_owner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("owner.key"); + let seed = [7u8; SEED_LEN]; + write_key(&path, &seed).unwrap(); + + let (owner, _) = read_key(&path).unwrap(); + let (expected, _) = ml_dsa_65().generate_keypair_from_seed(&seed); + assert_eq!(pointer_address(&owner), pointer_address(&expected)); + } + + #[test] + fn keygen_never_overwrites_a_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("owner.key"); + write_key(&path, &[1u8; SEED_LEN]).unwrap(); + assert!(write_key(&path, &[2u8; SEED_LEN]).is_err()); + + let (owner, _) = read_key(&path).unwrap(); + let (first, _) = ml_dsa_65().generate_keypair_from_seed(&[1u8; SEED_LEN]); + assert_eq!(pointer_address(&owner), pointer_address(&first)); + } + + #[cfg(unix)] + #[test] + fn a_key_file_is_readable_only_by_its_owner() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("owner.key"); + write_key(&path, &[3u8; SEED_LEN]).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn a_key_file_of_the_wrong_length_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("short.key"); + std::fs::write(&path, "abcd").unwrap(); + assert!(read_key(&path).is_err()); + } + + #[test] + fn only_writes_need_a_wallet_and_only_key_commands_skip_the_network() { + let key = "owner.key"; + let target = "00".repeat(32); + let parse = |args: &[&str]| TestPointerCli::try_parse_from(args).unwrap().action; + + let create = parse(&["t", "create", "--key", key, &target]); + assert!(create.needs_wallet() && create.needs_network()); + let update = parse(&["t", "update", "--key", key, &target, "--kind", "pointer"]); + assert!(update.needs_wallet() && update.needs_network()); + let get = parse(&["t", "get", &target]); + assert!(!get.needs_wallet() && get.needs_network()); + let resolve = parse(&["t", "resolve", &target]); + assert!(!resolve.needs_wallet() && resolve.needs_network()); + let keygen = parse(&["t", "keygen", "--output", key]); + assert!(!keygen.needs_wallet() && !keygen.needs_network()); + let address = parse(&["t", "address", "--key", key]); + assert!(!address.needs_wallet() && !address.needs_network()); + } + + #[test] + fn a_target_kind_maps_to_its_wire_kind() { + assert_eq!( + PointerTargetKind::from(TargetKind::Chunk), + PointerTargetKind::Chunk + ); + assert_eq!( + PointerTargetKind::from(TargetKind::Pointer), + PointerTargetKind::Pointer + ); + } +} diff --git a/ant-cli/src/main.rs b/ant-cli/src/main.rs index 7f6d94d0..e1baf402 100644 --- a/ant-cli/src/main.rs +++ b/ant-cli/src/main.rs @@ -165,6 +165,17 @@ async fn run() -> anyhow::Result<()> { client.save_adaptive_snapshot(); result?; } + Commands::Pointer { action } => { + if !action.needs_network() { + return action.execute_offline(json); + } + let client = + build_data_client(&data_ctx, action.needs_wallet(), json, None, None).await?; + let result = action.execute(&client, json).await; + client.save_peer_cache().await; + client.save_adaptive_snapshot(); + result?; + } Commands::Update(args) => { args.execute(json).await?; } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 9fa5c1d7..ab39246c 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -79,7 +79,7 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"], o # `MultiAddr` mutually incompatible in `node/devnet.rs`. The draft node # revision below carries the same reviewed core/transport pins as this # workspace and uses the same released ant-protocol lineage. -ant-node = { version = "0.20.0", optional = true } +ant-node = { version = "0.20.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "7353c9ea2740de33495e78a3c36ff75b75116d4b", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -168,7 +168,7 @@ test-utils = ["dep:fips204"] # always compile even without the `devnet` feature. Pinned to the same # version as the runtime dep so there is a single ant-node / # saorsa-core version across the whole graph. -ant-node = { version = "0.20.0", features = ["test-utils"] } +ant-node = { version = "0.20.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "7353c9ea2740de33495e78a3c36ff75b75116d4b", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/browser-tests/harness.js b/ant-core/browser-tests/harness.js index 19796110..19e7a44b 100644 --- a/ant-core/browser-tests/harness.js +++ b/ant-core/browser-tests/harness.js @@ -1,4 +1,4 @@ -import init, { BrowserNetworkClient, encryptPublicFile } from "../wasm-tests/pkg/ant_core.js"; +import init, { BrowserNetworkClient, encryptPublicFile, pointerAddress } from "../wasm-tests/pkg/ant_core.js"; import { Contract, JsonRpcProvider } from "ethers"; await init(); globalThis.runIntegration = async ({ endpoint, payment, rpcUrl }) => { @@ -50,3 +50,49 @@ globalThis.runIntegration = async ({ endpoint, payment, rpcUrl }) => { matches: downloaded.content.length === content.length && downloaded.content.every((value, index) => value === content[index]), hello: hello.type, records: result.records, replicas: result.file.replicas }; }; +// Pointers (ADR-0016) through real nodes: each state paid on the local chain, +// stored on the close group, and read back by a client that wrote nothing. +globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => { + const provider = new JsonRpcProvider(rpcUrl); + const signer = await provider.getSigner(0); + const token = new Contract(payment.payment_token_address, ["function approve(address,uint256) returns (bool)"], signer); + const vault = new Contract(payment.payment_vault_address, + ["function payForQuotes((address rewardsAddress,uint256 amount,bytes32 quoteHash)[] payments)"], signer); + let payments = 0; + const pay = async (_, quotes, submitted) => { + payments++; + const totalAmount = quotes.reduce((sum, quote) => sum + BigInt(quote.amount), 0n); + await (await token.approve(payment.payment_vault_address, totalAmount)).wait(); + const transaction = await vault.payForQuotes(quotes.map(quote => ({ + rewardsAddress: quote.rewardsAddress, amount: quote.amount, quoteHash: `0x${quote.quoteHash.replace(/^0x/, "")}`, + }))); + const receipt = { transactionHash: transaction.hash, totalAmount: totalAmount.toString() }; + await submitted(receipt); + await transaction.wait(); + return receipt; + }; + const seed = crypto.getRandomValues(new Uint8Array(32)); + const inner = crypto.getRandomValues(new Uint8Array(32)); + const chunk = n => n.toString(16).padStart(2, "0").repeat(32); + // A node admits 16 requests a second from one browser session (ADR-0015) + // and closes the session past that. A paid pointer write asks each node about + // eight things, and this wallet pays instantly on a local chain, so writes are + // spaced as a real wallet's confirmation would space them. + const paced = async write => { await new Promise(resolve => setTimeout(resolve, 1_500)); return write(); }; + const writer = new BrowserNetworkClient([endpoint]); + const created = await writer.createPointer(seed, chunk(1), "chunk", payment, pay); + const updated = await paced(() => writer.updatePointer(seed, chunk(2), "chunk", payment, pay)); + const end = await paced(() => writer.createPointer(inner, chunk(3), "chunk", payment, pay)); + await paced(() => writer.updatePointer(seed, end.pointer.address, "pointer", payment, pay)); + writer.close(); writer.free(); + const reader = new BrowserNetworkClient([endpoint]); + const read = await reader.getPointer(created.pointer.address); + const resolved = await reader.resolvePointer(created.pointer.address); + const absent = await reader.getPointer(chunk(0x42)); + reader.close(); reader.free(); + provider.destroy(); + return { payments, address: created.pointer.address, expectedAddress: pointerAddress(seed), + createdCounter: created.pointer.counter, updatedCounter: updated.pointer.counter, + readCounter: read?.counter, readKind: read?.kind, readTarget: read?.target, endAddress: end.pointer.address, + resolved, absent }; +}; diff --git a/ant-core/browser-tests/node-revision b/ant-core/browser-tests/node-revision index bb712f52..5b821f16 100644 --- a/ant-core/browser-tests/node-revision +++ b/ant-core/browser-tests/node-revision @@ -1 +1 @@ -b0263b324c418a5732d1727d7a66df4c15946559 +c092f225877b3058eccbc47e2082fc44750ed2b5 diff --git a/ant-core/browser-tests/webrtc.spec.js b/ant-core/browser-tests/webrtc.spec.js index 8e579e85..0ec75d28 100644 --- a/ant-core/browser-tests/webrtc.spec.js +++ b/ant-core/browser-tests/webrtc.spec.js @@ -14,3 +14,21 @@ test("real Chromium authenticates, resumes one paid upload, and downloads by add expect(result.records).toBeGreaterThan(1); expect(result.replicas).toBeGreaterThanOrEqual(4); expect(errors).toEqual([]); }); +test("real Chromium creates, updates, reads and resolves pointers on real nodes", async ({ page, request }) => { + const manifest = await (await request.get("http://127.0.0.1:35000/api/browser-manifest.json")).json(); + const info = await (await request.get("http://127.0.0.1:35000/api/info")).json(); + const errors = []; page.on("pageerror", error => errors.push(error.message)); + await page.goto("/"); + await page.waitForFunction(() => typeof globalThis.runPointerIntegration === "function"); + const result = await page.evaluate(options => globalThis.runPointerIntegration(options), { + endpoint: manifest.endpoints[0], payment: manifest.payment, rpcUrl: info.evm.rpc_url, + }); + expect(result.payments).toBe(4); + expect(result.address).toBe(result.expectedAddress); + expect(result.createdCounter).toBe("0"); expect(result.updatedCounter).toBe("1"); + expect(result.readCounter).toBe("2"); expect(result.readKind).toBe("pointer"); + expect(result.readTarget).toBe(result.endAddress); + expect(result.resolved).toEqual({ kind: "chunk", kindTag: 0, target: "03".repeat(32) }); + expect(result.absent).toBeNull(); + expect(errors).toEqual([]); +}); diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index 5fa9ffe2..dcae2023 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -56,6 +56,9 @@ use web_sys::{ }; const REQUEST_TIMEOUT_MS: u32 = 10_000; +/// Advertised by a node whose `chunk_protocol` admits pointer reads and paid +/// pointer writes (ADR-0016). +const POINTER_PROTOCOL_CAPABILITY: &str = "pointer_protocol"; // A queued operation may wait behind a maximum-sized request and response // (180 seconds each), plus connection/authentication setup. Admission has its // own ceiling and never spends the caller's response allowance. @@ -88,6 +91,7 @@ mod bootstrap; mod failed_payment; mod inbox; mod multiplex; +mod pointer; mod shared; mod upload_adapter; use ant_protocol::transport::{client_routing, PeerId}; diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs new file mode 100644 index 00000000..30715d95 --- /dev/null +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -0,0 +1,375 @@ +// Copyright 2026 MaidSafe.net limited. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Pointers from the browser (ADR-0016 in `ant-node`): read, resolve, create +//! and update, over the same client the native API uses. +//! +//! Reads, the quorum and corroboration rules, the payment plan and the write +//! fan-out are the native client's own; only the wallet is the page's. The +//! owner key is handed in as the 32-byte FIPS 204 seed it derives from, so a +//! page keeps 32 bytes rather than a 4,032-byte secret key. The page is trusted +//! with that seed exactly as it is trusted with its wallet. + +use super::upload_adapter::{paid_transactions, wallet_quotes}; +use super::*; +use crate::data::client::batch::ChunkPaymentPlan; +use crate::data::{ + ml_dsa_65, pointer_address, MlDsaPublicKey, MlDsaSecretKey, Pointer, PointerTarget, + PointerTargetKind, +}; +use ant_protocol::evm::{QuoteHash, TxHash}; + +/// Length of an owner key seed. +const OWNER_SEED_LEN: usize = 32; + +/// A pointer as JavaScript sees it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserPointer { + address: String, + /// Decimal: a counter can exceed JavaScript's safe integers. + counter: String, + kind: String, + kind_tag: u8, + target: String, + state_id: String, +} + +impl From<&Pointer> for BrowserPointer { + fn from(record: &Pointer) -> Self { + let target = record.target(); + Self { + address: hex::encode(record.address()), + counter: record.counter().to_string(), + kind: kind_name(&target).to_string(), + kind_tag: target.kind_tag(), + target: hex::encode(target.address), + state_id: hex::encode(record.state_id()), + } + } +} + +/// Where a pointer chain ends. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserPointerTarget { + kind: String, + kind_tag: u8, + target: String, +} + +/// A stored pointer state and what it cost. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserPointerWrite { + pointer: BrowserPointer, + #[serde(skip_serializing_if = "Option::is_none")] + transaction_hash: Option, + storage_cost_atto: String, +} + +/// A target kind by name. A tag this build does not know is reported as +/// unknown, with its number in `kindTag`, rather than hidden: the record is +/// signed, so the bytes are authentic and the caller decides. +fn kind_name(target: &PointerTarget) -> &'static str { + match target.kind() { + Some(PointerTargetKind::Chunk) => "chunk", + Some(PointerTargetKind::Pointer) => "pointer", + None => "unknown", + } +} + +fn owner_keys(seed: &[u8]) -> Result<(MlDsaPublicKey, MlDsaSecretKey), String> { + let seed: [u8; OWNER_SEED_LEN] = seed + .try_into() + .map_err(|_| format!("owner seed must be {OWNER_SEED_LEN} bytes"))?; + Ok(ml_dsa_65().generate_keypair_from_seed(&seed)) +} + +fn parse_target(target: &str, kind: &str) -> Result { + let kind = match kind { + "chunk" => PointerTargetKind::Chunk, + "pointer" => PointerTargetKind::Pointer, + other => return Err(format!("unknown pointer target kind {other}")), + }; + Ok(PointerTarget::new( + kind, + parse_lookup_key(target, "pointer target")?, + )) +} + +fn to_js(value: &T) -> Result { + value + .serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)) + .map_err(|error| JsValue::from_str(&error.to_string())) +} + +/// The address of the pointer an owner seed controls, computed offline. +#[wasm_bindgen(js_name = pointerAddress)] +pub fn pointer_address_wasm(owner_seed: &[u8]) -> Result { + let (owner, _) = owner_keys(owner_seed).map_err(|error| JsValue::from_str(&error))?; + Ok(hex::encode(pointer_address(&owner))) +} + +#[wasm_bindgen(js_class = BrowserNetworkClient)] +impl BrowserNetworkClient { + /// Read a pointer, or `null` if the network holds none at `address`. + /// + /// The record is verified and must be named by at least two of the close + /// group, as for a native read. + #[wasm_bindgen(js_name = getPointer)] + pub async fn get_pointer(&self, address: &str) -> Result { + let at = parse_lookup_key(address, "pointer address").map_err(|e| JsValue::from_str(&e))?; + let record = self + .shared + .pointer_get(&at) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + match record { + Some(record) => to_js(&BrowserPointer::from(&record)), + None => Ok(JsValue::NULL), + } + } + + /// Follow a chain of pointers to the target at its end. + #[wasm_bindgen(js_name = resolvePointer)] + pub async fn resolve_pointer(&self, address: &str) -> Result { + let at = parse_lookup_key(address, "pointer address").map_err(|e| JsValue::from_str(&e))?; + let target = self + .shared + .pointer_resolve(&at) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + to_js(&BrowserPointerTarget { + kind: kind_name(&target).to_string(), + kind_tag: target.kind_tag(), + target: hex::encode(target.address), + }) + } + + /// Create the pointer `owner_seed` controls, pointing at `target`, and pay + /// for it through the same wallet callback file uploads take. + /// + /// `kind` is `"chunk"` or `"pointer"`. Refused before anything is paid if + /// the pointer already exists. + /// + /// `on_paid`, if given, is called with `{ record, proof }` once the state + /// is paid for and before it is stored. Keep both: if storing fails, or the + /// page is lost, [`Self::store_paid_pointer`] stores the same state without + /// paying again. + #[wasm_bindgen(js_name = createPointer)] + pub async fn create_pointer( + &self, + owner_seed: &[u8], + target: &str, + kind: &str, + payment_network: JsValue, + pay_for_quotes: js_sys::Function, + on_paid: Option, + ) -> Result { + let payment_network = parse_payment_network(payment_network)?; + let written = self + .write_pointer( + PointerWrite::Create, + owner_seed, + target, + kind, + &payment_network, + &pay_for_quotes, + on_paid.as_ref(), + ) + .await + .map_err(|error| JsValue::from_str(&error))?; + to_js(&written) + } + + /// Point the pointer `owner_seed` controls at `target`, one past the + /// counter the network serves (or create it), and pay for the new state. + /// `on_paid` is as for [`Self::create_pointer`]. + #[wasm_bindgen(js_name = updatePointer)] + pub async fn update_pointer( + &self, + owner_seed: &[u8], + target: &str, + kind: &str, + payment_network: JsValue, + pay_for_quotes: js_sys::Function, + on_paid: Option, + ) -> Result { + let payment_network = parse_payment_network(payment_network)?; + let written = self + .write_pointer( + PointerWrite::Update, + owner_seed, + target, + kind, + &payment_network, + &pay_for_quotes, + on_paid.as_ref(), + ) + .await + .map_err(|error| JsValue::from_str(&error))?; + to_js(&written) + } + + /// Store a pointer state already paid for, from the `record` and `proof` + /// an earlier write handed to its `on_paid` callback. Pays nothing. + #[wasm_bindgen(js_name = storePaidPointer)] + pub async fn store_paid_pointer( + &self, + record: &[u8], + proof: &[u8], + payment_network: JsValue, + ) -> Result { + let payment_network = parse_payment_network(payment_network)?; + let record = Pointer::from_bytes(record) + .map_err(|error| JsValue::from_str(&format!("invalid pointer record: {error}")))?; + self.paying_client(&payment_network) + .pointer_put_paid(&record, proof.to_vec()) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + to_js(&BrowserPointer::from(&record)) + } +} + +/// Which write a page asked for. +#[derive(Clone, Copy)] +enum PointerWrite { + /// A new pointer, refused if one exists. + Create, + /// One past what the network serves, or a creation. + Update, +} + +impl BrowserNetworkClient { + #[allow(clippy::too_many_arguments)] // Mirrors the JS arguments. + async fn write_pointer( + &self, + write: PointerWrite, + owner_seed: &[u8], + target: &str, + kind: &str, + payment_network: &BrowserPaymentNetwork, + pay_for_quotes: &js_sys::Function, + on_paid: Option<&js_sys::Function>, + ) -> Result { + let (owner, secret) = owner_keys(owner_seed)?; + let target = parse_target(target, kind)?; + let client = self.paying_client(payment_network); + let record = match write { + PointerWrite::Create => client.pointer_sign_create(&secret, &owner, target).await, + PointerWrite::Update => client.pointer_sign_update(&secret, &owner, target).await, + } + .map_err(|error| error.to_string())?; + self.store_pointer(&client, &record, payment_network, pay_for_quotes, on_paid) + .await + } + + /// A client whose quotes and paid writes go only to nodes on the page's + /// payment network, as an upload's do. + fn paying_client(&self, payment_network: &BrowserPaymentNetwork) -> crate::data::Client { + let mut network = SharedNetworkAdapter::new(Rc::clone(&self.inner)); + network.payment_network = Some(payment_network.clone()); + crate::data::Client::from_network( + crate::data::Network::from_browser(Rc::new(network)), + crate::data::ClientConfig::default(), + ) + .with_shared_quote_state(&self.shared) + } + + /// Quote the record's state, have the wallet pay it, then store it. + async fn store_pointer( + &self, + client: &crate::data::Client, + record: &Pointer, + payment_network: &BrowserPaymentNetwork, + pay_for_quotes: &js_sys::Function, + on_paid: Option<&js_sys::Function>, + ) -> Result { + let plan = client + .prepare_pointer_payment(record) + .await + .map_err(|error| error.to_string())?; + let (transactions, submission) = self + .pay_plan(&plan, payment_network, pay_for_quotes) + .await?; + let proof = plan + .proof(&transactions) + .map_err(|error| error.to_string())?; + if let Some(on_paid) = on_paid { + // The payment is spent whatever the page does with this, so a + // callback that fails does not stop the state being stored. + let _ = hand_over_paid(on_paid, record, &proof).await; + } + client + .pointer_put_paid(record, proof) + .await + .map_err(|error| error.to_string())?; + Ok(BrowserPointerWrite { + pointer: BrowserPointer::from(record), + transaction_hash: submission.transaction_hash, + storage_cost_atto: submission.total_amount, + }) + } + + /// Ask the page's wallet to pay one pointer state. + /// + /// The upload path journals each broadcast so that an interrupted payment + /// is recovered rather than repeated. A pointer write pays one quote and + /// keeps no journal of its own: once paid, the page is handed the record + /// and proof through `on_paid`, and from then on nothing is paid twice. A + /// page lost between the wallet's broadcast and its receipt pays again on + /// retry. + async fn pay_plan( + &self, + plan: &ChunkPaymentPlan, + payment_network: &BrowserPaymentNetwork, + wallet: &js_sys::Function, + ) -> Result<(HashMap, BrowserPaymentSubmission), String> { + let verified = wallet_quotes(std::slice::from_ref(plan)).map_err(|e| e.to_string())?; + let network = serde_wasm_bindgen::to_value(payment_network).map_err(|e| e.to_string())?; + let quotes = serde_wasm_bindgen::to_value(&verified).map_err(|e| e.to_string())?; + let on_submission = Closure:: Promise>::new(|_: JsValue| { + Promise::resolve(&JsValue::UNDEFINED) + }) + .into_js_value(); + if self.inner.pool.availability.closed.get() { + return Err("browser client is closed; payment was not submitted".to_string()); + } + let returned = wallet + .call4( + &JsValue::NULL, + &network, + "es, + &on_submission, + &JsValue::UNDEFINED, + ) + .map_err(js_error_message)?; + let value = JsFuture::from(Promise::resolve(&returned)) + .await + .map_err(js_error_message)?; + let submission: BrowserPaymentSubmission = + serde_wasm_bindgen::from_value(value).map_err(|e| e.to_string())?; + let transactions = paid_transactions(&verified, &submission).map_err(|e| e.to_string())?; + Ok((transactions, submission)) + } +} + +/// Hand a paid state to the page as `{ record, proof }`, waiting for it if +/// the callback returns a promise. +async fn hand_over_paid( + on_paid: &js_sys::Function, + record: &Pointer, + proof: &[u8], +) -> Result<(), JsValue> { + let paid = js_sys::Object::new(); + js_sys::Reflect::set( + &paid, + &JsValue::from_str("record"), + &Uint8Array::from(record.to_bytes().as_slice()), + )?; + js_sys::Reflect::set(&paid, &JsValue::from_str("proof"), &Uint8Array::from(proof))?; + let returned = on_paid.call1(&JsValue::NULL, &paid)?; + JsFuture::from(Promise::resolve(&returned)).await?; + Ok(()) +} diff --git a/ant-core/src/browser/wasm_transport/shared.rs b/ant-core/src/browser/wasm_transport/shared.rs index 8026906f..8ecd8acb 100644 --- a/ant-core/src/browser/wasm_transport/shared.rs +++ b/ant-core/src/browser/wasm_transport/shared.rs @@ -234,7 +234,9 @@ impl BrowserNetwork for SharedNetworkAdapter { let admission = TransferDeadline::new(RPC_ADMISSION_TIMEOUT); let client = if matches!( &request.body, - ChunkMessageBody::GetRequest(_) | ChunkMessageBody::PutRequest(_) + ChunkMessageBody::GetRequest(_) + | ChunkMessageBody::PutRequest(_) + | ChunkMessageBody::PointerPutRequest(_) ) { self.inner .pool @@ -257,6 +259,20 @@ impl BrowserNetwork for SharedNetworkAdapter { "node does not support shared ant-protocol RPC".into(), )); } + // A node that predates browser pointers refuses them, so it is + // not asked: its refusal would read as a failed peer. + if matches!( + request.body, + ChunkMessageBody::PointerGetRequest(_) | ChunkMessageBody::PointerPutRequest(_) + ) && !hello + .capabilities + .iter() + .any(|cap| cap == POINTER_PROTOCOL_CAPABILITY) + { + return Err(DataError::Network( + "node does not support browser pointers".into(), + )); + } let payment_network = self.payment_network.clone(); if matches!( request.body, @@ -265,6 +281,7 @@ impl BrowserNetwork for SharedNetworkAdapter { | ChunkMessageBody::MerkleCandidateQuoteRequest(_) | ChunkMessageBody::MerkleCandidateQuoteRequestV2(_) | ChunkMessageBody::PutRequest(_) + | ChunkMessageBody::PointerPutRequest(_) ) { if let Some(network) = payment_network { assert_upload_node(&hello, &network).map_err(DataError::Network)?; @@ -289,7 +306,10 @@ impl BrowserNetwork for SharedNetworkAdapter { } else { None }; - let exclusive = matches!(&request.body, ChunkMessageBody::PutRequest(_)); + let exclusive = matches!( + &request.body, + ChunkMessageBody::PutRequest(_) | ChunkMessageBody::PointerPutRequest(_) + ); #[cfg(feature = "test-utils")] trace.event("admitted", ""); let bytes = request diff --git a/ant-core/src/browser/wasm_transport/test_utils.rs b/ant-core/src/browser/wasm_transport/test_utils.rs index cc5ddaa3..d8d106c0 100644 --- a/ant-core/src/browser/wasm_transport/test_utils.rs +++ b/ant-core/src/browser/wasm_transport/test_utils.rs @@ -3,6 +3,8 @@ //! all traverse the same ant-core implementation used by browser callers. use super::*; +use ant_protocol::chunk::{PointerGetResponse, PointerPutResponse}; +use ant_protocol::pointer::Pointer; mod node_session; use base64::Engine; @@ -332,6 +334,12 @@ pub struct BrowserTestNode { last_put_quote_hash: String, closest_peers: Vec, put_error: Option<(String, String)>, + pointers_enabled: bool, +} + +/// Where the mock keeps a pointer, beside chunks in the same record map. +fn pointer_key(address: &[u8; 32]) -> String { + format!("pointer:{}", hex::encode(address)) } fn network() -> BrowserPaymentNetwork { @@ -379,6 +387,7 @@ impl BrowserTestNode { last_put_quote_hash: String::new(), closest_peers: Vec::new(), put_error: None, + pointers_enabled: true, } } pub fn endpoint(&self) -> String { @@ -421,6 +430,9 @@ impl BrowserTestNode { pub fn set_closest_peers(&mut self, peers: JsValue) { self.closest_peers = serde_wasm_bindgen::from_value(peers).unwrap(); } + pub fn set_pointers_enabled(&mut self, enabled: bool) { + self.pointers_enabled = enabled; + } pub fn set_put_error(&mut self, code: String, message: String) { self.put_error = (!code.is_empty()).then_some((code, message)); } @@ -497,6 +509,9 @@ impl BrowserTestNode { capabilities .push(ant_protocol::transport::ADDRESS_V2_CAPABILITY.into()); } + if self.pointers_enabled { + capabilities.push(POINTER_PROTOCOL_CAPABILITY.into()); + } capabilities }, } @@ -916,6 +931,66 @@ impl BrowserTestNode { } }) } + Body::PointerPutRequest(request) => { + assert!( + self.pointers_enabled, + "pointer PUT sent to a node without them" + ); + self.last_method = "put_pointer".into(); + let record = Pointer::from_bytes(&request.record).unwrap(); + let address = record.address(); + let state_id = record.state_id(); + self.last_put_address = hex::encode(address); + let (proof, _) = ant_protocol::payment::deserialize_proof( + request.payment_proof.as_deref().unwrap(), + ) + .unwrap(); + // Paid at the state, never the address. + assert!(proof + .peer_quotes + .iter() + .all(|(_, quote)| quote.content.0 == state_id)); + let mut quotes = proof + .peer_quotes + .iter() + .map(|(_, quote)| quote) + .collect::>(); + quotes.sort_by_key(|quote| quote.price); + self.last_put_quote_hash = hex::encode(quotes[quotes.len() / 2].hash()); + let key = pointer_key(&address); + let held = self + .records + .get(&key) + .and_then(|bytes| Pointer::from_bytes(bytes).ok()); + Body::PointerPutResponse(match held { + Some(held) if held.state_id() == state_id => { + PointerPutResponse::Unchanged { address, state_id } + } + Some(held) if !record.replaces(&held) => PointerPutResponse::Stale { + address, + state_id: held.state_id(), + }, + _ => { + self.records.insert(key, request.record.to_vec()); + PointerPutResponse::Success { address, state_id } + } + }) + } + Body::PointerGetRequest(request) => { + assert!( + self.pointers_enabled, + "pointer GET sent to a node without them" + ); + self.last_method = "get_pointer".into(); + Body::PointerGetResponse(match self.records.get(&pointer_key(&request.address)) { + Some(record) => PointerGetResponse::Success { + record: record.clone().into(), + }, + None => PointerGetResponse::NotFound { + address: request.address, + }, + }) + } other => panic!("unsupported mock request: {other:?}"), }; ChunkMessage { diff --git a/ant-core/src/browser/wasm_transport/upload_adapter.rs b/ant-core/src/browser/wasm_transport/upload_adapter.rs index 34d535d7..e71be705 100644 --- a/ant-core/src/browser/wasm_transport/upload_adapter.rs +++ b/ant-core/src/browser/wasm_transport/upload_adapter.rs @@ -25,6 +25,64 @@ pub(super) struct BrowserUploadAdapter<'a> { pub recovering: Cell, } +/// The quotes a wallet is asked to pay: each plan's one paid quote, verified +/// and in the envelope the JavaScript wallet adapter takes. +pub(super) fn wallet_quotes(plans: &[ChunkPaymentPlan]) -> DataResult> { + plans + .iter() + .map(|plan| { + let payable = plan + .payment + .quotes + .iter() + .find(|q| !q.amount.is_zero()) + .ok_or_else(|| Error::Payment("payment plan has no paid quote".into()))?; + let (_, quote) = plan + .peer_quotes + .iter() + .find(|(_, q)| q.hash() == payable.quote_hash) + .ok_or_else(|| Error::Payment("paid quote missing from plan".into()))?; + Ok(VerifiedStorageQuote { + quote: native_quote_artifact(quote, &plan.commitment_sidecars) + .map_err(Error::Payment)?, + quote_hash: hex::encode(payable.quote_hash), + rewards_address: format!("0x{}", hex::encode(payable.rewards_address)), + amount: payable.amount.to_string(), + }) + }) + .collect() +} + +/// Match a wallet's receipt to the quotes it was asked to pay. +pub(super) fn paid_transactions( + verified: &[VerifiedStorageQuote], + payment: &BrowserPaymentSubmission, +) -> DataResult> { + let mut transactions = HashMap::new(); + for quote in verified { + let hash = if payment.transaction_hashes.is_empty() { + payment.transaction_hash.as_ref() + } else { + payment + .transaction_hashes + .get("e.quote_hash) + .or_else(|| { + payment + .transaction_hashes + .get(&format!("0x{}", quote.quote_hash)) + }) + } + .ok_or_else(|| Error::Payment("wallet returned no transaction for a paid quote".into()))?; + transactions.insert( + QuoteHash::from( + parse_lookup_key("e.quote_hash, "quote hash").map_err(Error::Payment)?, + ), + TxHash::from(parse_lookup_key(hash, "transaction hash").map_err(Error::Payment)?), + ); + } + Ok(transactions) +} + impl BrowserUploadAdapter<'_> { async fn invoke(&self, callback: &js_sys::Function, input: JsValue) -> DataResult { let state = self @@ -196,58 +254,13 @@ impl UploadAdapter for BrowserUploadAdapter<'_> { Ok(()) } async fn pay(&self, plans: &[ChunkPaymentPlan]) -> DataResult { - let verified = plans - .iter() - .map(|plan| { - let payable = plan - .payment - .quotes - .iter() - .find(|q| !q.amount.is_zero()) - .ok_or_else(|| Error::Payment("payment plan has no paid quote".into()))?; - let (_, quote) = plan - .peer_quotes - .iter() - .find(|(_, q)| q.hash() == payable.quote_hash) - .ok_or_else(|| Error::Payment("paid quote missing from plan".into()))?; - Ok(VerifiedStorageQuote { - quote: native_quote_artifact(quote, &plan.commitment_sidecars) - .map_err(Error::Payment)?, - quote_hash: hex::encode(payable.quote_hash), - rewards_address: format!("0x{}", hex::encode(payable.rewards_address)), - amount: payable.amount.to_string(), - }) - }) - .collect::>>()?; + let verified = wallet_quotes(plans)?; let input = serde_wasm_bindgen::to_value(&verified).map_err(|e| Error::Payment(e.to_string()))?; let value = self.invoke(self.wallet, input).await?; let payment: BrowserPaymentSubmission = serde_wasm_bindgen::from_value(value).map_err(|e| Error::Payment(e.to_string()))?; - let mut transactions = HashMap::new(); - for quote in &verified { - let hash = if payment.transaction_hashes.is_empty() { - payment.transaction_hash.as_ref() - } else { - payment - .transaction_hashes - .get("e.quote_hash) - .or_else(|| { - payment - .transaction_hashes - .get(&format!("0x{}", quote.quote_hash)) - }) - } - .ok_or_else(|| { - Error::Payment("wallet returned no transaction for a paid quote".into()) - })?; - transactions.insert( - QuoteHash::from( - parse_lookup_key("e.quote_hash, "quote hash").map_err(Error::Payment)?, - ), - TxHash::from(parse_lookup_key(hash, "transaction hash").map_err(Error::Payment)?), - ); - } + let transactions = paid_transactions(&verified, &payment)?; *self.last_transaction.borrow_mut() = payment.transaction_hash; Ok(UploadPayment { transactions, diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index 4a87f0d6..3928eb1d 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -10,6 +10,7 @@ use crate::data::client::file::UploadEvent; use crate::data::client::payment::peer_id_to_encoded; #[cfg(test)] use crate::data::client::payment::SINGLE_NODE_PAYMENT_MULTIPLIER; +use crate::data::client::quote::StoreQuotePlan; use crate::data::client::Client; use crate::data::error::{Error, Result}; use ant_protocol::evm::{ @@ -142,7 +143,8 @@ pub struct PreparedChunk { /// Verified payment plan for a record staged outside the Rust heap. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ChunkPaymentPlan { - /// Expected BLAKE3 content address. + /// The address the quotes name: a chunk's BLAKE3 content address, or a + /// pointer's `state_id`. pub address: XorName, /// Expected record length. pub data_size: u64, @@ -157,6 +159,16 @@ pub struct ChunkPaymentPlan { } impl ChunkPaymentPlan { + /// The payment proof for this plan, from the transactions that paid it: + /// what a storer is sent alongside the record. + /// + /// # Errors + /// + /// Returns an error if a paid quote has no transaction in `transactions`. + pub fn proof(&self, transactions: &HashMap) -> Result> { + build_plan_proof(self, transactions) + } + /// Bind externally staged bytes to the address and size that were quoted. pub fn with_content(self, content: Bytes) -> Result { if content.len() as u64 != self.data_size || compute_address(&content) != self.address { @@ -316,7 +328,7 @@ fn build_paid_chunks( .collect() } -pub(super) fn build_plan_proof( +pub(crate) fn build_plan_proof( plan: &ChunkPaymentPlan, tx_hash_map: &HashMap, ) -> Result> { @@ -349,6 +361,49 @@ pub(super) fn build_plan_proof( Ok(proof_bytes) } +/// Turn a verified quote plan into a payment plan: the median payment and the +/// quotes and commitments its proof carries. +fn payment_plan_from_quotes( + address: XorName, + data_size: u64, + quote_plan: StoreQuotePlan, +) -> Result { + let quotes_with_peers = quote_plan.quotes; + + // Capture the ordered PUT target set for replication. This can be + // wider than the peers that supplied the paid quotes. + let quoted_peers = quote_plan.put_peers; + + // Build peer_quotes for ProofOfPayment + quotes for single-node payment. + // Use node-reported prices directly — no contract price fetch needed. + let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len()); + let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len()); + // ADR-0004: forward each bound quote's commitment sidecar (baseline + // quotes ship none); `get_store_quotes` already verified the binding. + let mut commitment_sidecars = Vec::new(); + + for (peer_id, _addrs, quote, _price, commitment) in quotes_with_peers { + let encoded = peer_id_to_encoded(&peer_id)?; + peer_quotes.push((encoded, quote.clone())); + quotes_for_payment.push(quote); + if let Some(sidecar) = commitment { + commitment_sidecars.push(sidecar); + } + } + + let payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment) + .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?; + + Ok(ChunkPaymentPlan { + data_size, + address, + quoted_peers, + payment, + peer_quotes, + commitment_sidecars, + }) +} + /// Finalize a batch payment using externally-provided transaction hashes. /// /// Takes prepared chunks and a map of `quote_hash -> tx_hash` from the @@ -413,40 +468,28 @@ impl Client { } Err(e) => return Err(e), }; - let quotes_with_peers = quote_plan.quotes; - - // Capture the ordered PUT target set for replication. This can be - // wider than the peers that supplied the paid quotes. - let quoted_peers = quote_plan.put_peers; - - // Build peer_quotes for ProofOfPayment + quotes for single-node payment. - // Use node-reported prices directly — no contract price fetch needed. - let mut peer_quotes = Vec::with_capacity(quotes_with_peers.len()); - let mut quotes_for_payment = Vec::with_capacity(quotes_with_peers.len()); - // ADR-0004: forward each bound quote's commitment sidecar (baseline - // quotes ship none); `get_store_quotes` already verified the binding. - let mut commitment_sidecars = Vec::new(); - - for (peer_id, _addrs, quote, _price, commitment) in quotes_with_peers { - let encoded = peer_id_to_encoded(&peer_id)?; - peer_quotes.push((encoded, quote.clone())); - quotes_for_payment.push(quote); - if let Some(sidecar) = commitment { - commitment_sidecars.push(sidecar); - } - } + payment_plan_from_quotes(address, data_size, quote_plan).map(Some) + } - let payment = SingleNodeQuotePayment::from_quotes(quotes_for_payment) - .map_err(|e| Error::Payment(format!("Failed to create payment: {e}")))?; - - Ok(Some(ChunkPaymentPlan { - data_size, - address, - quoted_peers, - payment, - peer_quotes, - commitment_sidecars, - })) + /// Quote `content` from the close group around `routing` and plan its + /// payment, without paying. + /// + /// The two are one address for a chunk and two for a pointer, which is + /// quoted at its state and collected from the group around its address. + pub(crate) async fn prepare_payment_plan_split( + &self, + routing: &XorName, + content: &XorName, + data_size: u64, + data_type: u32, + ) -> Result { + if let Some(refusal) = self.corroborated_settlement_refusal() { + return Err(Error::ClientUpdateRequired(refusal)); + } + let quote_plan = self + .get_store_quote_plan_split(routing, content, data_size, data_type) + .await?; + payment_plan_from_quotes(*content, data_size, quote_plan) } /// Pay for multiple chunks in a single EVM transaction. diff --git a/ant-core/src/data/client/chunk.rs b/ant-core/src/data/client/chunk.rs index ec3d7704..e5ab1749 100644 --- a/ant-core/src/data/client/chunk.rs +++ b/ant-core/src/data/client/chunk.rs @@ -297,8 +297,12 @@ fn put_shortfall_error( #[cfg(test)] use crate::client_engine::read::is_authoritative_not_found; -/// Store-response timeout for non-merkle chunk PUTs. -const STORE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); +/// Store-response timeout for a PUT carrying a single-node payment proof. +/// +/// The merkle timeout is far longer only because a merkle pool makes the +/// storer do an authoritative network closeness lookup first. A PUT that +/// carries a single-node proof does no such lookup, so it is held to this. +pub(crate) const STORE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); /// Extra waves allowed after the computed diagnostic peer-sweep deadline. const DIAGNOSTIC_TIMEOUT_PADDING_WAVES: usize = 1; diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index 16dd9bb7..818a6d82 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -21,6 +21,7 @@ pub mod merkle; #[cfg(feature = "native")] mod native_payment; pub mod payment; +pub mod pointer; pub mod quote; pub mod upload; pub mod upload_state; diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index 87399ecb..1cfdcc24 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -45,6 +45,28 @@ impl Client { data_size: u64, data_type: u32, ) -> Result<(Vec, Vec<(PeerId, Vec)>)> { + self.pay_for_storage_split(address, address, data_size, data_type) + .await + } + + /// As [`Self::pay_for_storage`], quoting at `content` while collecting from + /// the close group around `routing`. + /// + /// The two are one address for a chunk and two for a pointer, whose address + /// is stable for its life: paying against it would buy every future update + /// at once. + /// + /// # Errors + /// + /// As [`Self::pay_for_storage`]. + pub async fn pay_for_storage_split( + &self, + routing: &[u8; 32], + content: &[u8; 32], + data_size: u64, + data_type: u32, + ) -> Result<(Vec, Vec<(PeerId, Vec)>)> { + let address = content; // A refusal established by any earlier upload on this client stops // this one before it spends. The verdict is about this build, not // about one operation. @@ -60,7 +82,7 @@ impl Client { // 1. Collect at least one witnessed quote from the network let quote_plan = self - .get_store_quote_plan(address, data_size, data_type) + .get_store_quote_plan_split(routing, content, data_size, data_type) .await?; let quotes_with_peers = quote_plan.quotes; let median_quote_issuer = diff --git a/ant-core/src/data/client/pointer.rs b/ant-core/src/data/client/pointer.rs new file mode 100644 index 00000000..c8d86ebb --- /dev/null +++ b/ant-core/src/data/client/pointer.rs @@ -0,0 +1,1282 @@ +//! Client operations for pointers (ADR-0016 in `ant-node`). +//! +//! A pointer is a mutable, owner-signed reference stored at +//! `BLAKE3::derive_key("autonomi.pointer.address.v1", owner_key)`. Public-key +//! addressed and self-verifying: the key is inside the record, so anything that +//! parses is checkable on the spot, with no fetch. +//! +//! # What the client owns +//! +//! - **The counter.** Creation is counter 0 and [`Client::pointer_update`] +//! signs one past the counter the network serves, because the update has to +//! beat it. A node takes any paid state that beats what it holds, so a +//! counter may skip, a peer that missed updates takes the next one, and two +//! states at one counter converge on the smaller target until a later +//! counter replaces both. +//! - **Paying at the state, not the address.** The quote names `state_id`, while +//! the close group that issues it is the one around the address. Paying at the +//! address would buy every future update at once. +//! - **Following targets.** A node never interprets a pointer's target — it +//! stores 33 opaque bytes. Chain resolution, its depth limit and its cycle +//! detection are entirely here: see [`Client::pointer_resolve`]. + +use std::collections::HashSet; +use std::future::Future; + +use ant_protocol::chunk::{ + ChunkMessage, ChunkMessageBody, PointerGetRequest, PointerGetResponse, PointerPutRequest, + PointerPutResponse, +}; +use ant_protocol::pointer::{ + pointer_address, Pointer, PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, POINTER_WIRE_LEN, +}; +use ant_protocol::pqc::api::{MlDsaPublicKey, MlDsaSecretKey}; +use ant_protocol::transport::{MultiAddr, PeerId}; +use ant_protocol::XorName; +use bytes::Bytes; +use futures::stream::{FuturesUnordered, StreamExt}; +use tracing::warn; +use web_time::Duration; + +use crate::client_engine::{store_retry_delay, STORE_MAX_RETRIES}; +use crate::data::client::batch::ChunkPaymentPlan; +use crate::data::client::chunk::STORE_RESPONSE_TIMEOUT; +use crate::data::client::Client; +use crate::data::error::{Error, Result}; +use crate::data::network::send_and_await_chunk_response; +use crate::runtime::sleep; + +/// How many of `peers` must answer a read. +/// +/// Derived from the group actually returned, not from a fixed constant: the +/// close-group width is configurable, and a fixed four against a width of +/// twenty would let a write land on four peers and a *disjoint* four answer the +/// read. Quorums only intersect if both are taken from the same set. +fn read_quorum(peers: usize) -> usize { + (peers / 2) + 1 +} + +/// How many of `peers` a write must reach. +/// +/// One wider than a bare majority, which is what a chunk needs. A chunk is +/// self-proving — one copy is *the* chunk for that address — but a pointer read +/// has to decide which of several signed states is current, and a state that +/// only one peer reports is a state one peer decided. So a read requires two +/// peers to report what it returns ([`corroboration`]), and the write is one +/// wider so that two of them always answer: with `|W| + |R| >= K + 2` the two +/// sets overlap in at least two peers. +fn write_quorum(peers: usize) -> usize { + (read_quorum(peers) + 1).min(peers) +} + +/// How many peers must report a state before a read will return it. +/// +/// Two, so that no single peer decides what a pointer says. A peer that is in +/// the close group and willing to serve what the owner never paid to store +/// could otherwise answer first with an owner-signed state no honest node +/// holds, and every reader would take it: the record verifies, it belongs at +/// the address, and it wins the merge. What it cannot do is make a second peer +/// agree. +/// +/// A group of one can only ever be corroborated by itself. +fn corroboration(peers: usize) -> usize { + peers.min(2) +} + +/// The outcome of asking a close group. +struct Answered { + /// How many peers gave a usable answer. + count: usize, + /// The last failure that was not a refusal, kept to explain a total + /// failure. + last_error: Option, + /// The first failure that was a refusal rather than a missing answer: a + /// peer that answered and said no. Asking again would get the same. + refusal: Option, +} + +impl Answered { + /// What to report when too few answered: a refusal if any peer gave one, + /// since it says why, else the last failure. + fn failure(self) -> Option { + self.refusal.or(self.last_error) + } +} + +/// Ask a whole close group at once and stop as soon as there is an answer. +/// +/// The shape a pointer write and a pointer read share: every peer is asked +/// concurrently and each answer is handed to `keep`, which says whether what it +/// has is settled. It ends at the first answer that is both settled and the +/// `wanted`-th, or when the group is exhausted. Stopping early is what keeps one +/// unreachable peer from holding an operation open for its whole timeout after +/// the answer is already decided; the requests still in flight are dropped, +/// which cancels them. +async fn ask_the_group( + mut in_flight: FuturesUnordered>>, + wanted: usize, + mut keep: impl FnMut(T) -> bool, +) -> Answered { + let mut answered = Answered { + count: 0, + last_error: None, + refusal: None, + }; + while let Some(result) = in_flight.next().await { + match result { + Ok(answer) => { + answered.count += 1; + let settled = keep(answer); + if settled && answered.count >= wanted { + break; + } + } + Err(e) if answered.refusal.is_none() && !worth_retrying(&e) => { + answered.refusal = Some(e); + } + Err(e) => answered.last_error = Some(e), + } + } + answered +} + +/// What the peers answering a read have said. +/// +/// A tally of every state offered, not a running winner. Keeping only the best +/// state and its count would let one peer erase the agreement behind another: +/// a single peer naming a higher state — which it cannot make anyone else +/// confirm — would discard the count behind the state the rest of the group +/// holds, and a healthy pointer would read back as uncorroborated. That is not +/// only an attack; it is what an ordinary read during an update looks like. +/// +/// At most one entry per peer, so this is never longer than the close group. +#[derive(Default)] +struct Replies { + /// Each distinct state offered, and how many peers offered it. + seen: Vec<(Pointer, usize)>, +} + +impl Replies { + /// Record one peer's answer. `None` is an answer that names no state. + fn add(&mut self, reply: Option) { + let Some(reply) = reply else { return }; + for (held, count) in &mut self.seen { + if held.state_id() == reply.state_id() { + *count += 1; + return; + } + } + self.seen.push((reply, 1)); + } + + /// The best state that at least `needed` peers named. + /// + /// Best by the merge rule, among the corroborated only: a stale state the + /// group agrees on beats a newer one only one peer has heard of, because + /// the second is one peer's word and the first is the network's. + fn corroborated(&self, needed: usize) -> Option<&Pointer> { + self.seen + .iter() + .filter(|(_, count)| *count >= needed) + .map(|(record, _)| record) + .reduce(|best, next| if next.replaces(best) { next } else { best }) + } + + /// Whether any peer named a state at all. + fn any(&self) -> bool { + !self.seen.is_empty() + } +} + +/// Whether a pointer write that failed this way may succeed if tried again +/// with the same proof: a shortfall or an unreachable group, not a refusal. +fn worth_retrying(error: &Error) -> bool { + matches!( + error, + Error::CloseGroupShortfall(_) + | Error::Network(_) + | Error::Timeout(_) + | Error::InsufficientPeers(_) + ) +} + +/// What one peer's reply means for a write. +/// +/// `None` means the message was not a reply to this request at all, and the +/// caller keeps waiting. +/// +/// A storer that acknowledges some *other* record is claiming to hold something +/// this client never sent. Taking that at face value would let one peer end the +/// write — after payment — while storing nothing, so an acknowledgement only +/// counts when it names the record that was sent. +fn read_put_reply( + body: ChunkMessageBody, + expected_address: XorName, + expected_state: XorName, +) -> Option> { + let ChunkMessageBody::PointerPutResponse(response) = body else { + return None; + }; + Some(match response { + // `Unchanged` is success for a retry: the state the client signed is + // exactly what the node holds. + PointerPutResponse::Success { address, state_id } + | PointerPutResponse::Unchanged { address, state_id } => { + if address == expected_address && state_id == expected_state { + Ok(()) + } else { + Err(Error::InvalidData(format!( + "peer acknowledged a pointer this client did not send: address {} state {}", + hex::encode(address), + hex::encode(state_id) + ))) + } + } + // Every reply names the address it is about, and every one of them is + // checked against the address that was sent. A refusal is not exempt: + // one about some other pointer says nothing about this write. + PointerPutResponse::Stale { address, state_id } => { + Err(Error::InvalidData(if address == expected_address { + format!( + "the pointer moved while this update was in flight; the network \ + now holds state {}", + hex::encode(state_id) + ) + } else { + format!( + "peer refused a pointer this client did not send: address {}", + hex::encode(address) + ) + })) + } + PointerPutResponse::PaymentRequired { message } => Err(Error::Payment(message)), + PointerPutResponse::Error(e) => Err(Error::Protocol(format!("pointer PUT refused: {e}"))), + }) +} + +/// What one peer's reply means for a read. +/// +/// Verify before trusting: the signature must check out and the record must +/// belong at the address that was asked for, or a storer could answer with +/// someone else's pointer, or with bytes nobody signed. +fn read_get_reply(body: ChunkMessageBody, address: XorName) -> Option>> { + let ChunkMessageBody::PointerGetResponse(response) = body else { + return None; + }; + Some(match response { + PointerGetResponse::Success { record } => match Pointer::from_bytes(&record) { + Ok(record) if record.address() == address => Ok(Some(record)), + Ok(_) => Err(Error::InvalidData( + "peer answered with a pointer for a different address".to_string(), + )), + Err(e) => Err(Error::InvalidData(format!("invalid pointer: {e}"))), + }, + // "I do not have it" still has to be about the address that was asked + // for, exactly as an acknowledgement does: an answer naming something + // else is not an answer to this request. + PointerGetResponse::NotFound { address: absent } => { + if absent == address { + Ok(None) + } else { + Err(Error::InvalidData(format!( + "peer reported a different address absent: {}", + hex::encode(absent) + ))) + } + } + PointerGetResponse::Error(e) => Err(Error::Protocol(format!("pointer GET refused: {e}"))), + }) +} + +/// How many pointer hops [`Client::pointer_resolve`] will follow. +/// +/// A pointer may target another pointer — that is how handover works — so a +/// chain can be arbitrarily long, and a malicious or careless owner can make it +/// a cycle. The node cannot stop either: it never reads the target. So the +/// client bounds it. +pub const MAX_POINTER_RESOLVE_DEPTH: usize = 16; + +impl Client { + /// Create a pointer at counter 0, owned by `owner`. + /// + /// # Errors + /// + /// Returns an error if signing fails, payment fails, or no storer accepts + /// the record. + pub async fn pointer_create( + &self, + secret_key: &MlDsaSecretKey, + owner: &MlDsaPublicKey, + target: PointerTarget, + ) -> Result { + let record = self.pointer_sign_create(secret_key, owner, target).await?; + self.pointer_put(&record).await + } + + /// Sign the record that creates `owner`'s pointer, after checking the + /// network holds none. + /// + /// Checked before anything is paid: a second creation would pay again for + /// a state the network either already holds, and answers as unchanged, or + /// has moved past, and refuses as stale. Either way the payment buys + /// nothing. + /// + /// # Errors + /// + /// Returns an error if the pointer already exists, if the network cannot + /// say whether it does, or if signing fails. + pub async fn pointer_sign_create( + &self, + secret_key: &MlDsaSecretKey, + owner: &MlDsaPublicKey, + target: PointerTarget, + ) -> Result { + let address = pointer_address(owner); + if let Some(existing) = self.pointer_get(&address).await? { + return Err(Error::InvalidData(format!( + "pointer {} already exists at counter {}; update it instead", + hex::encode(address), + existing.counter() + ))); + } + Pointer::create(secret_key, owner, target) + .map_err(|e| Error::InvalidData(format!("cannot sign pointer: {e}"))) + } + + /// Update the pointer owned by `owner` to `target`, at `counter + 1`. + /// + /// Reads the current record first, because the update has to beat what the + /// network serves: it is signed one past that counter. A pointer that does + /// not exist yet is created at 0. + /// + /// # Errors + /// + /// Returns an error if the current record cannot be read, the counter is + /// exhausted, signing fails, payment fails, or no storer accepts it. + pub async fn pointer_update( + &self, + secret_key: &MlDsaSecretKey, + owner: &MlDsaPublicKey, + target: PointerTarget, + ) -> Result { + let record = self.pointer_sign_update(secret_key, owner, target).await?; + self.pointer_put(&record).await + } + + /// Sign the record that updates `owner`'s pointer to `target`, without + /// storing it: one past the counter the network serves, or a creation at 0 + /// if it serves none. + /// + /// # Errors + /// + /// Returns an error if the current record cannot be read, the counter is + /// exhausted, or signing fails. + pub async fn pointer_sign_update( + &self, + secret_key: &MlDsaSecretKey, + owner: &MlDsaPublicKey, + target: PointerTarget, + ) -> Result { + let address = pointer_address(owner); + match self.pointer_get(&address).await? { + Some(current) => current + .update(secret_key, target) + .map_err(|e| Error::InvalidData(format!("cannot sign pointer update: {e}"))), + None => Pointer::create(secret_key, owner, target) + .map_err(|e| Error::InvalidData(format!("cannot sign pointer: {e}"))), + } + } + + /// Pay for and store an already-signed pointer. + /// + /// Each call settles a payment for the state it is given. A state the + /// network already holds is answered as stored, but the payment for it has + /// been made either way — so a retry after a timeout costs a second quote. + /// Read first if that matters; the node cannot tell a retry from a fresh + /// submission before it has been paid to look. + /// + /// # Errors + /// + /// Returns an error if no peer is responsible for the address, if payment + /// fails, or if no storer accepts the record. + pub async fn pointer_put(&self, record: &Pointer) -> Result { + let address = record.address(); + let state_id = record.state_id(); + + // Refuse before paying if nobody is responsible for this address. + // There is no sense buying storage with nowhere to put it, and a + // quorum of nothing would otherwise be satisfied by nothing — a paid + // write reported as stored on zero peers. + self.pointer_group(&address).await?; + + // The quote names the state; the peers that may issue it are the close + // group around the address. One payment, one state. + let (proof, _) = self + .pay_for_storage_split( + &address, + &state_id, + POINTER_WIRE_LEN as u64, + DATA_TYPE_POINTER, + ) + .await?; + self.pointer_put_paid(record, proof).await + } + + /// Quote a signed pointer's state for a payer outside this client, such as + /// a browser wallet or another external signer, without paying. + /// + /// Pay the plan's median quote, build the proof with + /// [`ChunkPaymentPlan::proof`], then store with [`Self::pointer_put_paid`]. + /// + /// # Errors + /// + /// Returns an error if no peer is responsible for the address, or if too + /// few acceptable quotes come back. + pub async fn prepare_pointer_payment(&self, record: &Pointer) -> Result { + let address = record.address(); + // As for a wallet payment: nothing is quoted with nowhere to store it. + self.pointer_group(&address).await?; + self.prepare_payment_plan_split( + &address, + &record.state_id(), + POINTER_WIRE_LEN as u64, + DATA_TYPE_POINTER, + ) + .await + } + + /// Store a signed pointer with a proof already paid for its state. + /// + /// The second half of [`Self::pointer_put`], for a proof paid outside this + /// client. It is also how to retry a write that fell short without paying + /// again: the proof stays valid for its state, and a node that already + /// holds that state answers as stored. + /// + /// # Errors + /// + /// Returns an error if no peer is responsible for the address or if too few + /// of the close group accept the record. + pub async fn pointer_put_paid(&self, record: &Pointer, proof: Vec) -> Result { + // A write that fell short is retried with the same proof, as a chunk + // store is: peers that took it the first time answer `Unchanged`, which + // counts, so each round only has to reach the ones that did not. A + // refusal that says something definite — a newer state won, the + // payment was refused, a peer acknowledged something else — is final. + let mut attempt = 0; + loop { + match self.pointer_put_once(record, &proof).await { + Ok(address) => return Ok(address), + Err(e) if attempt < STORE_MAX_RETRIES && worth_retrying(&e) => { + attempt += 1; + warn!( + "pointer {} write fell short ({e}); retry {attempt}/{STORE_MAX_RETRIES}", + hex::encode(record.address()) + ); + sleep(store_retry_delay(attempt)).await; + } + Err(e) => return Err(e), + } + } + } + + /// One round of [`Self::pointer_put_paid`]. + async fn pointer_put_once(&self, record: &Pointer, proof: &[u8]) -> Result { + let address = record.address(); + let state_id = record.state_id(); + + // Ask again rather than keep the set from before the payment. Settling + // on chain takes time, and the peers responsible for an address can + // change while it does; writing to the old set would then be refused by + // peers that are no longer responsible while the ones that are were + // never asked. The lookup above answered whether there was anywhere to + // store this — this one answers where. + let targets = self.pointer_group(&address).await?; + let wanted = write_quorum(targets.len()); + + let request = + PointerPutRequest::with_payment(Bytes::from(record.to_bytes()), proof.to_vec()); + let in_flight = FuturesUnordered::new(); + for (peer_id, addrs) in &targets { + let request = request.clone(); + in_flight.push(self.send_pointer_put( + request, + *peer_id, + addrs.clone(), + address, + state_id, + )); + } + + let answered = ask_the_group(in_flight, wanted, |()| true).await; + if answered.count >= wanted { + return Ok(address); + } + // A peer that refused — a newer state won, the payment was refused, an + // acknowledgement named another record — is the answer, and is not + // retried. Only a group that did not answer is a shortfall. + if let Some(refusal) = answered.refusal { + return Err(refusal); + } + if answered.count > 0 { + return Err(Error::CloseGroupShortfall(format!( + "pointer {} stored on {} of {wanted} close-group peers", + hex::encode(address), + answered.count + ))); + } + Err(answered.failure().unwrap_or_else(|| { + Error::Protocol("no close-group peer accepted the pointer".to_string()) + })) + } + + /// The peers a pointer write must land on and a read must ask. + /// + /// One definition serves both. A write acknowledged outside the set the + /// read queries is a write nobody can read — paid for, stored, and + /// invisible — so both go through here rather than each deciding for + /// itself. In particular the payment plan's targets, which can run far + /// wider than the close group, never count towards a write. + /// + /// Same definition, not the same answer: a read does its own lookup, so + /// membership that changed in between is not covered here. That is churn, + /// and what covers it is the nodes' replication, which hands a paid state to + /// every member of the group that should hold it. + async fn pointer_group(&self, address: &XorName) -> Result)>> { + let peers = self + .network() + .find_closest_peers(address, self.config().close_group_size) + .await?; + if peers.is_empty() { + // A lookup that succeeds and returns nobody is not somewhere a + // pointer can live. Saying so here keeps every caller from having + // to notice that a quorum of an empty group is zero. + return Err(Error::CloseGroupShortfall(format!( + "no peer is responsible for pointer {}", + hex::encode(address) + ))); + } + Ok(peers) + } + + /// Read the pointer at `address`, verifying it before returning it. + /// + /// The record is checked against the address it was asked for, so a storer + /// cannot answer with a different owner's pointer, and the state returned + /// must be named by more than one of them, so no single peer decides what + /// the pointer says. + /// + /// # Errors + /// + /// Returns an error if no peer answers, if too few of the group do, or if + /// the best state found has too few peers behind it to be the network's + /// answer rather than one peer's. + pub async fn pointer_get(&self, address: &XorName) -> Result> { + let peers = self.pointer_group(address).await?; + + // Ask the whole close group at once and keep the best answer. + // + // Every reply is verified independently, so a dishonest peer can only + // offer a record that is genuinely signed and genuinely belongs here. + // What it must not be able to do is decide the answer alone: any peer + // may legitimately hold a stale record, so taking the first reply would + // let one pin a reader to it. Asking concurrently also means one slow + // peer cannot stall the read behind the store timeout. + let in_flight = FuturesUnordered::new(); + for (peer_id, addrs) in &peers { + in_flight.push(self.send_pointer_get(*address, *peer_id, addrs.clone())); + } + + let wanted = read_quorum(peers.len()); + let needed = corroboration(peers.len()); + let mut replies = Replies::default(); + let answered = ask_the_group(in_flight, wanted, |found| { + replies.add(found); + // Answers alone settle a read that found nothing. A state is + // settled only once enough peers have named it — until then the + // read keeps asking, because the peers that would confirm it may + // simply not have answered yet. + !replies.any() || replies.corroborated(needed).is_some() + }) + .await; + let corroborated = replies.corroborated(needed).cloned(); + + if answered.count == 0 { + return Err(answered.failure().unwrap_or_else(|| { + Error::Protocol("no close-group peer answered for the pointer".to_string()) + })); + } + // A single answer is not a network verdict: one reachable peer holding + // a stale record, before replication has reached it, looks exactly like + // the whole group agreeing. Say so rather than presenting it as the + // value. + if answered.count < wanted { + return Err(Error::CloseGroupShortfall(format!( + "only {} of the close group answered for pointer {}", + answered.count, + hex::encode(address) + ))); + } + // States were offered, but none by enough peers to call it the + // network's answer. Either the write is still settling, or what was + // offered is a state no honest node holds. Neither is a value to hand + // back as current. + if corroborated.is_none() && replies.any() { + return Err(Error::CloseGroupShortfall(format!( + "no state for pointer {} was named by {needed} of the {} peers that answered", + hex::encode(address), + answered.count + ))); + } + Ok(corroborated) + } + + /// Follow a pointer chain to the chunk it ends at. + /// + /// A pointer may target another pointer, so this walks until it reaches a + /// non-pointer target, refusing to loop or to walk further than + /// [`MAX_POINTER_RESOLVE_DEPTH`]. Both guards live here because the node + /// never reads a target and so cannot enforce either. + /// + /// A multi-hop read is not atomic: a target can move while the walk is in + /// flight, so the result is what the chain said, not a snapshot of it. + /// + /// # Errors + /// + /// Returns an error if a hop is missing, the chain cycles, or the depth + /// limit is reached. A target of a kind this build does not know is *not* + /// an error: the record is signed, so the bytes are authentic, and the + /// caller is handed the target to decide about. + pub async fn pointer_resolve(&self, address: &XorName) -> Result { + let mut seen = HashSet::new(); + let mut at = *address; + + for _ in 0..MAX_POINTER_RESOLVE_DEPTH { + if !seen.insert(at) { + return Err(Error::InvalidData(format!( + "pointer chain cycles back to {}", + hex::encode(at) + ))); + } + let record = self.pointer_get(&at).await?.ok_or_else(|| { + Error::InvalidData(format!("pointer chain breaks at {}", hex::encode(at))) + })?; + let target = record.target(); + match target.kind() { + Some(PointerTargetKind::Pointer) => at = target.address, + Some(PointerTargetKind::Chunk) => return Ok(target), + // A tag this build does not know. The bytes are authentic — the + // record is signed — so report the target rather than pretend + // the chain is broken; the caller decides what to do with it. + None => return Ok(target), + } + } + Err(Error::InvalidData(format!( + "pointer chain from {} is longer than {MAX_POINTER_RESOLVE_DEPTH} hops", + hex::encode(address) + ))) + } + + /// Send one pointer PUT and check the acknowledgement against what was sent. + /// + /// A peer that answers `Success` for a different address or a different + /// state is claiming to hold something the client never submitted. Taking + /// that at face value would let one peer end the write — after payment — + /// while storing nothing, so the reply has to name the record it was given. + async fn send_pointer_put( + &self, + request: PointerPutRequest, + target_peer: PeerId, + peer_addrs: Vec, + expected_address: XorName, + expected_state: XorName, + ) -> Result<()> { + let request_id = self.next_request_id(); + let message = ChunkMessage { + request_id, + body: ChunkMessageBody::PointerPutRequest(request), + }; + let bytes = message + .encode() + .map_err(|e| Error::Protocol(format!("cannot encode pointer PUT: {e}")))?; + + send_and_await_chunk_response( + self.network(), + &target_peer, + bytes, + request_id, + // A pointer PUT carries a single-node proof — merkle proofs are + // refused for pointers — so it does not make the storer do the + // network closeness lookup the merkle timeout exists to cover. + // Same budget as a non-merkle chunk PUT. + STORE_RESPONSE_TIMEOUT, + &peer_addrs, + move |body| read_put_reply(body, expected_address, expected_state), + |e| Error::Network(format!("pointer PUT send failed: {e}")), + || Error::Timeout("pointer PUT timed out".to_string()), + ) + .await + } + + /// Send one pointer GET and validate whatever comes back. + async fn send_pointer_get( + &self, + address: XorName, + target_peer: PeerId, + peer_addrs: Vec, + ) -> Result> { + let request_id = self.next_request_id(); + let message = ChunkMessage { + request_id, + body: ChunkMessageBody::PointerGetRequest(PointerGetRequest::new(address)), + }; + let bytes = message + .encode() + .map_err(|e| Error::Protocol(format!("cannot encode pointer GET: {e}")))?; + + send_and_await_chunk_response( + self.network(), + &target_peer, + bytes, + request_id, + Duration::from_secs(self.config().chunk_get_timeout_secs), + &peer_addrs, + move |body| read_get_reply(body, address), + |e| Error::Network(format!("pointer GET send failed: {e}")), + || Error::Timeout("pointer GET timed out".to_string()), + ) + .await + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use ant_protocol::pqc::api::ml_dsa_65; + use futures::future::BoxFuture; + + fn keypair(seed: u8) -> (MlDsaPublicKey, MlDsaSecretKey) { + ml_dsa_65().generate_keypair_from_seed(&[seed; 32]) + } + + /// A write that fell short is retried with the proof already paid; one + /// that was refused for a reason is not, since retrying cannot change it. + #[test] + fn only_a_shortfall_is_retried_with_the_same_proof() { + for retried in [ + Error::CloseGroupShortfall("3 of 5".into()), + Error::Network("unreachable".into()), + Error::Timeout("slow".into()), + Error::InsufficientPeers("none".into()), + ] { + assert!(worth_retrying(&retried), "{retried} must be retried"); + } + for refused in [ + Error::InvalidData("the pointer moved while this update was in flight".into()), + Error::Payment("valid payment is required".into()), + Error::Protocol("pointer PUT refused".into()), + ] { + assert!(!worth_retrying(&refused), "{refused} must not be retried"); + } + } + + fn chunk_target(byte: u8) -> PointerTarget { + PointerTarget::new(PointerTargetKind::Chunk, [byte; 32]) + } + + /// A create is counter 0 and each update the client builds is one more, + /// the smallest counter that beats the record it read. + #[test] + fn the_client_creates_at_zero_and_steps_by_one() { + let (pk, sk) = keypair(1); + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + assert_eq!(created.counter(), 0); + + let mut current = created; + for expected in 1..=5u64 { + current = current.update(&sk, chunk_target(2)).expect("update"); + assert_eq!(current.counter(), expected); + } + } + + /// A pointer is public-key addressed: the address follows the owner key and + /// nothing else, so a client can compute where its own pointer lives + /// without asking the network. + #[test] + fn the_address_is_derivable_offline_from_the_owner_key() { + let (pk, sk) = keypair(2); + let record = Pointer::create(&sk, &pk, chunk_target(3)).expect("create"); + assert_eq!(record.address(), pointer_address(&pk)); + + let (other, _) = keypair(3); + assert_ne!(record.address(), pointer_address(&other)); + } + + /// Every update is a distinct paid state, so a receipt for one cannot fund + /// another. + #[test] + fn each_update_is_paid_under_its_own_identifier() { + let (pk, sk) = keypair(4); + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let next = created.update(&sk, chunk_target(2)).expect("update"); + let again = next.update(&sk, chunk_target(3)).expect("update"); + + let states: std::collections::BTreeSet<_> = [&created, &next, &again] + .iter() + .map(|r| r.state_id()) + .collect(); + assert_eq!(states.len(), 3, "three updates, three paid identifiers"); + + // And they all live at one address, which is what routing uses. + assert_eq!(created.address(), next.address()); + assert_eq!(next.address(), again.address()); + } + + /// Every reply a peer can give to a read, fed to the code that judges them. + #[test] + fn a_read_refuses_every_reply_but_this_address_own_signed_record() { + let (mine, my_sk) = keypair(5); + let (theirs, their_sk) = keypair(6); + let asked_for = pointer_address(&mine); + let ours = Pointer::create(&my_sk, &mine, chunk_target(1)).expect("create"); + let theirs = Pointer::create(&their_sk, &theirs, chunk_target(1)).expect("create"); + + let reply = |record: Bytes| { + read_get_reply( + ChunkMessageBody::PointerGetResponse(PointerGetResponse::Success { record }), + asked_for, + ) + }; + + let accepted = reply(Bytes::from(ours.to_bytes())); + assert_eq!( + accepted + .expect("a reply") + .expect("valid") + .expect("present") + .state_id(), + ours.state_id() + ); + + // Someone else's pointer: signed, genuine, and not what was asked for. + assert!(reply(Bytes::from(theirs.to_bytes())) + .expect("a reply") + .is_err()); + + // Tampering with any byte breaks the signature, so nothing parses. + let mut tampered = ours.to_bytes(); + tampered[100] ^= 0xff; + assert!(reply(Bytes::from(tampered)).expect("a reply").is_err()); + assert!(reply(Bytes::new()).expect("a reply").is_err()); + + // "I do not have it" is an answer, and counts towards the quorum — + // but only about the address that was asked for. + assert!(read_get_reply( + ChunkMessageBody::PointerGetResponse(PointerGetResponse::NotFound { + address: asked_for + }), + asked_for, + ) + .expect("a reply") + .expect("valid") + .is_none()); + assert!(read_get_reply( + ChunkMessageBody::PointerGetResponse(PointerGetResponse::NotFound { + address: [0u8; 32] + }), + asked_for, + ) + .expect("a reply") + .is_err()); + + // A message that is not a reply to this request is not an answer at all. + assert!(read_get_reply( + ChunkMessageBody::PointerPutResponse(PointerPutResponse::Success { + address: asked_for, + state_id: ours.state_id(), + }), + asked_for, + ) + .is_none()); + } + + /// A peer that answers with a record the client never sent must not end the + /// write. Otherwise one peer takes the payment, stores nothing, and says + /// "stored". + #[test] + fn an_acknowledgement_must_name_the_record_that_was_sent() { + let (pk, sk) = keypair(9); + let sent = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let other = Pointer::create(&sk, &pk, chunk_target(2)).expect("create"); + // Same address — same owner — but a different state. + assert_eq!(sent.address(), other.address()); + assert_ne!(sent.state_id(), other.state_id()); + + let judged = |response| { + read_put_reply( + ChunkMessageBody::PointerPutResponse(response), + sent.address(), + sent.state_id(), + ) + .expect("a reply") + }; + + for honest in [ + PointerPutResponse::Success { + address: sent.address(), + state_id: sent.state_id(), + }, + // A re-submission of a state the node already holds: the record is + // stored, which is all the write asked for. + PointerPutResponse::Unchanged { + address: sent.address(), + state_id: sent.state_id(), + }, + ] { + assert!(judged(honest).is_ok()); + } + + for lie in [ + // The right address, some other state of the same pointer. + PointerPutResponse::Success { + address: sent.address(), + state_id: other.state_id(), + }, + // The right state, at an address that was never written. + PointerPutResponse::Success { + address: [0u8; 32], + state_id: sent.state_id(), + }, + PointerPutResponse::Unchanged { + address: sent.address(), + state_id: other.state_id(), + }, + // Losing a race is not storing. + PointerPutResponse::Stale { + address: sent.address(), + state_id: other.state_id(), + }, + // Nor is a refusal about somebody else's pointer. + PointerPutResponse::Stale { + address: [0u8; 32], + state_id: other.state_id(), + }, + PointerPutResponse::PaymentRequired { + message: "pay up".to_string(), + }, + ] { + assert!(judged(lie).is_err(), "this must not count as stored"); + } + } + + /// Tally some replies and ask what the group's answer is. + fn tally(replies: Vec>) -> Replies { + let mut seen = Replies::default(); + for reply in replies { + seen.add(reply); + } + seen + } + + /// Reads take the best answer, not the first: any single peer is entitled + /// to serve a stale record, and must not be able to pin a reader to it. + #[test] + fn a_read_keeps_the_winner_whatever_order_replies_arrive_in() { + let (pk, sk) = keypair(10); + let old = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let new = old.update(&sk, chunk_target(2)).expect("update"); + let old = || Some(old.clone()); + let new = || Some(new.clone()); + + for order in [ + vec![old(), old(), new(), new()], + vec![new(), new(), old(), old()], + vec![None, old(), new(), None, new(), old()], + vec![new(), None, old(), new(), old()], + ] { + assert_eq!( + tally(order).corroborated(2).expect("an answer").state_id(), + new().expect("new").state_id(), + "the newest corroborated state wins however the replies interleave" + ); + } + assert!( + tally(vec![None, None]).corroborated(2).is_none(), + "nobody holds it" + ); + assert!(!tally(vec![None, None]).any(), "and nobody named a state"); + } + + /// One peer naming a higher state must not bury the state the rest of the + /// group agrees on. + /// + /// Two ways to arrive here. A bad peer offers an owner-signed state nobody + /// paid to store: it can no longer make the client return it, but if it + /// could discard the count behind the real state it would make the pointer + /// unreadable instead — a denial of service in place of a forgery. And an + /// ordinary read taken while an update is in flight looks exactly the same. + #[test] + fn a_higher_state_only_one_peer_has_does_not_bury_the_agreed_one() { + let (pk, sk) = keypair(12); + let agreed = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let singleton = Pointer::sign(&sk, &pk, 900, chunk_target(9)).expect("sign"); + assert!(singleton.replaces(&agreed), "it does outrank what is held"); + + let agreed_reply = || Some(agreed.clone()); + let singleton_reply = || Some(singleton.clone()); + + for order in [ + vec![singleton_reply(), agreed_reply(), agreed_reply()], + vec![agreed_reply(), singleton_reply(), agreed_reply()], + vec![agreed_reply(), agreed_reply(), singleton_reply()], + vec![ + singleton_reply(), + None, + agreed_reply(), + agreed_reply(), + agreed_reply(), + ], + ] { + let seen = tally(order); + assert_eq!( + seen.corroborated(2).expect("an answer").state_id(), + agreed.state_id(), + "the state the group agrees on is the answer, whenever the \ + singleton arrives" + ); + } + + // And once a second peer confirms it, it is the answer — that is the + // bar, and two colluding peers can clear it. See ADR-0016. + let seen = tally(vec![ + singleton_reply(), + singleton_reply(), + agreed_reply(), + agreed_reply(), + ]); + assert_eq!( + seen.corroborated(2).expect("an answer").state_id(), + singleton.state_id() + ); + } + + /// The fan-out both a write and a read use, driven with synthetic replies. + /// + /// A majority ends the operation. The peers that have not answered are + /// dropped mid-flight, so one unreachable peer cannot hold the operation + /// open for its whole timeout after the answer is already decided. + #[tokio::test] + async fn a_majority_ends_the_operation_and_a_dead_peer_cannot_stall_it() { + let group = |good: usize, dead: usize| { + let in_flight: FuturesUnordered>>> = + FuturesUnordered::new(); + for _ in 0..good { + in_flight.push(Box::pin(async { Ok(None) })); + } + for _ in 0..dead { + in_flight.push(Box::pin(futures::future::pending())); + } + in_flight + }; + + // Four of seven answer; three never will. Without the early return this + // would never finish. + let answered = tokio::time::timeout( + std::time::Duration::from_secs(10), + ask_the_group(group(4, 3), read_quorum(7), |_| true), + ) + .await + .expect("a majority must end the read without waiting for the rest"); + assert_eq!(answered.count, 4); + + // One short of a majority, and the rest are gone: the caller is told + // how many answered rather than being handed one peer's word. + let answered = tokio::time::timeout( + std::time::Duration::from_secs(10), + ask_the_group(group(3, 0), read_quorum(7), |_| true), + ) + .await + .expect("nothing is in flight to wait for"); + assert_eq!(answered.count, 3, "three is not a majority of seven"); + } + + /// A peer that errors has not answered, and cannot make up a quorum. + #[tokio::test] + async fn an_error_is_not_an_answer() { + let in_flight: FuturesUnordered>>> = + FuturesUnordered::new(); + for _ in 0..6 { + in_flight.push(Box::pin(async { + Err(Error::Protocol("peer refused".to_string())) + })); + } + in_flight.push(Box::pin(async { Ok(None) })); + + let answered = ask_the_group(in_flight, read_quorum(7), |_| true).await; + assert_eq!(answered.count, 1, "six failures are not six answers"); + assert!( + answered.failure().is_some(), + "the failure must be kept to explain the shortfall" + ); + } + + /// A refusal is kept apart from a missing answer, so a write that fell + /// short because a peer said no reports that, and is not retried. + #[tokio::test] + async fn a_refusal_is_kept_apart_from_a_missing_answer() { + let in_flight: FuturesUnordered>> = FuturesUnordered::new(); + in_flight.push(Box::pin(async { Err(Error::Timeout("slow".to_string())) })); + in_flight.push(Box::pin(async { + Err(Error::InvalidData( + "the pointer moved while this update was in flight".to_string(), + )) + })); + in_flight.push(Box::pin(async { Err(Error::Timeout("slow".to_string())) })); + in_flight.push(Box::pin(async { Ok(()) })); + + let answered = ask_the_group(in_flight, write_quorum(7), |()| true).await; + assert_eq!(answered.count, 1); + assert!( + matches!(&answered.refusal, Some(Error::InvalidData(message)) if message.contains("moved")), + "the refusal is what the write reports" + ); + assert!(matches!(answered.last_error, Some(Error::Timeout(_)))); + } + + /// A write quorum and a read quorum must intersect at every group width. + /// + /// This is the property a fixed four-of-K silently broke: at K=20 a write + /// on four peers and a read of a disjoint four would never meet, so an + /// acknowledged pointer could read back as missing. + #[test] + fn write_and_read_quorums_overlap_by_the_corroboration_a_read_demands() { + // The whole basis of the read rule: a state a write actually landed + // must be reported by at least as many answering peers as a read + // insists on, or a legitimate pointer would read back as unconfirmed. + for k in 1usize..=64 { + let write = write_quorum(k); + let read = read_quorum(k); + let needed = corroboration(k); + assert!( + write + read >= k + needed, + "at width {k} a {write}-write and a {read}-read overlap in fewer \ + than the {needed} peers a read requires" + ); + assert!(write <= k, "a quorum cannot need more peers than exist"); + assert!(read <= k); + assert!( + needed <= read, + "a read cannot need more backers than answers" + ); + } + } + + /// The default close group is seven: four answers decide a read, five + /// stores make a write, and two peers must name what a read returns. + #[test] + fn the_default_group_reads_on_four_and_writes_on_five() { + assert_eq!(read_quorum(7), 4); + assert_eq!(write_quorum(7), 5); + assert_eq!(corroboration(7), 2); + + assert_eq!(read_quorum(20), 11); + assert_eq!(write_quorum(20), 12); + + // A group of one cannot corroborate itself with anyone else. + assert_eq!(read_quorum(1), 1); + assert_eq!(write_quorum(1), 1); + assert_eq!(corroboration(1), 1); + } + + /// A quorum of nothing is satisfied by nothing. + /// + /// `write_quorum(0)` is zero, so a write to an empty group would count zero + /// acknowledgements as enough and report a paid record as stored on no + /// peers at all. Both paths refuse an empty group before that arithmetic + /// is ever reached. + #[test] + fn an_empty_group_is_not_a_quorum() { + assert_eq!( + write_quorum(0), + 0, + "which is exactly why an empty group must be refused earlier" + ); + assert_eq!(corroboration(0), 0, "and nothing can corroborate nothing"); + for k in 1usize..=64 { + assert!(write_quorum(k) >= 1, "a real group always needs a storer"); + assert!( + corroboration(k) >= 1, + "and a real answer always needs a peer" + ); + } + } + + /// One peer must not be able to decide what a pointer says. + /// + /// The attack this closes: an owner signs a state without paying for it and + /// one close-group peer serves it. Every honest peer says `NotFound`, the + /// record verifies and belongs at the address, and it wins the merge — so a + /// read that took the best answer regardless of who backed it would hand + /// back a state the network never stored. + #[test] + fn a_state_only_one_peer_reports_is_not_the_networks_answer() { + let (pk, sk) = keypair(11); + let unpaid = Pointer::sign(&sk, &pk, 900, chunk_target(9)).expect("sign"); + let stored = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + + // One peer offers the unpaid state; the rest of the close group has + // never heard of it. + let seen = tally(vec![Some(unpaid.clone()), None, None, None]); + assert!(seen.any(), "a state was offered"); + assert!( + seen.corroborated(corroboration(7)).is_none(), + "but one peer naming it is one peer deciding, not an answer" + ); + + // A state the group really holds clears the bar. + let seen = tally(vec![Some(stored.clone()), Some(stored.clone()), None]); + assert_eq!( + seen.corroborated(corroboration(7)) + .expect("an answer") + .state_id(), + stored.state_id() + ); + } + + /// A chain that points at itself must be caught by the seen-set, not by + /// running out of hops: the node never reads a target, so a cycle is the + /// client's to detect. + #[test] + fn a_self_referencing_chain_is_a_cycle() { + let (pk, sk) = keypair(8); + let address = pointer_address(&pk); + let loops = Pointer::sign( + &sk, + &pk, + 0, + PointerTarget::new(PointerTargetKind::Pointer, address), + ) + .expect("sign"); + + // The walk `pointer_resolve` performs, without the network: the first + // hop lands back on the address already visited. + let mut seen = HashSet::new(); + assert!(seen.insert(loops.address())); + assert!( + !seen.insert(loops.target().address), + "a pointer targeting its own address is a cycle the client must refuse" + ); + } + + /// An unknown target kind is carried, not rejected: the record is signed, so + /// the bytes are authentic even when this build cannot follow them. + #[test] + fn an_unknown_target_kind_is_carried_not_rejected() { + let (pk, sk) = keypair(7); + let exotic = PointerTarget::from_raw_tag(200, [9; 32]); + let record = Pointer::sign(&sk, &pk, 0, exotic).expect("sign"); + let parsed = Pointer::from_bytes(&record.to_bytes()).expect("parse"); + assert_eq!(parsed.target().kind_tag(), 200); + assert_eq!(parsed.target().kind(), None); + } +} diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 7eb1ff23..1df32d0e 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -961,7 +961,27 @@ impl Client { data_size: u64, data_type: u32, ) -> Result { - let witnessed_selection = self.select_witnessed_quote_selection(address).await?; + self.get_store_quote_plan_split(address, address, data_size, data_type) + .await + } + + /// As [`Self::get_store_quote_plan`], where the address that decides *who* + /// quotes differs from the one they quote *for*. + /// + /// A chunk passes the same value twice. A pointer does not: it is stored at + /// an address that never changes, so the quote must name the state being + /// paid for instead, or every update after the first would be free. The + /// close group is still the one around the pointer's address, because that + /// is what the network routes on and a state identifier names no group. + pub(crate) async fn get_store_quote_plan_split( + &self, + routing: &[u8; 32], + content: &[u8; 32], + data_size: u64, + data_type: u32, + ) -> Result { + let address = content; + let witnessed_selection = self.select_witnessed_quote_selection(routing).await?; let voters_by_peer: VotersByPeer = witnessed_selection .quote_peers .iter() diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs index fd456c35..e56932aa 100644 --- a/ant-core/src/data/mod.rs +++ b/ant-core/src/data/mod.rs @@ -22,6 +22,11 @@ pub use crate::node::devnet::LocalDevnet; // Re-export commonly used types from the wire protocol crate. pub use ant_protocol::{compute_address, DataChunk, XorName}; +// The pointer record (ADR-0016) and the owner key that signs it, so a caller of +// the pointer API needs no direct protocol dependency. +pub use ant_protocol::pointer::{pointer_address, Pointer, PointerTarget, PointerTargetKind}; +pub use ant_protocol::pqc::api::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; + // Re-export client data types pub use client::batch::{ finalize_batch_payment, PaidChunk, PaymentIntent, PreparedChunk, SingleNodeQuotePayment, diff --git a/ant-core/tests/e2e_pointer.rs b/ant-core/tests/e2e_pointer.rs new file mode 100644 index 00000000..7dc1e46a --- /dev/null +++ b/ant-core/tests/e2e_pointer.rs @@ -0,0 +1,1110 @@ +//! End-to-end tests for pointers against a local testnet with real EVM payments. +//! +//! Real nodes, real QUIC between them, real Anvil settlement, and the client's +//! own create / update / put / get / resolve path over the top. +//! +//! Most tests go through the public client API only. Some also need what no +//! client call produces on purpose: a state that reached only part of the close +//! group, or a request no honest client would send. Those pay for a state the +//! way the client does and then either +//! +//! - send it to one close-group node **over QUIC**, from the client's own node, +//! so transport, dispatch, payment verification and the store all run as +//! they do for any peer's PUT (`put_over_quic`); or +//! - hand it to chosen nodes through each node's request handler directly, to +//! place a fork or a lagging replica (`store_on`). That exercises the same +//! dispatch, payment verification, closeness gate and merge, but not the +//! transport, and it is not evidence about the client's write path. +//! +//! To make a read or a write reach only part of the group through the client's +//! own path, a test tells every node except the ones meant to answer to ignore +//! pointer GETs or PUTs while staying in the network (`support::PointerSilence`). +//! The client then waits out its timeout on them, exactly as it would on +//! unreachable peers. Silencing the whole network rather than chosen group +//! members keeps these tests independent of which seven peers the client's own +//! lookup settles on: that lookup keeps only peers that answer it in time, so +//! under load it can swap the peer at the edge of the group for the next one +//! out — which is then silent too. +//! +//! Tests that rely on which peers form the close group check that the group +//! has not changed before they read, so a moved group fails loudly rather than +//! as a puzzling assertion. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod support; + +use ant_core::data::client::pointer::MAX_POINTER_RESOLVE_DEPTH; +use ant_core::data::{Client, ClientConfig, Error, XorName}; +use ant_node::storage::AntProtocol; +use ant_protocol::chunk::{ + ChunkMessage, ChunkMessageBody, PointerGetRequest, PointerGetResponse, PointerPutRequest, + PointerPutResponse, ProtocolError, +}; +use ant_protocol::evm::U256; +use ant_protocol::pointer::{ + pointer_address, Pointer, PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, + POINTER_BODY_LEN, POINTER_WIRE_LEN, +}; +use ant_protocol::pqc::api::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; +use ant_protocol::transport::{MultiAddr, PeerId}; +use ant_protocol::{send_and_await_chunk_response, CLOSE_GROUP_SIZE}; +use bytes::Bytes; +use serial_test::serial; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use support::{test_client_config, MiniTestnet, PointerSilence, DEFAULT_NODE_COUNT}; + +/// The node the client is built on. Its own lookups leave it out, so it is +/// never one of the close group a test addresses. +const CLIENT_NODE: usize = 3; + +async fn setup() -> (Client, MiniTestnet) { + let testnet = MiniTestnet::start(DEFAULT_NODE_COUNT).await; + + // The harness waits for convergence but carries on if it runs out of + // time. These tests address close groups by name, so an unconverged + // network would fail them in confusing ways; say so here instead. + for (index, node) in testnet.nodes.iter().enumerate() { + if let Some(p2p) = &node.p2p_node { + let known = p2p.dht().get_routing_table_size().await; + assert!( + known >= DEFAULT_NODE_COUNT - 1, + "the testnet did not converge: node {index} knows {known} of {} peers", + DEFAULT_NODE_COUNT - 1 + ); + } + } + + let node = testnet.node(CLIENT_NODE).expect("client node"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + + (client, testnet) +} + +/// A fresh owner. Every test needs its own: the address *is* the key, so two +/// tests sharing one would be writing to one pointer. +fn owner() -> (MlDsaPublicKey, MlDsaSecretKey) { + ml_dsa_65() + .generate_keypair() + .expect("generate ML-DSA-65 keypair") +} + +fn chunk_target(byte: u8) -> PointerTarget { + PointerTarget::new(PointerTargetKind::Chunk, [byte; 32]) +} + +/// One peer of a close group, as the client addresses it and as the node +/// that runs it. +struct Member { + peer_id: PeerId, + addrs: Vec, + protocol: Arc, + silence: Arc, +} + +/// The close group the client writes `address` to, found by the lookup the +/// client itself uses. +async fn close_group(client: &Client, testnet: &MiniTestnet, address: &XorName) -> Vec { + let peers = client + .network() + .find_closest_peers(address, CLOSE_GROUP_SIZE) + .await + .expect("close group lookup"); + let group: Vec = peers + .into_iter() + .filter_map(|(peer_id, addrs)| { + testnet.nodes.iter().find_map(|node| { + let p2p = node.p2p_node.as_ref()?; + if *p2p.peer_id() != peer_id { + return None; + } + Some(Member { + peer_id, + addrs: addrs.clone(), + protocol: Arc::clone(node.protocol.as_ref()?), + silence: Arc::clone(&node.pointer_silence), + }) + }) + }) + .collect(); + assert_eq!( + group.len(), + CLOSE_GROUP_SIZE, + "every close-group peer is a testnet node" + ); + group +} + +/// Fail if the lookup no longer returns `group`, so a test that placed states +/// on named peers never reads from a different set without saying so. +/// +/// The lookup keeps only peers that answer it in time, so one slow peer can +/// drop out of a single lookup without the group having moved. It is asked a +/// few times before the group is declared moved. +async fn assert_group_unchanged(client: &Client, address: &XorName, group: &[Member]) { + let mut before: Vec<[u8; 32]> = group.iter().map(|m| *m.peer_id.as_bytes()).collect(); + before.sort_unstable(); + let mut now = Vec::new(); + for _ in 0..3 { + now = client + .network() + .find_closest_peers(address, CLOSE_GROUP_SIZE) + .await + .expect("close group lookup") + .iter() + .map(|(peer_id, _)| *peer_id.as_bytes()) + .collect(); + now.sort_unstable(); + if now == before { + return; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + assert_eq!(now, before, "the close group moved during the test"); +} + +async fn balance(client: &Client) -> U256 { + client + .wallet() + .expect("wallet") + .balance_of_tokens() + .await + .expect("balance query") +} + +/// Pay for `record`'s state once, the way the client does, and build the PUT +/// that carries the proof. +async fn paid(client: &Client, record: &Pointer) -> PointerPutRequest { + let (proof, _) = client + .pay_for_storage_split( + &record.address(), + &record.state_id(), + POINTER_WIRE_LEN as u64, + DATA_TYPE_POINTER, + ) + .await + .expect("pay for the pointer state"); + PointerPutRequest::with_payment(Bytes::from(record.to_bytes()), proof) +} + +/// Send `request` to one close-group peer over QUIC from the client's node, +/// and return exactly what that peer answered. +async fn put_over_quic( + client: &Client, + member: &Member, + request: PointerPutRequest, +) -> PointerPutResponse { + static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1 << 62); + let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let message = ChunkMessage { + request_id, + body: ChunkMessageBody::PointerPutRequest(request), + }; + send_and_await_chunk_response( + client.network().node(), + &member.peer_id, + message.encode().expect("encode"), + request_id, + Duration::from_secs(30), + &member.addrs, + |body| match body { + ChunkMessageBody::PointerPutResponse(response) => Some(Ok(response)), + _ => None, + }, + |e| format!("pointer PUT send failed: {e}"), + || "pointer PUT timed out".to_string(), + ) + .await + .expect("the peer answers a pointer PUT") +} + +/// Hand `request` to one node's request handler and return its answer. +async fn put_to(node: &AntProtocol, request: &PointerPutRequest) -> PointerPutResponse { + let message = ChunkMessage { + request_id: 1, + body: ChunkMessageBody::PointerPutRequest(request.clone()), + }; + let reply = node + .try_handle_request(&message.encode().expect("encode")) + .await + .expect("handle") + .expect("a pointer PUT is answered"); + match ChunkMessage::decode(&reply).expect("decode").body { + ChunkMessageBody::PointerPutResponse(response) => response, + other => panic!("expected a pointer PUT response, got {other:?}"), + } +} + +/// What one node serves at `address`. +async fn held_by(node: &AntProtocol, address: &XorName) -> Option { + let message = ChunkMessage { + request_id: 2, + body: ChunkMessageBody::PointerGetRequest(PointerGetRequest::new(*address)), + }; + let reply = node + .try_handle_request(&message.encode().expect("encode")) + .await + .expect("handle") + .expect("a pointer GET is answered"); + match ChunkMessage::decode(&reply).expect("decode").body { + ChunkMessageBody::PointerGetResponse(PointerGetResponse::Success { record }) => { + Some(Pointer::from_bytes(&record).expect("a served record verifies")) + } + ChunkMessageBody::PointerGetResponse(PointerGetResponse::NotFound { .. }) => None, + other => panic!("expected a pointer GET response, got {other:?}"), + } +} + +/// Pay for `record` once and deliver it to each of `members`, requiring every +/// one to store it. +async fn store_on(client: &Client, members: &[Member], record: &Pointer) { + let request = paid(client, record).await; + for (index, member) in members.iter().enumerate() { + match put_to(&member.protocol, &request).await { + PointerPutResponse::Success { address, state_id } => { + assert_eq!(address, record.address()); + assert_eq!(state_id, record.state_id()); + } + other => panic!( + "member {index} refused a paid state at counter {}: {other:?}", + record.counter() + ), + } + } +} + +/// Require each of `members` to serve exactly `record`'s state. +async fn assert_held(members: &[Member], record: &Pointer) { + for (index, member) in members.iter().enumerate() { + let held = held_by(&member.protocol, &record.address()) + .await + .unwrap_or_else(|| panic!("member {index} holds nothing")); + assert_eq!( + held.state_id(), + record.state_id(), + "member {index} holds counter {} instead of counter {}", + held.counter(), + record.counter() + ); + } +} + +/// How many nodes anywhere in the testnet serve exactly `record`'s state. +async fn holders_anywhere(testnet: &MiniTestnet, record: &Pointer) -> usize { + let mut count = 0; + for protocol in testnet + .nodes + .iter() + .filter_map(|node| node.protocol.as_ref()) + { + if held_by(protocol, &record.address()) + .await + .is_some_and(|held| held.state_id() == record.state_id()) + { + count += 1; + } + } + count +} + +/// A kind of pointer request a node can be told to ignore. +#[derive(Clone, Copy)] +enum Request { + Get, + Put, +} + +/// Make every node in the testnet except `answering` ignore pointer requests +/// of `kind`, and `answering` handle them. +fn only_these_answer(testnet: &MiniTestnet, answering: &[Member], kind: Request) { + for node in &testnet.nodes { + let silent = !answering + .iter() + .any(|member| Arc::ptr_eq(&member.silence, &node.pointer_silence)); + let switch = match kind { + Request::Get => &node.pointer_silence.gets, + Request::Put => &node.pointer_silence.puts, + }; + switch.store(silent, Ordering::Relaxed); + } +} + +/// Let every node handle every pointer request again. +fn everyone_answers(testnet: &MiniTestnet) { + for node in &testnet.nodes { + node.pointer_silence.gets.store(false, Ordering::Relaxed); + node.pointer_silence.puts.store(false, Ordering::Relaxed); + } +} + +/// Read `address` while only `answering` handle pointer GETs. +/// +/// The client's own lookup can leave out a peer that was meant to answer when +/// that peer is slow to answer the lookup itself; the read then hears from +/// fewer peers than were allowed, for a reason that has nothing to do with the +/// read. That one outcome is retried, a few times. Everything else — a value, +/// a shortfall with as many answers as were allowed, a failure to corroborate — +/// is returned at once, so no result under test is ever retried away. +async fn read_answered_by( + reader: &Client, + testnet: &MiniTestnet, + address: &XorName, + answering: &[Member], +) -> Result, Error> { + let mut attempts = 0; + loop { + attempts += 1; + only_these_answer(testnet, answering, Request::Get); + let read = reader.pointer_get(address).await; + everyone_answers(testnet); + let heard_from_fewer = matches!( + &read, + Err(Error::CloseGroupShortfall(message)) + if message.contains("of the close group answered") + && number_after(message, "only ") + .is_some_and(|answered| answered < answering.len()) + ); + if !heard_from_fewer || attempts == 3 { + return read; + } + } +} + +/// The number written straight after `prefix` in `message`. +fn number_after(message: &str, prefix: &str) -> Option { + message + .split_once(prefix)? + .1 + .split_whitespace() + .next()? + .parse() + .ok() +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_pointer_is_created_paid_for_and_read_back() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + + let before = balance(&client).await; + let address = client + .pointer_create(&sk, &pk, chunk_target(1)) + .await + .expect("pointer_create should succeed with payment"); + assert!( + balance(&client).await < before, + "creating a pointer must settle a payment on chain" + ); + + // Public-key addressed: the client knew the address before it asked anyone. + assert_eq!(address, pointer_address(&pk)); + + let record = client + .pointer_get(&address) + .await + .expect("pointer_get should succeed") + .expect("the pointer must be found after creating it"); + + assert_eq!(record.counter(), 0, "a create is counter 0"); + assert_eq!(record.target(), chunk_target(1)); + assert_eq!(record.address(), address); + assert_eq!( + record.owner().to_bytes(), + pk.to_bytes(), + "the record carries its own owner key" + ); + + drop(client); + testnet.teardown().await; +} + +/// Pay to update: each update is its own paid state, and the network serves +/// the new one. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn an_update_is_paid_for_and_replaces_what_the_network_serves() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + + let address = client + .pointer_create(&sk, &pk, chunk_target(1)) + .await + .expect("create"); + + for expected_counter in 1..=2u64 { + let before = balance(&client).await; + let updated = client + .pointer_update(&sk, &pk, chunk_target(expected_counter as u8 + 1)) + .await + .expect("pointer_update should succeed with payment"); + assert_eq!(updated, address, "an update stays at the same address"); + assert!( + balance(&client).await < before, + "every update must settle a payment of its own" + ); + + let record = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!( + record.counter(), + expected_counter, + "pointer_update signs one past what the network serves" + ); + assert_eq!(record.target(), chunk_target(expected_counter as u8 + 1)); + } + + drop(client); + testnet.teardown().await; +} + +/// A node that already holds a pointer takes a paid update to it, even one that +/// skips counters. The chunk path answers "already exists" and stops; a +/// pointer must not, or every update after the first is lost. Checked on every +/// node of the close group, not just a quorum, then through the client's own +/// update path. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn every_close_group_node_accepts_a_paid_update_to_a_pointer_it_holds() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let address = pointer_address(&pk); + let group = close_group(&client, &testnet, &address).await; + + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("sign"); + store_on(&client, &group, &created).await; + assert_held(&group, &created).await; + + // Every node holds the pointer now, and every one must take the update. + let updated = Pointer::sign(&sk, &pk, 5, chunk_target(2)).expect("sign"); + store_on(&client, &group, &updated).await; + assert_held(&group, &updated).await; + + // The same through the client's own path. + assert_group_unchanged(&client, &address, &group).await; + client + .pointer_update(&sk, &pk, chunk_target(3)) + .await + .expect("pointer_update with payment must not be refused"); + let record = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(record.counter(), 6); + assert_eq!(record.target(), chunk_target(3)); + + drop(client); + testnet.teardown().await; +} + +/// The counter orders states; it does not meter them. A paid record that skips +/// counters is taken, and the client's next update carries on from it. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_paid_update_may_skip_counters() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + + let address = client + .pointer_create(&sk, &pk, chunk_target(1)) + .await + .expect("create"); + + let skipped = Pointer::sign(&sk, &pk, 9, chunk_target(2)).expect("sign"); + client + .pointer_put(&skipped) + .await + .expect("a paid record that skips counters must be taken"); + let held = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(held.counter(), 9); + assert_eq!(held.target(), chunk_target(2)); + + client + .pointer_update(&sk, &pk, chunk_target(3)) + .await + .expect("an update after a skip"); + let held = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(held.counter(), 10); + assert_eq!(held.target(), chunk_target(3)); + + drop(client); + testnet.teardown().await; +} + +/// A write ends once its quorum has answered, so a node can miss updates. The +/// next update brings it level, however far behind it was. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn nodes_that_missed_updates_take_the_next_one() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let address = pointer_address(&pk); + let group = close_group(&client, &testnet, &address).await; + let (current, lagging) = group.split_at_checked(5).expect("a full close group"); + + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("sign"); + store_on(&client, &group, &created).await; + + // Two updates reach only five nodes; the same two nodes miss both. + let first = created.update(&sk, chunk_target(2)).expect("sign"); + store_on(&client, current, &first).await; + let second = first.update(&sk, chunk_target(3)).expect("sign"); + store_on(&client, current, &second).await; + assert_held(current, &second).await; + assert_held(lagging, &created).await; + + // Five nodes back the current state, so any four answers include two of + // them and the network reads it. + assert_group_unchanged(&client, &address, &group).await; + let read = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(read.state_id(), second.state_id()); + + // The next update is three counters ahead of what the laggards hold, and + // every node takes it, the laggards included. + let next = second.update(&sk, chunk_target(4)).expect("sign"); + store_on(&client, &group, &next).await; + assert_held(&group, &next).await; + + drop(client); + testnet.teardown().await; +} + +/// Two competing writes that both complete: each reached the write quorum. Any +/// node that saw both keeps the merge winner, so the winner ends on at least +/// five of the seven, every read of four answers includes two of them, and the +/// read returns the winner however the replies are ordered. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn two_completed_competing_writes_read_as_the_merge_winner() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let address = pointer_address(&pk); + let group = close_group(&client, &testnet, &address).await; + + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("sign"); + client + .pointer_put(&created) + .await + .expect("create completes"); + + let loser = Pointer::sign(&sk, &pk, 1, chunk_target(9)).expect("sign"); + let winner = Pointer::sign(&sk, &pk, 1, chunk_target(2)).expect("sign"); + assert!(winner.replaces(&loser), "smaller target bytes win"); + client + .pointer_put(&loser) + .await + .expect("the losing write completes"); + client + .pointer_put(&winner) + .await + .expect("the winning write completes"); + + assert!( + holders_anywhere(&testnet, &winner).await >= 5, + "a completed write reaches the write quorum" + ); + assert_group_unchanged(&client, &address, &group).await; + for _ in 0..3 { + let read = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(read.state_id(), winner.state_id()); + } + + drop(client); + testnet.teardown().await; +} + +/// A write that fails leaves a fork. Every node holds one state at counter 1; +/// the owner's competing write, which wins the merge, can reach only three of +/// the close group because every other node ignores it, so `pointer_put` +/// reports the shortfall — and the nodes it reached keep it. A read of four +/// answers can see either side corroborated, so it returns one of the two, +/// both owner-signed and paid for. The next update heals the fork on every +/// node. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_write_that_fails_leaves_a_fork_that_the_next_counter_heals() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let address = pointer_address(&pk); + let group = close_group(&client, &testnet, &address).await; + // The closest three, never the member at the edge of the group. + let reached = group.get(..3).expect("a full close group"); + + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("sign"); + store_on(&client, &group, &created).await; + let loser = Pointer::sign(&sk, &pk, 1, chunk_target(9)).expect("sign"); + store_on(&client, &group, &loser).await; + + let winner = Pointer::sign(&sk, &pk, 1, chunk_target(2)).expect("sign"); + assert!(winner.replaces(&loser), "smaller target bytes win"); + assert_group_unchanged(&client, &address, &group).await; + only_these_answer(&testnet, reached, Request::Put); + let written = client.pointer_put(&winner).await; + // Taken while everyone else is still silent, so nothing in flight can + // change it. + let winner_holders = holders_anywhere(&testnet, &winner).await; + everyone_answers(&testnet); + + let acknowledged = match &written { + Err(Error::CloseGroupShortfall(message)) if message.contains("of 5 close-group peers") => { + number_after(message, "stored on ") + } + _ => None, + } + .unwrap_or_else(|| panic!("a write that reaches three of seven must fail, got {written:?}")); + assert_eq!( + acknowledged, winner_holders, + "the write counts exactly the replicas it left" + ); + assert!( + (1..=3).contains(&winner_holders), + "a failed write lands where it reached and nowhere else, got {winner_holders} replicas" + ); + + // It failed, and it still landed where it reached: a fork. + for (index, member) in group.iter().enumerate() { + let held = held_by(&member.protocol, &address) + .await + .unwrap_or_else(|| panic!("member {index} holds nothing")); + assert!( + held.state_id() == winner.state_id() || held.state_id() == loser.state_id(), + "member {index} holds neither side of the fork" + ); + } + + assert_group_unchanged(&client, &address, &group).await; + let read = client + .pointer_get(&address) + .await + .expect("a forked pointer still reads") + .expect("present"); + assert!( + read.state_id() == winner.state_id() || read.state_id() == loser.state_id(), + "a forked read returns one of the two corroborated states" + ); + assert_eq!(read.counter(), 1); + + // The owner updates as usual, one past what the read returned. Every node + // on either side takes it, so it reaches the write quorum. + client + .pointer_update(&sk, &pk, chunk_target(5)) + .await + .expect("an update must heal a fork"); + let healed = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(healed.counter(), 2); + assert_eq!(healed.target(), chunk_target(5)); + + // The write stopped at its quorum. Delivering the same state to the whole + // group shows that no node on either side refuses it: each either already + // holds it or takes it now. + let request = paid(&client, &healed).await; + for (index, member) in group.iter().enumerate() { + match put_to(&member.protocol, &request).await { + PointerPutResponse::Success { state_id, .. } + | PointerPutResponse::Unchanged { state_id, .. } => { + assert_eq!(state_id, healed.state_id(), "member {index}"); + } + other => panic!("member {index} refused the healing update: {other:?}"), + } + } + assert_held(&group, &healed).await; + + drop(client); + testnet.teardown().await; +} + +/// Over QUIC, from the client's node to one close-group peer: an unpaid record +/// is refused, a forged one is refused, a proof paid for a different state is +/// refused, and only the genuine record paid for itself is stored. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_node_refuses_unpaid_forged_and_misdirected_writes_over_the_network() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let address = pointer_address(&pk); + let group = close_group(&client, &testnet, &address).await; + let peer = group.first().expect("a close-group peer"); + + let created = Pointer::create(&sk, &pk, chunk_target(1)).expect("sign"); + + // No proof at all. + let unpaid = PointerPutRequest::new(Bytes::from(created.to_bytes())); + match put_over_quic(&client, peer, unpaid).await { + PointerPutResponse::PaymentRequired { .. } => {} + other => panic!("an unpaid record must be refused, got {other:?}"), + } + assert!(held_by(&peer.protocol, &address).await.is_none()); + + // A real payment for this state, carried by a record whose signature is + // broken. The body is untouched, so the state and the payment match. + let genuine = paid(&client, &created).await; + let proof = genuine.payment_proof.clone().expect("a paid request"); + let mut forged_bytes = created.to_bytes(); + if let Some(byte) = forged_bytes.get_mut(POINTER_BODY_LEN) { + *byte ^= 0x01; + } + let forged = PointerPutRequest::with_payment(Bytes::from(forged_bytes), proof.clone()); + match put_over_quic(&client, peer, forged).await { + PointerPutResponse::Error(ProtocolError::StorageFailed(message)) + if message.contains("signature") => {} + other => panic!("a forged record must be refused for its signature, got {other:?}"), + } + assert!(held_by(&peer.protocol, &address).await.is_none()); + + // The genuine record with its own payment. + match put_over_quic(&client, peer, genuine).await { + PointerPutResponse::Success { + address: at, + state_id, + } => { + assert_eq!(at, address); + assert_eq!(state_id, created.state_id()); + } + other => panic!("a paid genuine record must be stored, got {other:?}"), + } + assert_held(std::slice::from_ref(peer), &created).await; + + // An update carrying the create's payment: paid, but for another state. + let update = created.update(&sk, chunk_target(2)).expect("sign"); + let misdirected = PointerPutRequest::with_payment(Bytes::from(update.to_bytes()), proof); + match put_over_quic(&client, peer, misdirected).await { + PointerPutResponse::PaymentRequired { .. } => {} + other => panic!("a payment for another state must be refused, got {other:?}"), + } + assert_held(std::slice::from_ref(peer), &created).await; + + // The same update paid for itself is taken. + match put_over_quic(&client, peer, paid(&client, &update).await).await { + PointerPutResponse::Success { state_id, .. } => assert_eq!(state_id, update.state_id()), + other => panic!("a paid update must be stored, got {other:?}"), + } + assert_held(std::slice::from_ref(peer), &update).await; + + drop(client); + testnet.teardown().await; +} + +/// The read's two rules, over QUIC: it needs answers from four of the seven, +/// and a state is an answer once two peers name it. +/// +/// Two higher states, each owner-signed and paid for but held by one peer, +/// never displace the one the rest agree on. Three answers that all agree +/// still do not settle a read. Four answers — the two lone states and exactly +/// two that agree — do. A corroboration bar of one or three, or a quorum of +/// three, fails this test. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_read_needs_four_answers_and_two_that_agree() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let address = pointer_address(&pk); + let group = close_group(&client, &testnet, &address).await; + + let agreed = Pointer::create(&sk, &pk, chunk_target(1)).expect("sign"); + store_on(&client, &group, &agreed).await; + let first_lone = Pointer::sign(&sk, &pk, 5, chunk_target(9)).expect("sign"); + store_on(&client, group.get(..1).expect("a peer"), &first_lone).await; + let second_lone = Pointer::sign(&sk, &pk, 6, chunk_target(8)).expect("sign"); + store_on(&client, group.get(1..2).expect("a peer"), &second_lone).await; + + // A reader that gives up on a silent peer after twenty seconds rather than + // a minute, still well past what an answering peer takes. + let reader = Client::from_node( + testnet.node(CLIENT_NODE).expect("client node"), + ClientConfig { + chunk_get_timeout_secs: 20, + ..test_client_config() + }, + ); + assert_group_unchanged(&reader, &address, &group).await; + + for _ in 0..3 { + let read = reader + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!( + read.state_id(), + agreed.state_id(), + "a state one peer names is not the network's answer" + ); + } + + // Only three answer, and all three hold the agreed state: corroborated + // three times over, but three answers are not a quorum. + let (_, agreeing) = group.split_at_checked(2).expect("a full close group"); + let three_agreeing = agreeing.get(..3).expect("three agreeing peers"); + let too_few = read_answered_by(&reader, &testnet, &address, three_agreeing).await; + + // Four answer: the two lone states and exactly two peers holding the + // agreed state. Two is enough, and neither lone state is. + let four = group.get(..4).expect("four peers"); + let settled = read_answered_by(&reader, &testnet, &address, four).await; + + match too_few { + Err(Error::CloseGroupShortfall(message)) + if message.contains("only 3 of the close group answered") => {} + other => panic!("three answers must not settle a read, got {other:?}"), + } + let read = settled + .expect("four answers settle a read") + .expect("present"); + assert_eq!( + read.state_id(), + agreed.state_id(), + "two peers agreeing settle the read over two lone states" + ); + + drop(reader); + drop(client); + testnet.teardown().await; +} + +/// Creating a pointer that already exists is refused before anything is +/// paid: the payment would buy nothing, whatever state the network holds. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn creating_a_pointer_that_exists_is_refused_before_paying() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + client + .pointer_create(&sk, &pk, chunk_target(1)) + .await + .expect("create"); + + let before = balance(&client).await; + let err = client + .pointer_create(&sk, &pk, chunk_target(2)) + .await + .expect_err("a second create must be refused"); + assert!(err.to_string().contains("already exists"), "got: {err}"); + assert_eq!(balance(&client).await, before, "the refused create paid"); + + drop(client); + testnet.teardown().await; +} + +/// Re-submitting a state the network already holds is answered as stored +/// without writing anything, so a retry after a timeout cannot lose the update +/// or fork it. +/// +/// "Buys nothing" is literal: the write is still paid for, because the node +/// cannot tell a retry from a fresh submission until it has been paid to look. +/// What the retry cannot do is move the pointer. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn resubmitting_a_stored_state_is_accepted_and_changes_nothing() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + + let address = client + .pointer_create(&sk, &pk, chunk_target(7)) + .await + .expect("create"); + let stored = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + + let before = balance(&client).await; + client + .pointer_put(&stored) + .await + .expect("re-submitting a stored record must not be refused"); + assert!( + balance(&client).await < before, + "a retry pays again: the client cannot know the state is held" + ); + + let after = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(after.state_id(), stored.state_id(), "nothing moved"); + assert_eq!(after.counter(), 0); + + drop(client); + testnet.teardown().await; +} + +/// A pointer may target another pointer. Resolution walks the chain client-side +/// — the node never reads a target — and ends at the chunk. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_chain_of_pointers_resolves_to_the_chunk_at_its_end() { + let (client, testnet) = setup().await; + let (last_pk, last_sk) = owner(); + let (first_pk, first_sk) = owner(); + + let content = Bytes::from("the chunk a pointer chain ends at"); + let chunk = client.chunk_put(content.clone()).await.expect("chunk_put"); + + // The end of the chain points at the chunk. + let last = client + .pointer_create( + &last_sk, + &last_pk, + PointerTarget::new(PointerTargetKind::Chunk, chunk), + ) + .await + .expect("create the last hop"); + + // The head points at that pointer. + let first = client + .pointer_create( + &first_sk, + &first_pk, + PointerTarget::new(PointerTargetKind::Pointer, last), + ) + .await + .expect("create the first hop"); + + let resolved = client + .pointer_resolve(&first) + .await + .expect("the chain must resolve"); + assert_eq!(resolved.kind(), Some(PointerTargetKind::Chunk)); + assert_eq!(resolved.address, chunk); + + let fetched = client + .chunk_get(&resolved.address) + .await + .expect("chunk_get") + .expect("the chunk the chain names must be there"); + assert_eq!(fetched.content.as_ref(), content.as_ref()); + + drop(client); + testnet.teardown().await; +} + +/// Resolution refuses what the node cannot: it never reads a target, so a +/// cycle, a broken chain and an over-long chain are all the client's to stop. +/// The depth limit is checked on both sides of the boundary. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn pointer_resolve_refuses_cycles_broken_chains_and_chains_past_its_depth() { + let (client, testnet) = setup().await; + + // A pointer that targets itself. + let (pk, sk) = owner(); + let own = pointer_address(&pk); + client + .pointer_create( + &sk, + &pk, + PointerTarget::new(PointerTargetKind::Pointer, own), + ) + .await + .expect("create a self-referencing pointer"); + let err = client + .pointer_resolve(&own) + .await + .expect_err("a cycle must not resolve"); + assert!(err.to_string().contains("cycles back"), "got: {err}"); + + // A pointer to a pointer nobody wrote. + let (pk, sk) = owner(); + let (nobody, _) = owner(); + let dangling = client + .pointer_create( + &sk, + &pk, + PointerTarget::new(PointerTargetKind::Pointer, pointer_address(&nobody)), + ) + .await + .expect("create a pointer to nothing"); + let err = client + .pointer_resolve(&dangling) + .await + .expect_err("a broken chain must not resolve"); + assert!(err.to_string().contains("breaks at"), "got: {err}"); + + // One pointer more than the limit. Built from the end, so each head is + // one hop longer than the last. + let chunk = client + .chunk_put(Bytes::from("the chunk at the end of a long chain")) + .await + .expect("chunk_put"); + let mut next = PointerTarget::new(PointerTargetKind::Chunk, chunk); + let mut heads = Vec::with_capacity(MAX_POINTER_RESOLVE_DEPTH + 1); + for _ in 0..=MAX_POINTER_RESOLVE_DEPTH { + let (pk, sk) = owner(); + let head = client + .pointer_create(&sk, &pk, next) + .await + .expect("create a hop"); + heads.push(head); + next = PointerTarget::new(PointerTargetKind::Pointer, head); + } + let mut from_the_longest = heads.iter().rev(); + let too_long = from_the_longest.next().expect("the longest chain"); + let at_the_limit = from_the_longest.next().expect("a chain at the limit"); + + let resolved = client + .pointer_resolve(at_the_limit) + .await + .expect("a chain of exactly the limit resolves"); + assert_eq!(resolved.address, chunk); + let err = client + .pointer_resolve(too_long) + .await + .expect_err("a chain past the limit must not resolve"); + assert!( + err.to_string() + .contains(&format!("longer than {MAX_POINTER_RESOLVE_DEPTH} hops")), + "got: {err}" + ); + + drop(client); + testnet.teardown().await; +} + +/// A pointer that does not exist reads as absent, not as an error. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn an_address_nobody_wrote_reads_as_absent() { + let (client, testnet) = setup().await; + let (pk, _sk) = owner(); + + assert!( + client + .pointer_get(&pointer_address(&pk)) + .await + .expect("a read of an empty address is not an error") + .is_none(), + "nothing was ever written here" + ); + + drop(client); + testnet.teardown().await; +} diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index a8a44aed..f371bfce 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -24,10 +24,12 @@ use ant_node::payment::{ EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, PriceFloorConfig, QuoteGenerator, QuotingMetricsTracker, }; +use ant_node::pointer::{service::PointerService, store::PointerStore}; use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; use ant_node::storage::{AntProtocol, ChunkStore, ChunkStoreConfig, MigrationConfig}; // Wire / transport / EVM types: route through ant-protocol so the test // harness exercises the same surface the client does. +use ant_protocol::chunk::{ChunkMessage, ChunkMessageBody}; use ant_protocol::evm::{testnet::Testnet, Network as EvmNetwork, RewardsAddress, Wallet}; use ant_protocol::pqc::ops::{MlDsaOperations, MlDsaSecretKey}; use ant_protocol::transport::{ @@ -36,6 +38,7 @@ use ant_protocol::transport::{ use ant_protocol::{CLOSE_GROUP_SIZE, MAX_WIRE_MESSAGE_SIZE}; use rand::Rng; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; @@ -94,9 +97,52 @@ pub fn test_client_config() -> ClientConfig { pub struct TestNode { pub p2p_node: Option>, pub protocol: Option>, + /// Which pointer requests the node takes off the wire and ignores. + pub pointer_silence: Arc, _handler_task: Option>, } +/// Switches that make a node ignore pointer requests while staying in the +/// network, as a peer that is up but unreachable for them would. +/// +/// A silenced request is read off the wire and dropped: nothing is stored and +/// nothing is answered, so the sender waits out its own timeout. Everything +/// else the node handles as usual. Lets a test choose how many of a close group +/// a read or a write actually reaches, over the real transport. +/// +/// The decision is taken as each message arrives, before any work is spawned +/// for it, so a request that arrived while its kind was silenced is dropped +/// even if the switch is lifted a moment later. +#[derive(Default)] +pub struct PointerSilence { + /// Ignore pointer GETs. + pub gets: AtomicBool, + /// Ignore pointer PUTs. + pub puts: AtomicBool, +} + +impl PointerSilence { + /// Whether `data` is a pointer request this node is ignoring. + fn drops(&self, data: &[u8]) -> bool { + let gets = self.gets.load(Ordering::Relaxed); + let puts = self.puts.load(Ordering::Relaxed); + if !gets && !puts { + return false; + } + match ChunkMessage::decode(data) { + Ok(ChunkMessage { + body: ChunkMessageBody::PointerGetRequest(_), + .. + }) => gets, + Ok(ChunkMessage { + body: ChunkMessageBody::PointerPutRequest(_), + .. + }) => puts, + _ => false, + } + } +} + pub struct MiniTestnet { pub nodes: Vec, _temp_dirs: Vec, @@ -149,7 +195,7 @@ impl MiniTestnet { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let (node, protocol, handler) = Self::spawn_node( + let (node, protocol, pointer_silence, handler) = Self::spawn_node( addr, &bootstrap_addrs, temp_dir.path(), @@ -163,6 +209,7 @@ impl MiniTestnet { nodes.push(TestNode { p2p_node: Some(Arc::clone(&node)), protocol: Some(protocol), + pointer_silence, _handler_task: Some(handler), }); temp_dirs.push(temp_dir); @@ -175,7 +222,7 @@ impl MiniTestnet { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let (node, protocol, handler) = Self::spawn_node( + let (node, protocol, pointer_silence, handler) = Self::spawn_node( addr, &bootstrap_addrs, temp_dir.path(), @@ -188,6 +235,7 @@ impl MiniTestnet { nodes.push(TestNode { p2p_node: Some(Arc::clone(&node)), protocol: Some(protocol), + pointer_silence, _handler_task: Some(handler), }); temp_dirs.push(temp_dir); @@ -289,7 +337,12 @@ impl MiniTestnet { evm_network: &EvmNetwork, node_index: usize, commitment_key_count: Option, - ) -> (Arc, Arc, tokio::task::JoinHandle<()>) { + ) -> ( + Arc, + Arc, + Arc, + tokio::task::JoinHandle<()>, + ) { // Generate ML-DSA-65 identity for this node let identity = Arc::new(NodeIdentity::generate().expect("generate node identity")); @@ -369,12 +422,22 @@ impl MiniTestnet { .map_or_else(|_| vec![], |sig| sig.as_bytes().to_vec()) }); + // Pointers live beside the chunks under the same root, exactly as a + // real node wires them: without a pointer service the node refuses + // every pointer message, which would make a pointer E2E test prove + // nothing about the code a node actually runs. + let pointer_store = PointerStore::new(data_dir) + .await + .expect("create pointer store"); + let pointers = PointerService::new(pointer_store) + .with_chunk_store(Arc::clone(&storage)) + .with_payments(Arc::clone(&payment_verifier)); + // Create protocol handler - let protocol = Arc::new(AntProtocol::new( - storage, - payment_verifier, - Arc::new(quote_generator), - )); + let protocol = Arc::new( + AntProtocol::new(storage, payment_verifier, Arc::new(quote_generator)) + .with_pointer_service(pointers), + ); // Wire the P2P node into the protocol so direct PUT storage-admission // and payment closeness checks use the node's live DHT view. protocol.attach_p2p_node(Arc::clone(&node)); @@ -390,6 +453,8 @@ impl MiniTestnet { } // Start message handler loop + let pointer_silence = Arc::new(PointerSilence::default()); + let handler_silence = Arc::clone(&pointer_silence); let handler_node = Arc::clone(&node); let handler_protocol = Arc::clone(&protocol); let handler = tokio::spawn(async move { @@ -402,6 +467,10 @@ impl MiniTestnet { data, .. }) => { + if topic == ant_protocol::CHUNK_PROTOCOL_ID && handler_silence.drops(&data) + { + continue; + } let protocol = Arc::clone(&handler_protocol); let node = Arc::clone(&handler_node); let topic_clone = topic.clone(); @@ -446,7 +515,7 @@ impl MiniTestnet { } }); - (node, protocol, handler) + (node, protocol, pointer_silence, handler) } /// Shut down a node by index, simulating a failure. diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index 35b1d493..d1cf4626 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -43,6 +43,13 @@ confirm each result reports the payment mode the shared coordinator used. Private-file tests keep the DataMap record local, then download and range-read through the caller-held DataMap, including one with nested DataMap records. +Pointer tests create, update, read and resolve pointers through the real +client, quorum and payment code against mock nodes that keep pointers with the +node's merge rule, and check that a node which does not advertise +`pointer_protocol` is never asked. The mock serves every channel of one node +from one store, as a real node does, since pointer reads and writes take +different channels. + Download regressions cover known holders omitted by failed discovery, complete discovery failure, bounded fallback, and unchanged BLAKE3 verification. The mock retains successful PUT payloads across connection replacements so a full upload, diff --git a/ant-core/wasm-tests/mock-webrtc.mjs b/ant-core/wasm-tests/mock-webrtc.mjs index 2b49d79f..3e8019fc 100644 --- a/ant-core/wasm-tests/mock-webrtc.mjs +++ b/ant-core/wasm-tests/mock-webrtc.mjs @@ -42,6 +42,8 @@ export function mockWebRtc(nodes = [{}]) { // A real DataChannel copies the outgoing bytes. The mock re-enters WASM // instead, where allocating the server argument can grow memory and // detach an outgoing view into that same memory. Copy before re-entry. + // One node has one store, whichever of its channels wrote to it. + for (const [key, content] of stores[this.index]) this.server.set_record(key, content); let response = this.server.push(new Uint8Array(message)); if (!response.length) return; const method = this.server.last_method(); @@ -50,7 +52,13 @@ export function mockWebRtc(nodes = [{}]) { const content = this.server.stored_record(address); if (content.length) stores[this.index].set(address, content); } - requests.push({ node: this.index, method, ...(method === "put_chunk" ? { + if (method === "put_pointer") { + const key = `pointer:${this.server.last_put_address()}`; + const record = this.server.stored_record(key); + if (record.length) stores[this.index].set(key, record); + } + const put = method === "put_chunk" || method === "put_pointer"; + requests.push({ node: this.index, method, ...(put ? { address: this.server.last_put_address(), quoteHash: this.server.last_put_quote_hash(), } : {}) }); @@ -134,6 +142,7 @@ export function mockWebRtc(nodes = [{}]) { channel.server.set_multiplex(channel.options.multiplex ?? false); channel.server.set_address_v2(channel.options.addressV2 ?? false); channel.server.set_uploads_enabled(channel.options.uploads ?? true); + channel.server.set_pointers_enabled(channel.options.pointers ?? true); if (channel.options.payment) channel.server.set_hello_payment(channel.options.payment); channel.server.set_invalid_quote(channel.options.invalidQuote ?? false); channel.server.set_committed_key_count(channel.options.keyCount ?? 0); diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs new file mode 100644 index 00000000..f9d56895 --- /dev/null +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -0,0 +1,176 @@ +// Pointers from the browser (ADR-0016): the real WASM client, quorum and +// payment code over the mock transport, against mock nodes that keep pointers +// with the node's merge rule. +import { BrowserNetworkClient } from "./client-fixture.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { pointerAddress } from "./pkg/ant_core.js"; +import { mockWebRtc, paymentNetwork } from "./mock-webrtc.mjs"; + +const seed = new Uint8Array(32).fill(7); +const target = (byte) => byte.toString(16).padStart(2, "0").repeat(32); + +function wallet() { + const calls = []; + return { + calls, + async pay(network, quotes) { + calls.push({ network, quotes }); + return { + transactionHash: `0x${"ab".repeat(32)}`, + totalAmount: quotes.reduce((sum, quote) => sum + BigInt(quote.amount), 0n).toString(), + }; + }, + }; +} + +const puts = (rtc) => rtc.requests.filter(({ method }) => method === "put_pointer"); + +test("a created pointer reads back from the close group", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + const written = await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay); + assert.equal(signer.calls.length, 1, "one state, one payment"); + assert.equal(signer.calls[0].quotes.length, 1); + assert.equal(written.pointer.address, pointerAddress(seed)); + assert.equal(written.pointer.counter, "0"); + assert.equal(written.storageCostAtto, signer.calls[0].quotes[0].amount); + assert.ok(puts(rtc).length >= 5, "a pointer write reaches a majority plus one"); + + const read = await client.getPointer(written.pointer.address); + assert.deepEqual(read, written.pointer); + assert.equal(read.kind, "chunk"); + assert.equal(read.target, target(1)); + } finally { + client.close(); + } +}); + +test("an update signs one past what the network serves and replaces it", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + const created = await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay); + const updated = await client.updatePointer(seed, target(2), "chunk", paymentNetwork, signer.pay); + assert.equal(updated.pointer.counter, "1"); + assert.notEqual(updated.pointer.stateId, created.pointer.stateId); + assert.equal(signer.calls.length, 2, "each state is paid for"); + + const read = await client.getPointer(created.pointer.address); + assert.equal(read.counter, "1"); + assert.equal(read.target, target(2)); + } finally { + client.close(); + } +}); + +test("an update of a pointer nobody created creates it", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + try { + const written = await client.updatePointer(seed, target(3), "chunk", paymentNetwork, wallet().pay); + assert.equal(written.pointer.counter, "0"); + } finally { + client.close(); + } +}); + +test("a chain of pointers resolves to the chunk at its end", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + const inner = new Uint8Array(32).fill(8); + try { + const last = await client.createPointer(inner, target(9), "chunk", paymentNetwork, signer.pay); + const first = await client.createPointer(seed, last.pointer.address, "pointer", paymentNetwork, signer.pay); + assert.equal(first.pointer.kind, "pointer"); + + const resolved = await client.resolvePointer(first.pointer.address); + assert.deepEqual(resolved, { kind: "chunk", kindTag: 0, target: target(9) }); + } finally { + client.close(); + } +}); + +test("an address nobody wrote reads as null", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + try { + assert.equal(await client.getPointer(target(0x42)), null); + } finally { + client.close(); + } +}); + +test("a node that does not advertise pointers is never sent one", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, (_, i) => ({ pointers: i !== 0 }))); + const client = new BrowserNetworkClient(rtc.endpoints); + try { + const written = await client.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay); + await client.getPointer(written.pointer.address); + const asked = rtc.requests.filter(({ method }) => method.endsWith("_pointer")); + assert.ok(asked.length > 0); + assert.ok(asked.every(({ node }) => node !== 0), "the mock node asserts it too"); + } finally { + client.close(); + } +}); + +test("an owner seed of the wrong length is refused before anything is paid", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + await assert.rejects( + client.createPointer(new Uint8Array(31), target(1), "chunk", paymentNetwork, signer.pay), + /owner seed must be 32 bytes/, + ); + assert.throws(() => pointerAddress(new Uint8Array(33)), /owner seed must be 32 bytes/); + await assert.rejects( + client.createPointer(seed, target(1), "file", paymentNetwork, signer.pay), + /unknown pointer target kind/, + ); + assert.equal(signer.calls.length, 0); + } finally { + client.close(); + } +}); + +test("creating a pointer that exists is refused before anything is paid", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay); + await assert.rejects( + client.createPointer(seed, target(2), "chunk", paymentNetwork, signer.pay), + /already exists at counter 0; update it instead/, + ); + assert.equal(signer.calls.length, 1, "the refused create paid nothing"); + } finally { + client.close(); + } +}); + +test("a paid state handed to onPaid is stored again without paying", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + let paid; + try { + const written = await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, + value => { paid = value; }); + assert.ok(paid.record instanceof Uint8Array && paid.proof instanceof Uint8Array); + const before = puts(rtc).length; + + const stored = await client.storePaidPointer(paid.record, paid.proof, paymentNetwork); + assert.deepEqual(stored, written.pointer); + assert.equal(signer.calls.length, 1, "storing a paid state pays nothing"); + assert.ok(puts(rtc).length > before, "and it is sent again"); + } finally { + client.close(); + } +});