From d120dbf5da6387c37c5000b3cbd8e603f03a03f3 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 29 Sep 2026 16:55:48 +0900 Subject: [PATCH 01/11] feat(pointer): hand a pointer over for good, and read forks by majority A pointer's owner key never changes, but the pointer can now be handed over for good (ADR-0018 in ant-node). The owner signs its final state, at u64::MAX, pointing at a pointer the new owner holds the key to. Readers of the address are redirected there and the address stays the same, and the former owner's key can change nothing: ant-protocol now replaces a final state with nothing. pointer_transfer refuses before paying when a transfer could not land well -- the pointer is already final, the recipient is the pointer itself, does not exist, or leads back here -- then signs, pays, stores and reports the group's finality. pointer_finality asks every peer rather than stopping at a quorum, so a rival final state held by even one peer is seen, and answers open, settling, final or forked; final is what a recipient checks before relying on a transfer. pointer_controller follows transfers to the pointer that now decides. Two final states are unordered, so the merge rule cannot choose between them: each node keeps the one it took first. A read that meets a final state is therefore settled only once a final state is held by a majority of the group, and returns that one; two with no majority fail as PointerForked rather than guess. One uncontested final state short of a majority is returned once corroborated, as any state is. Updating a final pointer is refused as PointerFinal before anything is paid. Also: ant pointer transfer / finality / controller, transferPointer and pointerFinality in the browser, and the ant-protocol and ant-node pins moved to the companion branches. --- CHANGELOG.md | 17 + CLAUDE.md | 4 +- Cargo.lock | 6 +- Cargo.toml | 6 + README.md | 41 +- ant-cli/src/commands/data/pointer.rs | 182 ++++- ant-core/Cargo.toml | 4 +- .../src/browser/wasm_transport/pointer.rs | 138 +++- ant-core/src/data/client/mod.rs | 9 + ant-core/src/data/client/pointer.rs | 773 +++++++++++++++++- ant-core/src/data/error.rs | 16 + ant-core/src/data/mod.rs | 5 +- ant-core/tests/e2e_pointer.rs | 266 +++++- ant-core/wasm-tests/pointer.test.mjs | 68 ++ 14 files changed, 1506 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b78e32..25017134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Pointer ownership transfer (ADR-0018 in `ant-node`). `ant pointer transfer --key + ` signs the pointer's final state, at counter `u64::MAX`, pointing at the new + owner's pointer: readers of the address are redirected there, the address stays the same, and + the former owner's key can change nothing, because a final state is replaced by nothing. It is + refused before paying if the pointer is already final, or if the recipient does not exist or + leads back. `ant pointer finality
` asks the whole close group whether a pointer is + `open`, `settling`, `final` or `forked`, which is what a recipient checks before relying on a + transfer, and `ant pointer controller
` follows transfers to the pointer that now + decides. In `ant-core`: `Client::pointer_transfer`, `pointer_sign_transfer`, + `pointer_finality` and `pointer_controller`, and the new `Error::PointerFinal` and + `Error::PointerForked`. In the browser: `transferPointer` and `pointerFinality`. + - `ant` is distributed on npm as `@withautonomi/ant`, so `npm install -g @withautonomi/ant` works in agent sandboxes and CI runners that allow package-manager traffic but block direct binary downloads (V2-1152). Packaging only: the published packages carry the same signed @@ -27,6 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 run. ### Changed +- A pointer read that meets a final state returns the final state a majority of the close group + holds, rather than the merge winner, and asks the whole group until one is found. Two + different final states are a fork only the owner can make, by racing them to different nodes; + with no majority on either, the read fails with `Error::PointerForked` instead of guessing. + Updating a final pointer is refused with `Error::PointerFinal`, before anything is paid. - `ant update` no longer replaces the binary when `ant` was installed by a package manager. It still reports whether a newer version is available, then prints the command that will install it (`npm update -g @withautonomi/ant`). Self-replacing an npm-managed binary left npm's diff --git a/CLAUDE.md b/CLAUDE.md index af74d494..449a0342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +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) +│ ├── pointer.rs # pointer_create/update/get/resolve (ADR-0016), transfer/finality (ADR-0018) │ └── cache.rs # In-memory LRU chunk cache │ └── manifest/ # File manifests and ant:// links (ADR-0006) │ ├── mod.rs # Manifest, ManifestEntry, ContentRef, encode/decode, limits @@ -120,7 +120,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 + │ ├── pointer.rs # ant pointer keygen/address/create/update/get/resolve/transfer/finality/controller │ ├── manifest.rs # ant manifest create/list/show/link/export/download │ └── wallet.rs # ant wallet address/balance └── node/ diff --git a/Cargo.lock b/Cargo.lock index 9da2f580..0a4e1a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,8 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afebae6037bc8d219f3089305cad8803bde439484aa9d00979486c5ea554271" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=e582b24b879f7e64b21420158c0cb2a71ef5a122#e582b24b879f7e64b21420158c0cb2a71ef5a122" dependencies = [ "ant-protocol", "bao", @@ -966,8 +965,7 @@ dependencies = [ [[package]] name = "ant-protocol" version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb1f2e060c4283457a718c094d5fd594efd1d4eeec7cc6ca3ea113df17cce382" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=7aab0772aed9931ce7912eeda23b1f8482d6d116#7aab0772aed9931ce7912eeda23b1f8482d6d116" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index ec8f5ce5..fc1f49f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,9 @@ members = ["ant-core", "ant-cli"] resolver = "2" +[patch.crates-io] +# The published ant-protocol 3.1.0 has pointers (ADR-0016) but not the final +# state that ownership transfer relies on (ADR-0018 in ant-node). Pin the +# companion revision until a release includes it. Versions are bumped at +# release; this immutable revision remains 3.1.0. +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "7aab0772aed9931ce7912eeda23b1f8482d6d116" } diff --git a/README.md b/README.md index ea1a7ade..45943a40 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ 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. +without paying again. `transferPointer` hands a pointer over for good and +`pointerFinality` asks the whole close group whether it is final (see +`ant pointer transfer` below). 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; @@ -309,6 +311,11 @@ 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. +The key cannot change hands, but the pointer can: `ant pointer transfer` signs +its final state, pointing at a pointer the new owner holds the key to. Readers +of the address are redirected there, the address stays the same, and the +former owner's key can change nothing any more (ADR-0018 in `ant-node`). + #### `ant pointer keygen --output ` Create a new owner key and print the address of the pointer it controls. Never @@ -351,6 +358,38 @@ state_id: 5e7d... Follow a chain of pointers to the target at its end. +#### `ant pointer transfer --key ` + +Hand the pointer over for good to the pointer at `RECIPIENT` (hex), which the +new owner creates first, with a fresh key for each pointer they receive. +Requires `SECRET_KEY` to pay for the final state. Refused before paying if the +pointer is already final, or if `RECIPIENT` does not exist or leads back to +this pointer. Irreversible once stored. Prints the result of `finality`. + +#### `ant pointer finality
` + +Ask every peer of the close group whether a pointer is final, and on what: + +- `open` — not final; its owner can still move it. +- `settling` — one final state, not yet on a majority of the group. +- `final` — one final state, held by a majority, with no rival. The only + status to rely on before treating a transfer as done. +- `forked` — its owner signed more than one final state. Reads return the one a + majority holds, or fail if none does. + +``` +$ ant pointer finality 9f3c... +address: 9f3c... +answered: 7 of 7 +status: final +state: pointer 4b1e... (state 77a0..., held by 7 of 7) +``` + +#### `ant pointer controller
` + +Follow a pointer's transfers to the pointer whose owner now decides what it +resolves to. + ### `ant wallet` — Wallet Operations Inspect the EVM wallet derived from `SECRET_KEY`. diff --git a/ant-cli/src/commands/data/pointer.rs b/ant-cli/src/commands/data/pointer.rs index 2045c3ee..0607ad08 100644 --- a/ant-cli/src/commands/data/pointer.rs +++ b/ant-cli/src/commands/data/pointer.rs @@ -3,14 +3,19 @@ //! 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. +//! +//! The key never changes, but what the pointer's address resolves to can be +//! handed over for good (ADR-0018 in `ant-node`): `transfer` signs the +//! pointer's final state, pointing at the recipient's pointer, and `finality` +//! is how the recipient checks it landed before relying on it. 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, + ml_dsa_65, pointer_address, Client, FinalState, FinalityStatus, MlDsaPublicKey, MlDsaSecretKey, + Pointer, PointerFinality, PointerTarget, PointerTargetKind, XorName, }; use clap::{Subcommand, ValueEnum}; use rand::rngs::OsRng; @@ -28,7 +33,8 @@ const SEED_LEN: usize = 32; pub enum TargetKind { /// A chunk, such as a file's data map. Chunk, - /// Another pointer, which is how a pointer is handed over. + /// Another pointer. A forwarding its owner can still change; use + /// `transfer` to hand a pointer over for good. Pointer, } @@ -44,7 +50,8 @@ impl From for PointerTargetKind { /// Pointer subcommands. #[derive(Subcommand, Debug)] pub enum PointerAction { - /// Create a new owner key. A pointer's owner can never change, so keep it. + /// Create a new owner key. A pointer's owner key can never change, so keep + /// it. Use a fresh key for each pointer you receive by transfer. Keygen { /// File to write the key to. Refuses to overwrite an existing file. #[arg(long, short)] @@ -80,6 +87,36 @@ pub enum PointerAction { #[arg(long, value_enum, default_value = "chunk")] kind: TargetKind, }, + /// Hand the pointer a key owns over to the pointer at RECIPIENT, for good. + /// Pays for the final state. + /// + /// Readers of the pointer's address are redirected to RECIPIENT, which + /// only its owner can move, and this key can change nothing any more. The + /// address stays the same. Irreversible once stored. + /// + /// Refused, before paying, if the pointer is already final, if RECIPIENT + /// does not exist, or if RECIPIENT leads back to this pointer. + Transfer { + /// Owner key file. + #[arg(long, short)] + key: PathBuf, + /// Hex-encoded address of the recipient's pointer (64 hex chars). + recipient: String, + }, + /// Ask the whole close group whether a pointer is final, and on what. + /// + /// Check this before relying on a transfer: only `final` means one final + /// state, held by a majority of the group, with no rival. + Finality { + /// Hex-encoded pointer address (64 hex chars). + address: String, + }, + /// Follow a pointer's transfers to the pointer whose owner now decides + /// what it resolves to. + Controller { + /// Hex-encoded pointer address (64 hex chars). + address: String, + }, /// Read a pointer. Get { /// Hex-encoded pointer address (64 hex chars). @@ -95,7 +132,10 @@ pub enum PointerAction { impl PointerAction { /// Whether this action pays, and so needs a wallet. pub fn needs_wallet(&self) -> bool { - matches!(self, Self::Create { .. } | Self::Update { .. }) + matches!( + self, + Self::Create { .. } | Self::Update { .. } | Self::Transfer { .. } + ) } /// Whether this action talks to the network at all. @@ -160,6 +200,52 @@ impl PointerAction { .map_err(|e| anyhow::anyhow!("Pointer update failed: {e}"))?; print_address(&address, json); } + Self::Transfer { key, recipient } => { + let (owner, secret) = read_key(&key)?; + let recipient = parse_address(&recipient)?; + info!("Transferring pointer"); + let finality = client + .pointer_transfer(&secret, &owner, recipient) + .await + .map_err(|e| anyhow::anyhow!("Pointer transfer failed: {e}"))?; + print_finality(&finality, json); + } + Self::Finality { address } => { + let at = parse_address(&address)?; + let finality = client + .pointer_finality(&at) + .await + .map_err(|e| anyhow::anyhow!("Pointer finality check failed: {e}"))?; + print_finality(&finality, json); + } + Self::Controller { address } => { + let at = parse_address(&address)?; + let controller = client + .pointer_controller(&at) + .await + .map_err(|e| anyhow::anyhow!("Pointer controller lookup failed: {e}"))?; + let pointer = hex::encode(controller.pointer); + let transfers: Vec = controller.transfers.iter().map(hex::encode).collect(); + if json { + println!( + "{}", + json!({ + "pointer": pointer, + "transfers": transfers, + "frozen": controller.frozen, + }) + ); + } else { + for hop in &transfers { + println!("transferred to: {hop}"); + } + if controller.frozen { + println!("frozen: {pointer} (final; nobody can move it)"); + } else { + println!("controller: {pointer}"); + } + } + } Self::Get { address } => { let at = parse_address(&address)?; let record = client @@ -225,6 +311,86 @@ fn print_record(record: &Pointer, json: bool) { } } +/// One final state as JSON. +fn final_state_json(state: &FinalState) -> serde_json::Value { + let (kind, target) = + describe_target(&PointerTarget::from_raw_tag(state.kind_tag, state.target)); + json!({ + "state_id": hex::encode(state.state_id), + "kind": kind, + "target": target, + "holders": state.holders, + "transferred_to": state.transferred_to().map(hex::encode), + }) +} + +/// One final state as a line of text. +fn final_state_line(state: &FinalState, group: usize) -> String { + let (kind, target) = + describe_target(&PointerTarget::from_raw_tag(state.kind_tag, state.target)); + let holders = state.holders; + let id = hex::encode(state.state_id); + format!("{kind} {target} (state {id}, held by {holders} of {group})") +} + +fn print_finality(finality: &PointerFinality, json: bool) { + let address = hex::encode(finality.address); + let group = finality.group; + let answered = finality.answered; + if json { + let status = match &finality.status { + FinalityStatus::Open { counter } => json!({ "status": "open", "counter": counter }), + FinalityStatus::Settling(state) => { + json!({ "status": "settling", "state": final_state_json(state) }) + } + FinalityStatus::Final(state) => { + json!({ "status": "final", "state": final_state_json(state) }) + } + FinalityStatus::Forked { states, majority } => json!({ + "status": "forked", + "states": states.iter().map(final_state_json).collect::>(), + "majority": majority.as_ref().map(final_state_json), + }), + }; + println!( + "{}", + json!({ + "address": address, + "group": group, + "answered": answered, + "finality": status, + }) + ); + return; + } + println!("address: {address}"); + println!("answered: {answered} of {group}"); + match &finality.status { + FinalityStatus::Open { counter } => match counter { + Some(counter) => println!("status: open (counter {counter}; its owner can move it)"), + None => println!("status: open (no state held)"), + }, + FinalityStatus::Settling(state) => { + println!("status: settling (final, but not yet on a majority of the group)"); + println!("state: {}", final_state_line(state, group)); + } + FinalityStatus::Final(state) => { + println!("status: final"); + println!("state: {}", final_state_line(state, group)); + } + FinalityStatus::Forked { states, majority } => { + println!("status: forked (its owner signed more than one final state)"); + for state in states { + println!("state: {}", final_state_line(state, group)); + } + match majority { + Some(state) => println!("reads: {}", final_state_line(state, group)), + None => println!("reads: fail (no state is held by a majority)"), + } + } + } +} + /// 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) { @@ -355,6 +521,12 @@ mod tests { assert!(!get.needs_wallet() && get.needs_network()); let resolve = parse(&["t", "resolve", &target]); assert!(!resolve.needs_wallet() && resolve.needs_network()); + let transfer = parse(&["t", "transfer", "--key", key, &target]); + assert!(transfer.needs_wallet() && transfer.needs_network()); + let finality = parse(&["t", "finality", &target]); + assert!(!finality.needs_wallet() && finality.needs_network()); + let controller = parse(&["t", "controller", &target]); + assert!(!controller.needs_wallet() && controller.needs_network()); let keygen = parse(&["t", "keygen", "--output", key]); assert!(!keygen.needs_wallet() && !keygen.needs_network()); let address = parse(&["t", "address", "--key", key]); diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 8abc470c..fdfd4c5a 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "e582b24b879f7e64b21420158c0cb2a71ef5a122", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "e582b24b879f7e64b21420158c0cb2a71ef5a122", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 2eb72557..1ee3ea31 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -1,8 +1,9 @@ // 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. +//! Pointers from the browser (ADR-0016 in `ant-node`): read, resolve, create, +//! update, and hand over for good (ADR-0018), 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 @@ -14,8 +15,8 @@ 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, + ml_dsa_65, pointer_address, FinalState, FinalityStatus, MlDsaPublicKey, MlDsaSecretKey, + Pointer, PointerFinality, PointerTarget, PointerTargetKind, }; use ant_protocol::evm::{QuoteHash, TxHash}; @@ -78,6 +79,80 @@ struct BrowserPointerWrite { storage_cost_atto: String, } +/// A final state as JavaScript sees it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserFinalState { + state_id: String, + kind: String, + kind_tag: u8, + target: String, + holders: usize, + #[serde(skip_serializing_if = "Option::is_none")] + transferred_to: Option, +} + +impl From<&FinalState> for BrowserFinalState { + fn from(state: &FinalState) -> Self { + let target = PointerTarget::from_raw_tag(state.kind_tag, state.target); + Self { + state_id: hex::encode(state.state_id), + kind: kind_name(&target).to_string(), + kind_tag: state.kind_tag, + target: hex::encode(state.target), + holders: state.holders, + transferred_to: state.transferred_to().map(hex::encode), + } + } +} + +/// Where a pointer stands with respect to finality, as JavaScript sees it. +/// +/// `status` is `"open"`, `"settling"`, `"final"` or `"forked"`. `states` holds +/// every final state seen, most-held first; `majority` the one reads return. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserPointerFinality { + address: String, + group: usize, + answered: usize, + status: &'static str, + /// Decimal, for an open pointer: a counter can exceed JavaScript's safe + /// integers. + #[serde(skip_serializing_if = "Option::is_none")] + counter: Option, + states: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + majority: Option, +} + +impl From<&PointerFinality> for BrowserPointerFinality { + fn from(finality: &PointerFinality) -> Self { + let (status, counter, states, majority) = match &finality.status { + FinalityStatus::Open { counter } => { + ("open", counter.map(|c| c.to_string()), Vec::new(), None) + } + FinalityStatus::Settling(state) => ("settling", None, vec![state.into()], None), + FinalityStatus::Final(state) => ("final", None, vec![state.into()], Some(state.into())), + FinalityStatus::Forked { states, majority } => ( + "forked", + None, + states.iter().map(BrowserFinalState::from).collect(), + majority.as_ref().map(BrowserFinalState::from), + ), + }; + Self { + address: hex::encode(finality.address), + group: finality.group, + answered: finality.answered, + status, + counter, + states, + majority, + } + } +} + /// 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. @@ -222,6 +297,54 @@ impl BrowserNetworkClient { to_js(&written) } + /// Hand the pointer `owner_seed` controls over to the pointer at + /// `recipient`, for good, and pay for the final state. `on_paid` is as for + /// [`Self::create_pointer`]. + /// + /// Readers of the pointer's address are redirected to `recipient`, which + /// only its owner can move, and `owner_seed` can change nothing any more. + /// Refused before anything is paid if the pointer is already final, or if + /// `recipient` does not exist or leads back to this pointer. Check the + /// result with [`Self::pointer_finality`] before relying on it. + #[wasm_bindgen(js_name = transferPointer)] + pub async fn transfer_pointer( + &self, + owner_seed: &[u8], + recipient: &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::Transfer, + owner_seed, + recipient, + "pointer", + &payment_network, + &pay_for_quotes, + on_paid.as_ref(), + ) + .await + .map_err(|error| JsValue::from_str(&error))?; + to_js(&written) + } + + /// Ask the whole close group whether the pointer at `address` is final, + /// and on what. Only `status: "final"` means one final state, held by a + /// majority of the group, with no rival. + #[wasm_bindgen(js_name = pointerFinality)] + pub async fn pointer_finality(&self, address: &str) -> Result { + let at = parse_lookup_key(address, "pointer address").map_err(|e| JsValue::from_str(&e))?; + let finality = self + .shared + .pointer_finality(&at) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + to_js(&BrowserPointerFinality::from(&finality)) + } + /// 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)] @@ -249,6 +372,8 @@ enum PointerWrite { Create, /// One past what the network serves, or a creation. Update, + /// The final state, handing the pointer over to the target pointer. + Transfer, } impl BrowserNetworkClient { @@ -269,6 +394,11 @@ impl BrowserNetworkClient { let record = match write { PointerWrite::Create => client.pointer_sign_create(&secret, &owner, target).await, PointerWrite::Update => client.pointer_sign_update(&secret, &owner, target).await, + PointerWrite::Transfer => { + client + .pointer_sign_transfer(&secret, &owner, target.address) + .await + } } .map_err(|error| error.to_string())?; self.store_pointer(&client, &record, payment_network, pay_for_quotes, on_paid) diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index 818a6d82..65c29a7a 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -303,6 +303,8 @@ mod settlement_refusal_tests { /// - `RemotePut` -> `ApplicationError` (the remote node responded with a /// structured rejection — the transport succeeded, so the node declined /// at the application layer; not a local capacity signal) +/// - `PointerFinal`, `PointerForked` -> `ApplicationError` (a verdict about +/// one pointer's state, read over a working link) /// - `ClientUpdateRequired` -> `ApplicationError` (the storer refused to quote /// a client that settles under superseded rules — a terminal verdict about /// this build, not about link capacity, and no retry rate clears it) @@ -341,6 +343,11 @@ pub(crate) fn classify_error(err: &Error) -> Outcome { // run on the basis of a fault no retry can clear. | Error::ClientUpdateRequired(_) | Error::StorerUpdateRequired(_) + // A pointer that is final, or whose owner forked it, is a verdict + // about that pointer read over a working link. No retry rate changes + // it. + | Error::PointerFinal(_) + | Error::PointerForked(_) | Error::BadQuoteBinding { .. } | Error::BadQuoteCommitment { .. } // An external-signer merkle batch larger than one tree can hold — @@ -1251,6 +1258,8 @@ mod tests { | Error::RemotePut { .. } | Error::ClientUpdateRequired(_) | Error::StorerUpdateRequired(_) + | Error::PointerFinal(_) + | Error::PointerForked(_) | Error::CloseGroupShortfall(_) => (), }; } diff --git a/ant-core/src/data/client/pointer.rs b/ant-core/src/data/client/pointer.rs index 94c38b11..50608395 100644 --- a/ant-core/src/data/client/pointer.rs +++ b/ant-core/src/data/client/pointer.rs @@ -19,7 +19,16 @@ //! - **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`]. - +//! - **Deciding between final states.** A state at the final counter is +//! replaced by nothing, which is what lets an owner hand a pointer over for +//! good ([`Client::pointer_transfer`], ADR-0018 in `ant-node`). Two different +//! final states are the one fork left, made only by an owner racing them to +//! different nodes, and each node keeps the one it took first. A read that +//! meets a final state returns the one a majority of the close group holds, +//! and reports a fork when none does; [`Client::pointer_finality`] asks the +//! whole group, so a recipient can check a transfer before relying on it. + +use std::cmp::Reverse; use std::collections::HashSet; use std::future::Future; @@ -28,13 +37,15 @@ use ant_protocol::chunk::{ PointerPutResponse, }; use ant_protocol::pointer::{ - pointer_address, Pointer, PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, POINTER_WIRE_LEN, + pointer_address, Pointer, PointerError, PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, + FINAL_COUNTER, 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 serde::{Deserialize, Serialize}; use tracing::warn; use web_time::Duration; @@ -54,6 +65,9 @@ use crate::runtime::sleep; /// 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. +/// +/// It is also the bare majority of the group, which is what decides between +/// two final states: at most one can be held by a majority. fn read_quorum(peers: usize) -> usize { (peers / 2) + 1 } @@ -209,6 +223,38 @@ impl Replies { !self.seen.is_empty() } + /// The distinct final states named, with how many peers named each. + fn finals(&self) -> impl Iterator { + self.seen.iter().filter(|(record, _)| record.is_terminal()) + } + + /// The final state at least `majority` peers named. At most one can be, + /// so long as `majority` is a majority of the group. + fn final_majority(&self, majority: usize) -> Option<&Pointer> { + self.finals() + .find(|(_, count)| *count >= majority) + .map(|(record, _)| record) + } + + /// What the tally says the pointer holds. + /// + /// Below the final counter, the best corroborated state. Once a final state + /// has been named the merge rule no longer answers on its own: two + /// different final states are unordered, and each node keeps whichever it + /// took first. So the one a majority of the group holds is the answer, as + /// it is the one most nodes accepted first. Without a majority, two final + /// states are a fork with no answer, and one final state is taken as any + /// state is — once corroborated — since nothing contests it. + fn chosen(&self, needed: usize, majority: usize) -> Chosen<'_> { + if let Some(held) = self.final_majority(majority) { + return Chosen::State(Some(held)); + } + if self.finals().nth(1).is_some() { + return Chosen::Forked; + } + Chosen::State(self.corroborated(needed)) + } + /// Whether what has been heard settles the read. /// /// Answers alone settle a read that found nothing. A state is settled only @@ -224,7 +270,16 @@ impl Replies { /// the older state stand. The newer state still has to be corroborated to /// be returned, so no single peer decides the answer; one that names a /// state nobody else holds only makes the read ask everyone. - fn settled(&self, needed: usize) -> bool { + /// + /// A final state changes what settles a read. Nothing replaces it, but a + /// different final state need not replace it to win: the one most of the + /// group holds does. So once any final state has been named, the read is + /// settled only by a final state a `majority` of the group has named, and + /// otherwise asks everyone, so a fork is seen rather than guessed past. + fn settled(&self, needed: usize, majority: usize) -> bool { + if self.finals().next().is_some() { + return self.final_majority(majority).is_some(); + } match self.corroborated(needed) { None => !self.any(), Some(best) => !self @@ -235,6 +290,15 @@ impl Replies { } } +/// What a read's tally settles on (see [`Replies::chosen`]). +#[derive(Debug)] +enum Chosen<'a> { + /// This state, or none at all. + State(Option<&'a Pointer>), + /// Two or more different final states, none held by a majority. + Forked, +} + /// Ask a close group of `peers` for a pointer, tallying the states they name. /// /// Ends once a read quorum has answered and the tally is settled (see @@ -244,15 +308,188 @@ async fn collect_read( peers: usize, ) -> (Answered, Replies) { let needed = corroboration(peers); + let majority = read_quorum(peers); let mut replies = Replies::default(); let answered = ask_the_group(in_flight, read_quorum(peers), |found| { replies.add(found); - replies.settled(needed) + replies.settled(needed, majority) }) .await; (answered, replies) } +/// A final state, as [`Client::pointer_finality`] reports it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinalState { + /// The state's identifier. + pub state_id: XorName, + /// The target's raw kind tag. + pub kind_tag: u8, + /// The target's address. + pub target: XorName, + /// How many peers of the close group hold it. + pub holders: usize, +} + +impl FinalState { + fn of(record: &Pointer, holders: usize) -> Self { + let target = record.target(); + Self { + state_id: record.state_id(), + kind_tag: target.kind_tag(), + target: target.address, + holders, + } + } + + /// The pointer this state hands the address over to, if it is a transfer + /// rather than a freeze. + #[must_use] + pub fn transferred_to(&self) -> Option { + match PointerTargetKind::from_tag(self.kind_tag) { + Some(PointerTargetKind::Pointer) => Some(self.target), + _ => None, + } + } +} + +/// Where a pointer stands with respect to finality, as the whole close group +/// reports it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum FinalityStatus { + /// No peer holds a final state: the owner can still move the pointer. + /// `counter` is the corroborated state's, or `None` if no state is. + Open { + /// The counter of the state a read would return. + counter: Option, + }, + /// One final state, and no other, but a majority of the group does not + /// hold it yet: a transfer still spreading, or a write that fell short. + Settling(FinalState), + /// One final state, held by a majority of the group, and no other seen. + /// Nothing can move the pointer off it. + Final(FinalState), + /// The owner signed two or more different final states. `majority` is + /// the one a majority holds, which reads return; with none, reads fail. + Forked { + /// Every final state seen, most-held first. + states: Vec, + /// The state a majority of the group holds, if any. + majority: Option, + }, +} + +/// The answer to [`Client::pointer_finality`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PointerFinality { + /// The pointer asked about. + pub address: XorName, + /// How many peers were asked: the whole close group. + pub group: usize, + /// How many of them answered. + pub answered: usize, + /// What they said. + pub status: FinalityStatus, +} + +impl PointerFinality { + /// Whether the pointer is final beyond dispute: one final state, held by a + /// majority of the group, with no rival seen. + #[must_use] + pub fn is_final(&self) -> bool { + matches!(self.status, FinalityStatus::Final(_)) + } + + /// The pointer this one was handed over to, once that is beyond dispute. + #[must_use] + pub fn transferred_to(&self) -> Option { + match &self.status { + FinalityStatus::Final(state) => state.transferred_to(), + _ => None, + } + } +} + +/// Whose pointer decides what an address resolves to, after following every +/// transfer (see [`Client::pointer_controller`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PointerController { + /// The pointer whose owner decides: the address asked about, or the last + /// pointer it was handed over to. + pub pointer: XorName, + /// Every pointer the address was handed over to, in order. + pub transfers: Vec, + /// Whether that pointer is final without being handed over: nobody can + /// move it any more. + pub frozen: bool, +} + +/// A final state's description for an error: its identifier and target. +fn describe_final(record: &Pointer) -> String { + let target = record.target(); + let kind = match target.kind() { + Some(PointerTargetKind::Pointer) => "pointer", + Some(PointerTargetKind::Chunk) => "chunk", + None => "unknown kind", + }; + format!( + "final state {} targeting {kind} {}", + hex::encode(record.state_id()), + hex::encode(target.address) + ) +} + +/// The error for a transfer the network refused because the pointer is +/// already final on another state, if that is what `finality` shows. +fn rival_final(finality: &PointerFinality, ours: &Pointer) -> Option { + let ours = ours.state_id(); + let address = hex::encode(finality.address); + match &finality.status { + FinalityStatus::Open { .. } => None, + FinalityStatus::Settling(state) | FinalityStatus::Final(state) => (state.state_id != ours) + .then(|| { + Error::PointerFinal(format!( + "pointer {address} is already final on state {} held by {} of {} peers", + hex::encode(state.state_id), + state.holders, + finality.group + )) + }), + FinalityStatus::Forked { states, .. } => Some(Error::PointerForked(format!( + "pointer {address} holds {} different final states", + states.len() + ))), + } +} + +/// What a finality check makes of a tally over the whole group. +fn finality_status(replies: &Replies, group: usize) -> FinalityStatus { + let majority = read_quorum(group); + let mut finals: Vec = replies + .finals() + .map(|(record, count)| FinalState::of(record, *count)) + .collect(); + finals.sort_by_key(|state| Reverse(state.holders)); + let held_by_majority = finals + .iter() + .find(|state| state.holders >= majority) + .cloned(); + match finals.as_slice() { + [] => FinalityStatus::Open { + counter: replies + .corroborated(corroboration(group)) + .map(Pointer::counter), + }, + [only] if only.holders >= majority => FinalityStatus::Final(only.clone()), + [only] => FinalityStatus::Settling(only.clone()), + _ => FinalityStatus::Forked { + states: finals, + majority: held_by_majority, + }, + } +} + /// What one peer's acknowledgement of a pointer PUT says. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PutAck { @@ -506,14 +743,247 @@ impl Client { ) -> 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}"))), + Some(current) => current.update(secret_key, target).map_err(|e| match e { + PointerError::CounterExhausted => Error::PointerFinal(format!( + "pointer {} holds {}; nothing can update it", + hex::encode(address), + describe_final(¤t) + )), + 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}"))), } } + /// Hand the pointer owned by `owner` over to the pointer at `recipient`, + /// for good, and report where the transfer stands. + /// + /// Signs the pointer's final state, pointing at `recipient`, pays for it + /// and stores it (ADR-0018 in `ant-node`). A final state is replaced by + /// nothing, so from then on every reader of this address is redirected to + /// the recipient's pointer, which only its owner can move, and this key + /// can change nothing. The address readers use stays the same. + /// + /// The recipient should create a pointer with a fresh key for each address + /// it receives: a pointer's address derives from its key, so every address + /// handed to one recipient pointer resolves to the same place. + /// + /// The returned finality is the whole close group's view once the write + /// has landed. A recipient should check it with + /// [`Self::pointer_finality`] itself before relying on the transfer. + /// + /// # Errors + /// + /// As [`Self::pointer_sign_transfer`] before anything is paid; then as + /// [`Self::pointer_put`], except that a write refused because the pointer + /// was already final elsewhere is reported as [`Error::PointerFinal`] or + /// [`Error::PointerForked`]. + pub async fn pointer_transfer( + &self, + secret_key: &MlDsaSecretKey, + owner: &MlDsaPublicKey, + recipient: XorName, + ) -> Result { + let record = self + .pointer_sign_transfer(secret_key, owner, recipient) + .await?; + let address = record.address(); + if let Err(e) = self.pointer_put(&record).await { + // Nodes refuse a final state when they hold, or their group + // proves, a different one. If that is why, say which. + if let Ok(finality) = self.pointer_finality(&address).await { + if let Some(refusal) = rival_final(&finality, &record) { + return Err(refusal); + } + } + return Err(e); + } + self.pointer_finality(&address).await + } + + /// Sign the final state that hands `owner`'s pointer over to the pointer + /// at `recipient`, without storing it. + /// + /// Everything that can be checked is checked before signing, because once + /// a final state is stored it cannot be taken back: the pointer must not + /// be final already, the recipient must not be the pointer itself, the + /// recipient pointer must exist, and following it must not lead back + /// here. + /// + /// # Errors + /// + /// Returns [`Error::PointerFinal`] if the pointer is already final, + /// [`Error::InvalidData`] for a recipient that is this pointer, does not + /// exist or leads back to it, or an error if either pointer cannot be + /// read or signing fails. + pub async fn pointer_sign_transfer( + &self, + secret_key: &MlDsaSecretKey, + owner: &MlDsaPublicKey, + recipient: XorName, + ) -> Result { + let address = pointer_address(owner); + if recipient == address { + return Err(Error::InvalidData(format!( + "pointer {} cannot be handed over to itself", + hex::encode(address) + ))); + } + let current = self.pointer_get(&address).await?; + if let Some(current) = current.as_ref().filter(|record| record.is_terminal()) { + return Err(Error::PointerFinal(format!( + "pointer {} already holds {}", + hex::encode(address), + describe_final(current) + ))); + } + self.check_recipient(&address, &recipient).await?; + + let target = PointerTarget::new(PointerTargetKind::Pointer, recipient); + match current { + Some(current) => current.transfer_to(secret_key, recipient), + None => Pointer::sign(secret_key, owner, FINAL_COUNTER, target), + } + .map_err(|e| Error::InvalidData(format!("cannot sign pointer transfer: {e}"))) + } + + /// Refuse a recipient that does not exist or whose chain leads back to + /// `address`. + /// + /// A transfer to a pointer nobody created redirects every reader into a + /// broken chain, and one that leads back here is a cycle; either is final + /// the moment it is stored. The chain is followed as it stands now: the + /// recipient can still move its own pointer later, and a read that meets + /// a cycle then refuses it as it would any other. + async fn check_recipient(&self, address: &XorName, recipient: &XorName) -> Result<()> { + let mut at = *recipient; + let mut seen = HashSet::new(); + for hop in 0..MAX_POINTER_RESOLVE_DEPTH { + if at == *address { + return Err(Error::InvalidData(format!( + "recipient pointer {} leads back to pointer {}; the transfer would be a \ + cycle", + hex::encode(recipient), + hex::encode(address) + ))); + } + if !seen.insert(at) { + return Ok(()); + } + let Some(record) = self.pointer_get(&at).await? else { + if hop == 0 { + return Err(Error::InvalidData(format!( + "recipient pointer {} does not exist; its owner must create it before \ + it can receive a transfer", + hex::encode(recipient) + ))); + } + return Ok(()); + }; + let target = record.target(); + match target.kind() { + Some(PointerTargetKind::Pointer) => at = target.address, + _ => return Ok(()), + } + } + Ok(()) + } + + /// Ask the whole close group where `address` stands with respect to + /// finality. + /// + /// Unlike [`Self::pointer_get`], this does not stop once it has an answer: + /// every peer is asked, within its own deadline, so a second final state + /// held by even one of them is seen. That is what a recipient needs before + /// relying on a transfer — [`FinalityStatus::Final`] means one final state, + /// held by a majority, and no rival anywhere the group could show. + /// + /// # Errors + /// + /// Returns an error if no peer is responsible for the address, or if too + /// few of the group answer to speak for it. + pub async fn pointer_finality(&self, address: &XorName) -> Result { + let peers = self.pointer_group(address).await?; + let in_flight = FuturesUnordered::new(); + for (peer_id, addrs) in &peers { + in_flight.push(self.send_pointer_get(*address, *peer_id, addrs.clone())); + } + let mut replies = Replies::default(); + let answered = ask_the_group(in_flight, peers.len(), |found| { + replies.add(found); + false + }) + .await; + + let wanted = read_quorum(peers.len()); + if answered.count < wanted { + let count = answered.count; + return Err(answered + .failure() + .filter(|_| count == 0) + .unwrap_or_else(|| { + Error::CloseGroupShortfall(format!( + "only {count} of the close group answered for pointer {}", + hex::encode(address) + )) + })); + } + Ok(PointerFinality { + address: *address, + group: peers.len(), + answered: answered.count, + status: finality_status(&replies, peers.len()), + }) + } + + /// Follow `address` through every transfer to the pointer whose owner now + /// decides what it resolves to. + /// + /// Only transfers are followed — final states targeting a pointer. A + /// pointer target below the final counter is a forwarding its owner can + /// still take back, so that owner still decides, and the walk stops there. + /// + /// # Errors + /// + /// Returns an error if a pointer on the way is missing or unreadable, if + /// the transfers cycle, or if they are longer than + /// [`MAX_POINTER_RESOLVE_DEPTH`]. + pub async fn pointer_controller(&self, address: &XorName) -> Result { + let mut seen = HashSet::new(); + let mut transfers = Vec::new(); + let mut at = *address; + for _ in 0..MAX_POINTER_RESOLVE_DEPTH { + if !seen.insert(at) { + return Err(Error::InvalidData(format!( + "pointer transfers cycle back to {}", + hex::encode(at) + ))); + } + let record = self + .pointer_get(&at) + .await? + .ok_or_else(|| Error::NotFound(format!("no pointer at {}", hex::encode(at))))?; + match record.transferred_to() { + Some(next) => { + transfers.push(next); + at = next; + } + None => { + return Ok(PointerController { + pointer: at, + transfers, + frozen: record.is_terminal(), + }) + } + } + } + Err(Error::InvalidData(format!( + "pointer transfers from {} are longer than {MAX_POINTER_RESOLVE_DEPTH} hops", + hex::encode(address) + ))) + } + /// Pay for and store an already-signed pointer. /// /// Each call settles a payment for the state it is given. A state the @@ -790,7 +1260,6 @@ impl Client { let wanted = read_quorum(width); let needed = corroboration(width); let (answered, replies) = collect_read(in_flight, width).await; - let corroborated = replies.corroborated(needed).cloned(); if answered.count == 0 { return Err(answered.failure().unwrap_or_else(|| { @@ -808,6 +1277,20 @@ impl Client { hex::encode(address) ))); } + // The owner signed two final states and no majority holds either: + // there is nothing to return that the rest of the network would agree + // with, and asking again sees the same split. + let corroborated = match replies.chosen(needed, wanted) { + Chosen::State(state) => state.cloned(), + Chosen::Forked => { + return Err(Error::PointerForked(format!( + "pointer {} holds {} different final states and none is held by {wanted} \ + of the {width} peers in its close group", + hex::encode(address), + replies.finals().count() + ))); + } + }; // 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 @@ -1701,6 +2184,280 @@ mod tests { ); } + /// The final state handing `sk`'s pointer over to `recipient`. + fn handed_to(pk: &MlDsaPublicKey, sk: &MlDsaSecretKey, recipient: u8) -> Pointer { + Pointer::sign( + sk, + pk, + FINAL_COUNTER, + PointerTarget::new(PointerTargetKind::Pointer, [recipient; 32]), + ) + .expect("sign") + } + + fn state_of(chosen: Chosen<'_>) -> Option { + match chosen { + Chosen::State(state) => state.map(Pointer::state_id), + Chosen::Forked => panic!("expected a state, got a fork"), + } + } + + /// A transfer every peer holds is the answer, and the read settles on a + /// majority of them. + #[test] + fn a_transfer_the_group_holds_is_read_back() { + let (pk, sk) = keypair(40); + let before = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let transfer = handed_to(&pk, &sk, 0x77); + + let mut seen = Replies::default(); + for _ in 0..3 { + seen.add(Some(transfer.clone())); + assert!( + !seen.settled(2, 4), + "a final state short of a majority does not settle a read" + ); + } + seen.add(Some(before.clone())); + seen.add(Some(transfer.clone())); + assert!(seen.settled(2, 4)); + assert_eq!(state_of(seen.chosen(2, 4)), Some(transfer.state_id())); + } + + /// Two final states are unordered, so the merge rule cannot pick one; the + /// one a majority holds is what most nodes took first, and is the answer + /// whichever order the replies arrive in, even though the other's target + /// sorts first. + #[test] + fn of_two_final_states_the_majority_is_read_in_any_order() { + let (pk, sk) = keypair(41); + let established = handed_to(&pk, &sk, 0x77); + let late = handed_to(&pk, &sk, 0x01); + assert!(late.target().to_bytes() < established.target().to_bytes()); + + let mut replies = vec![Some(late.clone()), Some(late.clone())]; + replies.extend((0..5).map(|_| Some(established.clone()))); + for rotation in 0..replies.len() { + let mut order = replies.clone(); + order.rotate_left(rotation); + let seen = tally(order); + assert!(seen.settled(2, 4)); + assert_eq!( + state_of(seen.chosen(2, 4)), + Some(established.state_id()), + "rotation {rotation}" + ); + } + } + + /// Two final states and no majority: there is no answer, and saying so is + /// the only honest read. The read is never settled early, so it asks the + /// whole group before concluding it. + #[test] + fn two_final_states_without_a_majority_are_a_fork() { + let (pk, sk) = keypair(42); + let before = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let one = handed_to(&pk, &sk, 0x77); + let other = handed_to(&pk, &sk, 0x01); + + let seen = tally(vec![ + Some(one.clone()), + Some(one.clone()), + Some(one.clone()), + Some(other.clone()), + Some(other.clone()), + Some(before.clone()), + Some(before.clone()), + ]); + assert!(!seen.settled(2, 4)); + assert!(matches!(seen.chosen(2, 4), Chosen::Forked)); + + // Even a lone peer holding the other side is evidence of a fork: only + // the owner could have signed it. + let seen = tally(vec![ + Some(one.clone()), + Some(one.clone()), + Some(one.clone()), + Some(other.clone()), + Some(before.clone()), + Some(before.clone()), + None, + ]); + assert!(matches!(seen.chosen(2, 4), Chosen::Forked)); + } + + /// One final state short of a majority and nothing contesting it is a + /// transfer still spreading. It is returned once corroborated, as any state + /// is, and one peer's word is not enough. + #[test] + fn one_uncontested_final_state_is_read_once_corroborated() { + let (pk, sk) = keypair(43); + let before = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let transfer = handed_to(&pk, &sk, 0x77); + + let spreading = tally(vec![ + Some(transfer.clone()), + Some(transfer.clone()), + Some(before.clone()), + Some(before.clone()), + Some(before.clone()), + ]); + assert_eq!(state_of(spreading.chosen(2, 4)), Some(transfer.state_id())); + + let lone = tally(vec![ + Some(transfer.clone()), + Some(before.clone()), + Some(before.clone()), + Some(before.clone()), + ]); + assert_eq!(state_of(lone.chosen(2, 4)), Some(before.state_id())); + } + + /// The whole-group check a recipient runs before relying on a transfer. + #[test] + fn finality_names_each_case_a_recipient_must_tell_apart() { + let (pk, sk) = keypair(44); + let before = Pointer::create(&sk, &pk, chunk_target(1)).expect("create"); + let transfer = handed_to(&pk, &sk, 0x77); + let rival = handed_to(&pk, &sk, 0x01); + let frozen = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk_target(5)).expect("sign"); + let group = 7; + + let open = finality_status(&tally(vec![Some(before.clone()); 7]), group); + assert_eq!(open, FinalityStatus::Open { counter: Some(0) }); + + let status = finality_status( + &tally(vec![ + Some(transfer.clone()), + Some(transfer.clone()), + Some(transfer.clone()), + Some(transfer.clone()), + Some(before.clone()), + None, + ]), + group, + ); + match &status { + FinalityStatus::Final(state) => { + assert_eq!(state.state_id, transfer.state_id()); + assert_eq!(state.holders, 4); + assert_eq!(state.transferred_to(), Some([0x77; 32])); + } + other => panic!("expected Final, got {other:?}"), + } + + let status = finality_status( + &tally(vec![ + Some(transfer.clone()), + Some(transfer.clone()), + Some(before.clone()), + ]), + group, + ); + assert!(matches!(status, FinalityStatus::Settling(ref s) if s.holders == 2)); + + let status = finality_status( + &tally(vec![ + Some(rival.clone()), + Some(transfer.clone()), + Some(transfer.clone()), + Some(transfer.clone()), + Some(transfer.clone()), + ]), + group, + ); + match status { + FinalityStatus::Forked { states, majority } => { + assert_eq!(states.len(), 2); + assert_eq!( + states.first().map(|s| s.state_id), + Some(transfer.state_id()), + "most-held first" + ); + assert_eq!(majority.map(|s| s.state_id), Some(transfer.state_id())); + } + other => panic!("expected Forked, got {other:?}"), + } + + let status = finality_status(&tally(vec![Some(frozen.clone()); 5]), group); + match status { + FinalityStatus::Final(state) => assert_eq!(state.transferred_to(), None), + other => panic!("expected a frozen Final, got {other:?}"), + } + } + + /// A transfer refused because the pointer was already final on another + /// state is reported as that, and one that landed is not a refusal. + #[test] + fn a_refused_transfer_names_the_final_state_that_beat_it() { + let (pk, sk) = keypair(45); + let ours = handed_to(&pk, &sk, 0x77); + let theirs = handed_to(&pk, &sk, 0x01); + let finality = |status| PointerFinality { + address: ours.address(), + group: 7, + answered: 7, + status, + }; + + let beaten = finality(FinalityStatus::Final(FinalState::of(&theirs, 7))); + assert!(matches!( + rival_final(&beaten, &ours), + Some(Error::PointerFinal(_)) + )); + let landed = finality(FinalityStatus::Final(FinalState::of(&ours, 7))); + assert!(rival_final(&landed, &ours).is_none()); + assert!(rival_final(&finality(FinalityStatus::Open { counter: Some(3) }), &ours).is_none()); + let forked = finality(FinalityStatus::Forked { + states: vec![FinalState::of(&ours, 3), FinalState::of(&theirs, 3)], + majority: None, + }); + assert!(matches!( + rival_final(&forked, &ours), + Some(Error::PointerForked(_)) + )); + } + + /// A read over a group split by a fork asks everyone, then reports it. + #[tokio::test(start_paused = true)] + async fn a_read_of_a_forked_pointer_asks_the_whole_group() { + let (pk, sk) = keypair(46); + let one = handed_to(&pk, &sk, 0x77); + let other = handed_to(&pk, &sk, 0x01); + let (answered, replies) = collect_read( + arriving(vec![ + Ok(Some(one.clone())), + Ok(Some(one.clone())), + Ok(Some(other.clone())), + Ok(Some(other.clone())), + Ok(Some(one.clone())), + Ok(Some(other.clone())), + Ok(None), + ]), + 7, + ) + .await; + assert_eq!(answered.count, 7, "no majority, so nobody was skipped"); + assert!(matches!(replies.chosen(2, 4), Chosen::Forked)); + + // With a majority it stops as soon as the majority is heard. + let (answered, replies) = collect_read( + arriving(vec![ + Ok(Some(other.clone())), + Ok(Some(one.clone())), + Ok(Some(one.clone())), + Ok(Some(one.clone())), + Ok(Some(one.clone())), + Ok(Some(other.clone())), + Ok(Some(one.clone())), + ]), + 7, + ) + .await; + assert_eq!(answered.count, 5); + assert_eq!(state_of(replies.chosen(2, 4)), Some(one.state_id())); + } + /// 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] diff --git a/ant-core/src/data/error.rs b/ant-core/src/data/error.rs index 4a38d9cd..524d797b 100644 --- a/ant-core/src/data/error.rs +++ b/ant-core/src/data/error.rs @@ -192,6 +192,22 @@ pub enum Error { /// Cost estimation could not reach a representative quote. /// + /// The pointer is final: its state is at the final counter, so nothing + /// can update it or hand it over again (ADR-0018 in `ant-node`). + /// + /// Raised before anything is paid. + #[error("pointer is final: {0}")] + PointerFinal(String), + + /// The pointer's owner signed two or more different final states and no + /// one of them is held by a majority of the close group, so there is no + /// answer to give (ADR-0018 in `ant-node`). + /// + /// Not a transient failure: nodes keep the final state they took first, + /// so a retry sees the same split. + #[error("pointer is forked: {0}")] + PointerForked(String), + /// Returned by [`crate::data::Client::estimate_upload_cost`] when every /// sampled chunk address reported `AlreadyStored`, so the network price /// for the remainder of the file cannot be inferred from a sample. diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs index 93805ae0..1dfc4aa3 100644 --- a/ant-core/src/data/mod.rs +++ b/ant-core/src/data/mod.rs @@ -25,7 +25,9 @@ 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::pointer::{ + pointer_address, Pointer, PointerTarget, PointerTargetKind, FINAL_COUNTER, +}; pub use ant_protocol::pqc::api::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; // Re-export client data types @@ -52,6 +54,7 @@ pub use client::merkle::{ finalize_merkle_batch, MerkleBatchPaymentResult, PaymentMode, PreparedMerkleBatch, DEFAULT_MERKLE_THRESHOLD, }; +pub use client::pointer::{FinalState, FinalityStatus, PointerController, PointerFinality}; // Re-export self-encryption types pub use self_encryption::DataMap; diff --git a/ant-core/tests/e2e_pointer.rs b/ant-core/tests/e2e_pointer.rs index 7dc1e46a..6e25fd6c 100644 --- a/ant-core/tests/e2e_pointer.rs +++ b/ant-core/tests/e2e_pointer.rs @@ -35,7 +35,7 @@ mod support; use ant_core::data::client::pointer::MAX_POINTER_RESOLVE_DEPTH; -use ant_core::data::{Client, ClientConfig, Error, XorName}; +use ant_core::data::{Client, ClientConfig, Error, FinalityStatus, XorName}; use ant_node::storage::AntProtocol; use ant_protocol::chunk::{ ChunkMessage, ChunkMessageBody, PointerGetRequest, PointerGetResponse, PointerPutRequest, @@ -43,7 +43,7 @@ use ant_protocol::chunk::{ }; use ant_protocol::evm::U256; use ant_protocol::pointer::{ - pointer_address, Pointer, PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, + pointer_address, Pointer, PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, FINAL_COUNTER, POINTER_BODY_LEN, POINTER_WIRE_LEN, }; use ant_protocol::pqc::api::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; @@ -1108,3 +1108,265 @@ async fn an_address_nobody_wrote_reads_as_absent() { drop(client); testnet.teardown().await; } + +/// A transfer hands the address over for good (ADR-0018 in `ant-node`). +/// +/// Refused before anything is paid when it could not land well: to the +/// pointer itself, to a pointer nobody created, or to one that leads back. +/// Once stored, readers of the address are redirected to the recipient's +/// pointer, which the recipient moves, and the former owner can move nothing: +/// the client refuses before paying, and a paid final state sent straight to +/// every node that holds the transfer is refused by each, whichever way its +/// target sorts. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_transfer_hands_the_address_over_for_good() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let (recipient_pk, recipient_sk) = owner(); + + let address = client + .pointer_create(&sk, &pk, chunk_target(1)) + .await + .expect("create the pointer to hand over"); + let recipient = client + .pointer_create(&recipient_sk, &recipient_pk, chunk_target(2)) + .await + .expect("the recipient creates its own pointer"); + + // Refusals that cost nothing. + let before = balance(&client).await; + let to_itself = client.pointer_transfer(&sk, &pk, address).await; + assert!(matches!(to_itself, Err(Error::InvalidData(ref m)) if m.contains("itself"))); + let nowhere = client.pointer_transfer(&sk, &pk, [0x42; 32]).await; + assert!(matches!(nowhere, Err(Error::InvalidData(ref m)) if m.contains("does not exist"))); + let (loop_pk, loop_sk) = owner(); + let loops_back = client + .pointer_create( + &loop_sk, + &loop_pk, + PointerTarget::new(PointerTargetKind::Pointer, address), + ) + .await + .expect("a pointer that forwards back to the one being handed over"); + let paid_for_loop = balance(&client).await; + let cycle = client.pointer_transfer(&sk, &pk, loops_back).await; + assert!(matches!(cycle, Err(Error::InvalidData(ref m)) if m.contains("cycle"))); + assert!( + before > paid_for_loop, + "only the forwarding pointer was paid for" + ); + assert_eq!( + balance(&client).await, + paid_for_loop, + "no refused transfer settles a payment" + ); + + // The transfer itself. + let finality = client + .pointer_transfer(&sk, &pk, recipient) + .await + .expect("the transfer lands"); + assert!( + balance(&client).await < paid_for_loop, + "a transfer is paid for" + ); + assert_eq!(finality.address, address); + assert!(finality.is_final(), "got {:?}", finality.status); + assert_eq!(finality.transferred_to(), Some(recipient)); + + let held = client + .pointer_get(&address) + .await + .expect("get") + .expect("present"); + assert_eq!(held.counter(), FINAL_COUNTER); + assert_eq!(held.transferred_to(), Some(recipient)); + + // Readers follow it; the recipient moves it. + assert_eq!( + client.pointer_resolve(&address).await.expect("resolve"), + chunk_target(2) + ); + client + .pointer_update(&recipient_sk, &recipient_pk, chunk_target(3)) + .await + .expect("the recipient updates its own pointer"); + assert_eq!( + client.pointer_resolve(&address).await.expect("resolve"), + chunk_target(3), + "the recipient now decides what the address resolves to" + ); + let controller = client + .pointer_controller(&address) + .await + .expect("controller"); + assert_eq!(controller.pointer, recipient); + assert_eq!(controller.transfers, vec![recipient]); + assert!(!controller.frozen); + + // The former owner, through the client: refused before paying. + let before = balance(&client).await; + let update = client.pointer_update(&sk, &pk, chunk_target(4)).await; + assert!( + matches!(update, Err(Error::PointerFinal(_))), + "got {update:?}" + ); + let again = client.pointer_transfer(&sk, &pk, loops_back).await; + assert!( + matches!(again, Err(Error::PointerFinal(_))), + "got {again:?}" + ); + assert_eq!(balance(&client).await, before, "nothing was paid"); + + // The former owner, around the client: a paid final state whose target + // sorts first, sent to every node that holds the transfer. + let group = close_group(&client, &testnet, &address).await; + let take_back = Pointer::sign( + &sk, + &pk, + FINAL_COUNTER, + PointerTarget::new(PointerTargetKind::Chunk, [0; 32]), + ) + .expect("sign"); + assert!(take_back.target().to_bytes() < held.target().to_bytes()); + let request = paid(&client, &take_back).await; + let mut refused = 0; + for member in &group { + let holds_transfer = held_by(&member.protocol, &address) + .await + .is_some_and(|record| record.state_id() == held.state_id()); + if !holds_transfer { + continue; + } + match put_over_quic(&client, member, request.clone()).await { + PointerPutResponse::Stale { state_id, .. } => { + assert_eq!(state_id, held.state_id(), "the refusal names the transfer"); + refused += 1; + } + other => panic!("a node holding the transfer took another final state: {other:?}"), + } + } + assert!( + refused >= 5, + "the transfer reached a write quorum ({refused})" + ); + assert_group_unchanged(&client, &address, &group).await; + assert_eq!( + client + .pointer_get(&address) + .await + .expect("get") + .expect("present") + .state_id(), + held.state_id() + ); + assert!(client + .pointer_finality(&address) + .await + .expect("finality") + .is_final()); + + drop(client); + testnet.teardown().await; +} + +/// The one fork a final state allows: the owner signs two and races them to +/// different nodes, each of which keeps what it took first. With a majority +/// on one side, reads return it and the finality check reports the fork; with +/// no majority, reads fail as forked rather than pick a side, and the client +/// will not build on either. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_raced_final_state_is_read_by_its_majority_or_reported_as_forked() { + let (client, testnet) = setup().await; + + // Four against three: the four win every read. + 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"); + let majority = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk_target(9)).expect("sign"); + let minority = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk_target(2)).expect("sign"); + let (four, three) = group.split_at(4); + store_on(&client, four, &majority).await; + store_on(&client, three, &minority).await; + assert_held(four, &majority).await; + assert_held(three, &minority).await; + assert_group_unchanged(&client, &address, &group).await; + + for _ in 0..3 { + let read = client + .pointer_get(&address) + .await + .expect("a majority answers") + .expect("present"); + assert_eq!( + read.state_id(), + majority.state_id(), + "the majority wins even though the minority's target sorts first" + ); + } + match client + .pointer_finality(&address) + .await + .expect("finality") + .status + { + FinalityStatus::Forked { + states, + majority: held, + } => { + assert_eq!(states.len(), 2); + assert_eq!(held.map(|s| s.state_id), Some(majority.state_id())); + } + other => panic!("expected the fork to be reported, got {other:?}"), + } + + // Three against three, one left behind: no answer. + 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"); + let one = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk_target(7)).expect("sign"); + let other = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk_target(8)).expect("sign"); + let (first, rest) = group.split_at(3); + let (second, _behind) = rest.split_at(3); + store_on(&client, first, &one).await; + store_on(&client, second, &other).await; + assert_group_unchanged(&client, &address, &group).await; + + let read = client.pointer_get(&address).await; + assert!(matches!(read, Err(Error::PointerForked(_))), "got {read:?}"); + match client + .pointer_finality(&address) + .await + .expect("finality") + .status + { + FinalityStatus::Forked { states, majority } => { + assert_eq!(states.len(), 2); + assert!(majority.is_none()); + } + other => panic!("expected a fork with no majority, got {other:?}"), + } + let before = balance(&client).await; + let (recipient_pk, recipient_sk) = owner(); + let recipient = client + .pointer_create(&recipient_sk, &recipient_pk, chunk_target(3)) + .await + .expect("recipient"); + let paid_recipient = balance(&client).await; + assert!(paid_recipient < before); + let transfer = client.pointer_transfer(&sk, &pk, recipient).await; + assert!( + matches!(transfer, Err(Error::PointerForked(_))), + "got {transfer:?}" + ); + assert_eq!(balance(&client).await, paid_recipient, "nothing was paid"); + + drop(client); + testnet.teardown().await; +} diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index d561ec65..ad4c1e3c 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -352,3 +352,71 @@ test("a paid state handed to onPaid is stored again without paying", async () => client.close(); } }); + +test("a transfer hands the address over for good, and reads follow it", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + const recipientSeed = new Uint8Array(32).fill(8); + try { + const recipient = await client.createPointer(recipientSeed, target(9), "chunk", paymentNetwork, signer.pay); + const handed = await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay); + const address = handed.pointer.address; + + const written = await client.transferPointer(seed, recipient.pointer.address, paymentNetwork, signer.pay); + assert.equal(written.pointer.address, address, "the address readers use does not change"); + assert.equal(written.pointer.counter, "18446744073709551615", "signed at the final counter"); + assert.equal(written.pointer.kind, "pointer"); + assert.equal(written.pointer.target, recipient.pointer.address); + assert.equal(signer.calls.length, 3, "the transfer is one paid state"); + + const finality = await client.pointerFinality(address); + assert.equal(finality.status, "final"); + assert.equal(finality.states.length, 1); + assert.equal(finality.majority.transferredTo, recipient.pointer.address); + assert.ok(finality.majority.holders >= 5, "the write reached a majority plus one"); + + assert.deepEqual(await client.resolvePointer(address), { kind: "chunk", kindTag: 0, target: target(9) }); + + // The recipient moves it; the former owner can move nothing. + await client.updatePointer(recipientSeed, target(4), "chunk", paymentNetwork, signer.pay); + assert.deepEqual(await client.resolvePointer(address), { kind: "chunk", kindTag: 0, target: target(4) }); + const paid = signer.calls.length; + await assert.rejects( + client.updatePointer(seed, target(5), "chunk", paymentNetwork, signer.pay), + /pointer is final/, + ); + await assert.rejects( + client.transferPointer(seed, target(6), paymentNetwork, signer.pay), + /pointer is final/, + ); + assert.equal(signer.calls.length, paid, "nothing is paid for a move a final pointer cannot make"); + } finally { + client.close(); + } +}); + +test("an open pointer reports its counter, and a transfer nowhere is refused before paying", 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); + const finality = await client.pointerFinality(written.pointer.address); + assert.equal(finality.status, "open"); + assert.equal(finality.counter, "0"); + assert.deepEqual(finality.states, []); + + await assert.rejects( + client.transferPointer(seed, target(0x42), paymentNetwork, signer.pay), + /does not exist/, + ); + await assert.rejects( + client.transferPointer(seed, written.pointer.address, paymentNetwork, signer.pay), + /to itself/, + ); + assert.equal(signer.calls.length, 1, "only the create was paid for"); + } finally { + client.close(); + } +}); From 40530d413ccf6946bbcdfc5f27ed99385862a3dd Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 12:37:55 +0900 Subject: [PATCH 02/11] fix(pointer): check the whole group before a transfer, and never call a half-heard group final A read returns a state only once two peers name it, so a final state an earlier transfer left on one peer was invisible to the transfer and update preflights. A second transfer then signed past it, paid, and forked the pointer for good on every node that had not heard of the first. A transfer now asks the whole close group before it is stored, and any final state other than its own refuses it; the same state, left by an earlier attempt, does not. An update refuses a final state any peer its read heard from reported. The finality check called one final state held by a majority Final even when part of the group had not answered, although a silent peer could hold a rival. It now counts over the configured close group, and a majority without every peer answering is Unconfirmed; only Final means final. A transfer whose write landed but whose finality check then failed was reported as a failed transfer, although it was stored and final. The transfer now returns what it stored with the check beside it, and the CLI prints a failed check as that. The recipient-chain check could run out of hops exactly as the chain reached the pointer being handed over, and let the cycle through. It now reserves the hop a reader spends on that pointer and refuses a chain it cannot follow to the end. The PointerFinal error no longer carries the cost-estimation doc that belongs to the next variant. ant-protocol and ant-node are pinned at the heads of their companion changes. --- Cargo.lock | 4 +- Cargo.toml | 2 +- ant-cli/src/commands/data/pointer.rs | 44 +- ant-core/Cargo.toml | 4 +- .../src/browser/wasm_transport/pointer.rs | 8 +- ant-core/src/data/client/pointer.rs | 400 ++++++++++++++---- ant-core/src/data/error.rs | 15 +- ant-core/src/data/mod.rs | 4 +- ant-core/tests/e2e_pointer.rs | 62 ++- 9 files changed, 449 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a4e1a83..7c37c6f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=e582b24b879f7e64b21420158c0cb2a71ef5a122#e582b24b879f7e64b21420158c0cb2a71ef5a122" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=092493d8a694213f086bca0329b38ef383fb56aa#092493d8a694213f086bca0329b38ef383fb56aa" dependencies = [ "ant-protocol", "bao", @@ -965,7 +965,7 @@ dependencies = [ [[package]] name = "ant-protocol" version = "3.1.0" -source = "git+https://github.com/WithAutonomi/ant-protocol?rev=7aab0772aed9931ce7912eeda23b1f8482d6d116#7aab0772aed9931ce7912eeda23b1f8482d6d116" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=9ad7fce38aa2b79ea6c532985930a3f347ab529b#9ad7fce38aa2b79ea6c532985930a3f347ab529b" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index fc1f49f9..8eb0afa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,4 @@ resolver = "2" # state that ownership transfer relies on (ADR-0018 in ant-node). Pin the # companion revision until a release includes it. Versions are bumped at # release; this immutable revision remains 3.1.0. -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "7aab0772aed9931ce7912eeda23b1f8482d6d116" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "9ad7fce38aa2b79ea6c532985930a3f347ab529b" } diff --git a/ant-cli/src/commands/data/pointer.rs b/ant-cli/src/commands/data/pointer.rs index 0607ad08..89a6d21f 100644 --- a/ant-cli/src/commands/data/pointer.rs +++ b/ant-cli/src/commands/data/pointer.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use ant_core::data::{ ml_dsa_65, pointer_address, Client, FinalState, FinalityStatus, MlDsaPublicKey, MlDsaSecretKey, - Pointer, PointerFinality, PointerTarget, PointerTargetKind, XorName, + Pointer, PointerFinality, PointerTarget, PointerTargetKind, PointerTransfer, XorName, }; use clap::{Subcommand, ValueEnum}; use rand::rngs::OsRng; @@ -204,11 +204,11 @@ impl PointerAction { let (owner, secret) = read_key(&key)?; let recipient = parse_address(&recipient)?; info!("Transferring pointer"); - let finality = client + let transfer = client .pointer_transfer(&secret, &owner, recipient) .await .map_err(|e| anyhow::anyhow!("Pointer transfer failed: {e}"))?; - print_finality(&finality, json); + print_transfer(&transfer, json); } Self::Finality { address } => { let at = parse_address(&address)?; @@ -333,6 +333,34 @@ fn final_state_line(state: &FinalState, group: usize) -> String { format!("{kind} {target} (state {id}, held by {holders} of {group})") } +/// A stored transfer, and the finality check after it. A failed check is +/// printed as that, not as a failed transfer: the transfer is stored and final +/// either way. +fn print_transfer(transfer: &PointerTransfer, json: bool) { + let address = hex::encode(transfer.address); + let state = hex::encode(transfer.state_id); + match &transfer.finality { + Ok(finality) => { + if !json { + println!("transferred: pointer {address}, final state {state}"); + } + print_finality(finality, json); + } + Err(e) if json => println!( + "{}", + json!({ + "address": address, + "state": state, + "finality_error": e.to_string(), + }) + ), + Err(e) => { + println!("transferred: pointer {address}, final state {state}"); + println!("finality: unavailable ({e}); run `pointer finality {address}` again"); + } + } +} + fn print_finality(finality: &PointerFinality, json: bool) { let address = hex::encode(finality.address); let group = finality.group; @@ -346,6 +374,9 @@ fn print_finality(finality: &PointerFinality, json: bool) { FinalityStatus::Final(state) => { json!({ "status": "final", "state": final_state_json(state) }) } + FinalityStatus::Unconfirmed(state) => { + json!({ "status": "unconfirmed", "state": final_state_json(state) }) + } FinalityStatus::Forked { states, majority } => json!({ "status": "forked", "states": states.iter().map(final_state_json).collect::>(), @@ -378,6 +409,13 @@ fn print_finality(finality: &PointerFinality, json: bool) { println!("status: final"); println!("state: {}", final_state_line(state, group)); } + FinalityStatus::Unconfirmed(state) => { + println!( + "status: unconfirmed (final on a majority, but not every peer answered, so a \ + rival could be unseen; ask again)" + ); + println!("state: {}", final_state_line(state, group)); + } FinalityStatus::Forked { states, majority } => { println!("status: forked (its owner signed more than one final state)"); for state in states { diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index fdfd4c5a..850336bb 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "e582b24b879f7e64b21420158c0cb2a71ef5a122", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "092493d8a694213f086bca0329b38ef383fb56aa", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "e582b24b879f7e64b21420158c0cb2a71ef5a122", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "092493d8a694213f086bca0329b38ef383fb56aa", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 1ee3ea31..6be6d5b0 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -108,8 +108,9 @@ impl From<&FinalState> for BrowserFinalState { /// Where a pointer stands with respect to finality, as JavaScript sees it. /// -/// `status` is `"open"`, `"settling"`, `"final"` or `"forked"`. `states` holds -/// every final state seen, most-held first; `majority` the one reads return. +/// `status` is `"open"`, `"settling"`, `"final"`, `"unconfirmed"` (final on a +/// majority, but not every peer answered) or `"forked"`. `states` holds every +/// final state seen, most-held first; `majority` the one reads return. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BrowserPointerFinality { @@ -134,6 +135,9 @@ impl From<&PointerFinality> for BrowserPointerFinality { } FinalityStatus::Settling(state) => ("settling", None, vec![state.into()], None), FinalityStatus::Final(state) => ("final", None, vec![state.into()], Some(state.into())), + FinalityStatus::Unconfirmed(state) => { + ("unconfirmed", None, vec![state.into()], Some(state.into())) + } FinalityStatus::Forked { states, majority } => ( "forked", None, diff --git a/ant-core/src/data/client/pointer.rs b/ant-core/src/data/client/pointer.rs index 50608395..38e6865e 100644 --- a/ant-core/src/data/client/pointer.rs +++ b/ant-core/src/data/client/pointer.rs @@ -367,9 +367,15 @@ pub enum FinalityStatus { /// One final state, and no other, but a majority of the group does not /// hold it yet: a transfer still spreading, or a write that fell short. Settling(FinalState), - /// One final state, held by a majority of the group, and no other seen. - /// Nothing can move the pointer off it. + /// One final state, held by a majority of the group, and no other: every + /// peer of the group answered and none holds a rival. Nothing can move + /// the pointer off it. Final(FinalState), + /// One final state, held by a majority of the group, and no other among + /// the peers that answered, but not every peer of the group answered. A + /// rival held by one that did not would not show, so this is not yet + /// [`Self::Final`]; ask again once the whole group can answer. + Unconfirmed(FinalState), /// The owner signed two or more different final states. `majority` is /// the one a majority holds, which reads return; with none, reads fail. Forked { @@ -385,7 +391,8 @@ pub enum FinalityStatus { pub struct PointerFinality { /// The pointer asked about. pub address: XorName, - /// How many peers were asked: the whole close group. + /// How many peers the group has: the configured close group, or more if + /// a lookup returned more. pub group: usize, /// How many of them answered. pub answered: usize, @@ -411,6 +418,30 @@ impl PointerFinality { } } +/// A pointer read (see [`Client::pointer_get`]), with what it heard but +/// does not return. +struct PointerRead { + /// What the read returns. + current: Option, + /// A final state some answering peer named that the read does not return: + /// too few peers to decide a read, but signed by the owner all the same. + unreturned_final: Option, +} + +/// What [`Client::pointer_transfer`] stored, and what the close group said +/// about it afterwards. +#[derive(Debug)] +pub struct PointerTransfer { + /// The pointer handed over. + pub address: XorName, + /// The final state stored. + pub state_id: XorName, + /// The whole close group's view once the write landed. An error here does + /// not undo the transfer, which is stored and final; only the check + /// failed. Ask again with [`Client::pointer_finality`]. + pub finality: Result, +} + /// Whose pointer decides what an address resolves to, after following every /// transfer (see [`Client::pointer_controller`]). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -447,15 +478,16 @@ fn rival_final(finality: &PointerFinality, ours: &Pointer) -> Option { let address = hex::encode(finality.address); match &finality.status { FinalityStatus::Open { .. } => None, - FinalityStatus::Settling(state) | FinalityStatus::Final(state) => (state.state_id != ours) - .then(|| { - Error::PointerFinal(format!( - "pointer {address} is already final on state {} held by {} of {} peers", - hex::encode(state.state_id), - state.holders, - finality.group - )) - }), + FinalityStatus::Settling(state) + | FinalityStatus::Final(state) + | FinalityStatus::Unconfirmed(state) => (state.state_id != ours).then(|| { + Error::PointerFinal(format!( + "pointer {address} is already final on state {} held by {} of {} peers", + hex::encode(state.state_id), + state.holders, + finality.group + )) + }), FinalityStatus::Forked { states, .. } => Some(Error::PointerForked(format!( "pointer {address} holds {} different final states", states.len() @@ -463,8 +495,9 @@ fn rival_final(finality: &PointerFinality, ours: &Pointer) -> Option { } } -/// What a finality check makes of a tally over the whole group. -fn finality_status(replies: &Replies, group: usize) -> FinalityStatus { +/// What a finality check makes of a tally over a group of `group` peers, of +/// which `answered` answered. +fn finality_status(replies: &Replies, group: usize, answered: usize) -> FinalityStatus { let majority = read_quorum(group); let mut finals: Vec = replies .finals() @@ -481,7 +514,10 @@ fn finality_status(replies: &Replies, group: usize) -> FinalityStatus { .corroborated(corroboration(group)) .map(Pointer::counter), }, - [only] if only.holders >= majority => FinalityStatus::Final(only.clone()), + [only] if only.holders >= majority && answered >= group => { + FinalityStatus::Final(only.clone()) + } + [only] if only.holders >= majority => FinalityStatus::Unconfirmed(only.clone()), [only] => FinalityStatus::Settling(only.clone()), _ => FinalityStatus::Forked { states: finals, @@ -659,6 +695,75 @@ fn read_get_reply(body: ChunkMessageBody, address: XorName) -> Option( + address: &XorName, + recipient: &XorName, + get: Get, +) -> Result<()> +where + Get: Fn(XorName) -> Fut, + Fut: Future>>, +{ + let mut at = *recipient; + let mut seen = HashSet::new(); + for hop in 0..MAX_POINTER_RESOLVE_DEPTH.saturating_sub(1) { + if at == *address { + return Err(Error::InvalidData(format!( + "recipient pointer {} leads back to pointer {}; the transfer would be a \ + cycle", + hex::encode(recipient), + hex::encode(address) + ))); + } + if !seen.insert(at) { + return Ok(()); + } + let Some(record) = get(at).await? else { + if hop == 0 { + return Err(Error::InvalidData(format!( + "recipient pointer {} does not exist; its owner must create it before \ + it can receive a transfer", + hex::encode(recipient) + ))); + } + return Ok(()); + }; + let target = record.target(); + match target.kind() { + Some(PointerTargetKind::Pointer) => at = target.address, + _ => return Ok(()), + } + } + if at == *address { + return Err(Error::InvalidData(format!( + "recipient pointer {} leads back to pointer {}; the transfer would be a cycle", + hex::encode(recipient), + hex::encode(address) + ))); + } + Err(Error::InvalidData(format!( + "recipient pointer {} starts a chain longer than {} hops; readers of pointer {} \ + could not follow the transfer", + hex::encode(recipient), + MAX_POINTER_RESOLVE_DEPTH.saturating_sub(1), + hex::encode(address) + ))) +} + impl Client { /// Create a pointer at counter 0, owned by `owner`. /// @@ -733,8 +838,10 @@ impl Client { /// /// # Errors /// - /// Returns an error if the current record cannot be read, the counter is - /// exhausted, or signing fails. + /// Returns [`Error::PointerFinal`] if the pointer is final, or if any + /// peer the read heard from holds a final state for it, even one too few + /// peers hold for the read to return; otherwise an error if the current + /// record cannot be read or signing fails. pub async fn pointer_sign_update( &self, secret_key: &MlDsaSecretKey, @@ -742,7 +849,19 @@ impl Client { target: PointerTarget, ) -> Result { let address = pointer_address(owner); - match self.pointer_get(&address).await? { + let read = self.pointer_read(&address).await?; + // A final state even one answering peer holds was signed by the owner, + // and no node holding it will take this update. That is a transfer or + // freeze that fell short: finish it rather than move the rest of the + // group somewhere those nodes never follow. + if let Some(lone) = read.unreturned_final.as_ref() { + return Err(Error::PointerFinal(format!( + "pointer {} has {} on some of its close group; an update cannot replace it", + hex::encode(address), + describe_final(lone) + ))); + } + match read.current { Some(current) => current.update(secret_key, target).map_err(|e| match e { PointerError::CounterExhausted => Error::PointerFinal(format!( "pointer {} holds {}; nothing can update it", @@ -769,9 +888,11 @@ impl Client { /// it receives: a pointer's address derives from its key, so every address /// handed to one recipient pointer resolves to the same place. /// - /// The returned finality is the whole close group's view once the write - /// has landed. A recipient should check it with - /// [`Self::pointer_finality`] itself before relying on the transfer. + /// Once the write has landed the whole close group is asked where the + /// transfer stands. That check is reported beside the transfer, not as its + /// outcome: a check that fails after the write says nothing about a + /// transfer that is already stored and final. A recipient should check it + /// with [`Self::pointer_finality`] itself before relying on the transfer. /// /// # Errors /// @@ -784,7 +905,7 @@ impl Client { secret_key: &MlDsaSecretKey, owner: &MlDsaPublicKey, recipient: XorName, - ) -> Result { + ) -> Result { let record = self .pointer_sign_transfer(secret_key, owner, recipient) .await?; @@ -799,24 +920,40 @@ impl Client { } return Err(e); } - self.pointer_finality(&address).await + Ok(PointerTransfer { + address, + state_id: record.state_id(), + finality: self.pointer_finality(&address).await, + }) } /// Sign the final state that hands `owner`'s pointer over to the pointer /// at `recipient`, without storing it. /// - /// Everything that can be checked is checked before signing, because once - /// a final state is stored it cannot be taken back: the pointer must not - /// be final already, the recipient must not be the pointer itself, the + /// Everything that can be checked is checked before it is stored, because + /// once a final state is stored it cannot be taken back: the pointer must + /// not be final already, the recipient must not be the pointer itself, the /// recipient pointer must exist, and following it must not lead back /// here. /// + /// "Final already" is asked of the whole close group + /// ([`Self::pointer_finality`]), not of an ordinary read. A read returns a + /// state only once two peers name it, so a final state an earlier + /// transfer left on one peer is not what it returns, and signing a second + /// final state past that one would fork the pointer for good. Any final + /// state other than the one signed here refuses the transfer; the same + /// state, left by an earlier attempt at this very transfer, does not. A + /// peer that does not answer cannot be asked, so this sees every final + /// state the answering peers hold, and the nodes' own look before a final + /// state covers the rest. + /// /// # Errors /// - /// Returns [`Error::PointerFinal`] if the pointer is already final, + /// Returns [`Error::PointerFinal`] if the pointer is already final, or + /// [`Error::PointerForked`] if its owner already forked it, /// [`Error::InvalidData`] for a recipient that is this pointer, does not /// exist or leads back to it, or an error if either pointer cannot be - /// read or signing fails. + /// read, the group cannot be asked, or signing fails. pub async fn pointer_sign_transfer( &self, secret_key: &MlDsaSecretKey, @@ -841,53 +978,26 @@ impl Client { self.check_recipient(&address, &recipient).await?; let target = PointerTarget::new(PointerTargetKind::Pointer, recipient); - match current { + let record = match current { Some(current) => current.transfer_to(secret_key, recipient), None => Pointer::sign(secret_key, owner, FINAL_COUNTER, target), } - .map_err(|e| Error::InvalidData(format!("cannot sign pointer transfer: {e}"))) + .map_err(|e| Error::InvalidData(format!("cannot sign pointer transfer: {e}")))?; + + let finality = self.pointer_finality(&address).await?; + if let Some(refusal) = rival_final(&finality, &record) { + return Err(refusal); + } + Ok(record) } /// Refuse a recipient that does not exist or whose chain leads back to - /// `address`. - /// - /// A transfer to a pointer nobody created redirects every reader into a - /// broken chain, and one that leads back here is a cycle; either is final - /// the moment it is stored. The chain is followed as it stands now: the - /// recipient can still move its own pointer later, and a read that meets - /// a cycle then refuses it as it would any other. + /// `address` (see [`check_recipient_chain`]). async fn check_recipient(&self, address: &XorName, recipient: &XorName) -> Result<()> { - let mut at = *recipient; - let mut seen = HashSet::new(); - for hop in 0..MAX_POINTER_RESOLVE_DEPTH { - if at == *address { - return Err(Error::InvalidData(format!( - "recipient pointer {} leads back to pointer {}; the transfer would be a \ - cycle", - hex::encode(recipient), - hex::encode(address) - ))); - } - if !seen.insert(at) { - return Ok(()); - } - let Some(record) = self.pointer_get(&at).await? else { - if hop == 0 { - return Err(Error::InvalidData(format!( - "recipient pointer {} does not exist; its owner must create it before \ - it can receive a transfer", - hex::encode(recipient) - ))); - } - return Ok(()); - }; - let target = record.target(); - match target.kind() { - Some(PointerTargetKind::Pointer) => at = target.address, - _ => return Ok(()), - } - } - Ok(()) + check_recipient_chain(address, recipient, |at| async move { + self.pointer_get(&at).await + }) + .await } /// Ask the whole close group where `address` stands with respect to @@ -897,7 +1007,9 @@ impl Client { /// every peer is asked, within its own deadline, so a second final state /// held by even one of them is seen. That is what a recipient needs before /// relying on a transfer — [`FinalityStatus::Final`] means one final state, - /// held by a majority, and no rival anywhere the group could show. + /// held by a majority, with every peer of the configured close group + /// answering and none holding a rival. A group that did not all answer + /// can show at most [`FinalityStatus::Unconfirmed`]. /// /// # Errors /// @@ -916,7 +1028,11 @@ impl Client { }) .await; - let wanted = read_quorum(peers.len()); + // Counted over the configured group, as every pointer quorum is: a + // lookup that came back short is peers that did not answer, not a + // smaller group. + let width = quorum_width(peers.len(), self.config().close_group_size); + let wanted = read_quorum(width); if answered.count < wanted { let count = answered.count; return Err(answered @@ -931,9 +1047,9 @@ impl Client { } Ok(PointerFinality { address: *address, - group: peers.len(), + group: width, answered: answered.count, - status: finality_status(&replies, peers.len()), + status: finality_status(&replies, width, answered.count), }) } @@ -1131,11 +1247,16 @@ impl Client { } /// Whether the network has moved past `record`: a read returns a state - /// that replaces it. A read that fails says nothing, and counts as no. + /// that replaces it, or, for a final state, a different final state, + /// which nothing replaces either and which the group settled on instead. + /// A read that fails says nothing, and counts as no. async fn moved_past(&self, record: &Pointer) -> bool { matches!( self.pointer_get(&record.address()).await, Ok(Some(current)) if current.replaces(record) + || (record.is_terminal() + && current.is_terminal() + && current.state_id() != record.state_id()) ) } @@ -1237,6 +1358,12 @@ impl Client { /// 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> { + self.pointer_read(address).await.map(|read| read.current) + } + + /// [`Self::pointer_get`], also naming a final state a peer reported that + /// the read does not return. + async fn pointer_read(&self, address: &XorName) -> Result { let peers = self.pointer_group(address).await?; // Ask the whole close group at once and keep the best answer. @@ -1302,7 +1429,19 @@ impl Client { answered.count ))); } - Ok(corroborated) + let unreturned_final = replies + .finals() + .map(|(record, _)| record) + .find(|record| { + corroborated + .as_ref() + .is_none_or(|current| current.state_id() != record.state_id()) + }) + .cloned(); + Ok(PointerRead { + current: corroborated, + unreturned_final, + }) } /// Follow a pointer chain to the chunk it ends at. @@ -1429,6 +1568,7 @@ mod tests { use super::*; use ant_protocol::pqc::api::ml_dsa_65; use futures::future::BoxFuture; + use std::collections::HashMap; fn keypair(seed: u8) -> (MlDsaPublicKey, MlDsaSecretKey) { ml_dsa_65().generate_keypair_from_seed(&[seed; 32]) @@ -2323,7 +2463,7 @@ mod tests { let frozen = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk_target(5)).expect("sign"); let group = 7; - let open = finality_status(&tally(vec![Some(before.clone()); 7]), group); + let open = finality_status(&tally(vec![Some(before.clone()); 7]), group, group); assert_eq!(open, FinalityStatus::Open { counter: Some(0) }); let status = finality_status( @@ -2334,8 +2474,10 @@ mod tests { Some(transfer.clone()), Some(before.clone()), None, + None, ]), group, + group, ); match &status { FinalityStatus::Final(state) => { @@ -2353,6 +2495,7 @@ mod tests { Some(before.clone()), ]), group, + 3, ); assert!(matches!(status, FinalityStatus::Settling(ref s) if s.holders == 2)); @@ -2365,6 +2508,7 @@ mod tests { Some(transfer.clone()), ]), group, + 5, ); match status { FinalityStatus::Forked { states, majority } => { @@ -2379,13 +2523,117 @@ mod tests { other => panic!("expected Forked, got {other:?}"), } - let status = finality_status(&tally(vec![Some(frozen.clone()); 5]), group); + let status = finality_status(&tally(vec![Some(frozen.clone()); 7]), group, group); match status { FinalityStatus::Final(state) => assert_eq!(state.transferred_to(), None), other => panic!("expected a frozen Final, got {other:?}"), } } + /// Four of seven report the transfer and three do not answer. One of the + /// three could hold a rival, so this is not yet final; a recipient that + /// relied on it could be relying on the minority side of a fork. + #[test] + fn a_majority_is_not_final_while_part_of_the_group_is_silent() { + let (pk, sk) = keypair(47); + let transfer = handed_to(&pk, &sk, 0x77); + let status = finality_status(&tally(vec![Some(transfer.clone()); 4]), 7, 4); + assert!( + matches!(&status, FinalityStatus::Unconfirmed(state) if state.holders == 4), + "got {status:?}" + ); + let finality = PointerFinality { + address: transfer.address(), + group: 7, + answered: 4, + status, + }; + assert!(!finality.is_final()); + assert_eq!(finality.transferred_to(), None); + + // And a rival already seen still refuses a transfer it would fork. + let other = handed_to(&pk, &sk, 0x01); + assert!(matches!( + rival_final(&finality, &other), + Some(Error::PointerFinal(_)) + )); + assert!(rival_final(&finality, &transfer).is_none()); + } + + /// The recipient walk, over pointers held in a map. + async fn walk( + chain: &HashMap, + address: XorName, + recipient: XorName, + ) -> Result<()> { + check_recipient_chain(&address, &recipient, |at| { + let found = chain.get(&at).cloned(); + async move { Ok(found) } + }) + .await + } + + /// A chain of `hops` pointers from `[1; 32]`, each targeting the next, + /// the last targeting `end`. + fn chain_to(hops: u8, end: PointerTarget) -> HashMap { + (1..=hops) + .map(|hop| { + let (pk, sk) = keypair(hop); + let target = if hop == hops { + end + } else { + PointerTarget::new( + PointerTargetKind::Pointer, + pointer_address(&keypair(hop + 1).0), + ) + }; + let record = Pointer::create(&sk, &pk, target).expect("create"); + (record.address(), record) + }) + .collect() + } + + /// Readers of the source read it and then every recipient hop, so the + /// recipient's chain gets one hop fewer than a resolve allows. A chain + /// that returns to the source at the very last hop is a cycle, and one + /// too long to follow is refused rather than let through unchecked. + #[tokio::test] + async fn a_recipient_chain_is_checked_to_the_last_hop_a_reader_takes() { + let (source_pk, _) = keypair(200); + let source = pointer_address(&source_pk); + let back = PointerTarget::new(PointerTargetKind::Pointer, source); + let first = pointer_address(&keypair(1).0); + let allowed = u8::try_from(MAX_POINTER_RESOLVE_DEPTH - 1).expect("small"); + + let ends = chain_to(allowed, chunk_target(9)); + assert!( + walk(&ends, source, first).await.is_ok(), + "the longest chain a reader can follow" + ); + + let cycles_at_the_end = chain_to(allowed, back); + assert!( + matches!(walk(&cycles_at_the_end, source, first).await, Err(Error::InvalidData(m)) if m.contains("cycle")), + "a chain back to the source at its last hop is a cycle" + ); + + let too_long = chain_to(allowed + 1, chunk_target(9)); + assert!( + matches!(walk(&too_long, source, first).await, Err(Error::InvalidData(m)) if m.contains("longer")), + "a chain no reader could follow is refused" + ); + + // One hop longer and back to the source: the walk runs out exactly + // as it reaches the source, which must not pass as no cycle. + let cycles_past_the_end = chain_to(allowed + 1, back); + assert!(walk(&cycles_past_the_end, source, first).await.is_err()); + + let nowhere = HashMap::new(); + assert!( + matches!(walk(&nowhere, source, first).await, Err(Error::InvalidData(m)) if m.contains("does not exist")), + ); + } + /// A transfer refused because the pointer was already final on another /// state is reported as that, and one that landed is not a refusal. #[test] diff --git a/ant-core/src/data/error.rs b/ant-core/src/data/error.rs index 524d797b..f918ac6f 100644 --- a/ant-core/src/data/error.rs +++ b/ant-core/src/data/error.rs @@ -190,24 +190,27 @@ pub enum Error { max_leaves: usize, }, - /// Cost estimation could not reach a representative quote. - /// /// The pointer is final: its state is at the final counter, so nothing /// can update it or hand it over again (ADR-0018 in `ant-node`). /// - /// Raised before anything is paid. + /// Raised before anything is paid when a read or the finality check shows + /// it. A transfer that was paid for and then refused because the group + /// already holds a different final state is reported as this too. #[error("pointer is final: {0}")] PointerFinal(String), - /// The pointer's owner signed two or more different final states and no - /// one of them is held by a majority of the close group, so there is no - /// answer to give (ADR-0018 in `ant-node`). + /// The pointer's owner signed two or more different final states + /// (ADR-0018 in `ant-node`). A read raises it when no one of them is held + /// by a majority of the close group, so there is no answer to give; a + /// transfer raises it whenever it sees more than one. /// /// Not a transient failure: nodes keep the final state they took first, /// so a retry sees the same split. #[error("pointer is forked: {0}")] PointerForked(String), + /// Cost estimation could not reach a representative quote. + /// /// Returned by [`crate::data::Client::estimate_upload_cost`] when every /// sampled chunk address reported `AlreadyStored`, so the network price /// for the remainder of the file cannot be inferred from a sample. diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs index 1dfc4aa3..d60af180 100644 --- a/ant-core/src/data/mod.rs +++ b/ant-core/src/data/mod.rs @@ -54,7 +54,9 @@ pub use client::merkle::{ finalize_merkle_batch, MerkleBatchPaymentResult, PaymentMode, PreparedMerkleBatch, DEFAULT_MERKLE_THRESHOLD, }; -pub use client::pointer::{FinalState, FinalityStatus, PointerController, PointerFinality}; +pub use client::pointer::{ + FinalState, FinalityStatus, PointerController, PointerFinality, PointerTransfer, +}; // Re-export self-encryption types pub use self_encryption::DataMap; diff --git a/ant-core/tests/e2e_pointer.rs b/ant-core/tests/e2e_pointer.rs index 6e25fd6c..9e2dc6c4 100644 --- a/ant-core/tests/e2e_pointer.rs +++ b/ant-core/tests/e2e_pointer.rs @@ -1163,7 +1163,7 @@ async fn a_transfer_hands_the_address_over_for_good() { ); // The transfer itself. - let finality = client + let transfer = client .pointer_transfer(&sk, &pk, recipient) .await .expect("the transfer lands"); @@ -1171,6 +1171,8 @@ async fn a_transfer_hands_the_address_over_for_good() { balance(&client).await < paid_for_loop, "a transfer is paid for" ); + assert_eq!(transfer.address, address); + let finality = transfer.finality.expect("the group can be asked"); assert_eq!(finality.address, address); assert!(finality.is_final(), "got {:?}", finality.status); assert_eq!(finality.transferred_to(), Some(recipient)); @@ -1271,6 +1273,64 @@ async fn a_transfer_hands_the_address_over_for_good() { testnet.teardown().await; } +/// A transfer that fell short left its final state on one node. A read does +/// not return a state only one peer holds, so a second transfer built on a +/// read would sign past it, pay, and fork the pointer for good on the nodes +/// that never heard of the first. The whole group is asked first, and the +/// second transfer is refused before anything is paid. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_final_state_left_on_one_node_refuses_a_second_transfer_before_paying() { + let (client, testnet) = setup().await; + let (pk, sk) = owner(); + let (recipient_pk, recipient_sk) = owner(); + let address = client + .pointer_create(&sk, &pk, chunk_target(1)) + .await + .expect("create the pointer"); + let recipient = client + .pointer_create(&recipient_sk, &recipient_pk, chunk_target(2)) + .await + .expect("the recipient creates its own pointer"); + + let group = close_group(&client, &testnet, &address).await; + let first = Pointer::sign( + &sk, + &pk, + FINAL_COUNTER, + PointerTarget::new(PointerTargetKind::Pointer, [0x42; 32]), + ) + .expect("sign"); + let (one, _) = group.split_at(1); + store_on(&client, one, &first).await; + assert_eq!( + client + .pointer_get(&address) + .await + .expect("get") + .expect("present") + .counter(), + 0, + "a read does not return a state one node holds" + ); + + let before = balance(&client).await; + let second = client.pointer_transfer(&sk, &pk, recipient).await; + assert!( + matches!(&second, Err(Error::PointerFinal(m)) if m.contains(&hex::encode(first.state_id()))), + "got {second:?}" + ); + assert_eq!(balance(&client).await, before, "nothing was paid"); + assert_eq!( + holders_anywhere(&testnet, &first).await, + 1, + "and nothing else changed" + ); + + drop(client); + testnet.teardown().await; +} + /// The one fork a final state allows: the owner signs two and races them to /// different nodes, each of which keeps what it took first. With a majority /// on one side, reads return it and the finality check reports the fork; with From 55b853d88b9e7102b349864acfdfaafc6fac04bf Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 13:19:24 +0900 Subject: [PATCH 03/11] chore(deps): pin ant-node at the head of its companion change The node change now keeps both sides of a proven fork and shares one finality look among replays queued behind it. --- Cargo.lock | 2 +- ant-core/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c37c6f3..a4010378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=092493d8a694213f086bca0329b38ef383fb56aa#092493d8a694213f086bca0329b38ef383fb56aa" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=282cf1405ecc26656ffe0ed969c613f739609c86#282cf1405ecc26656ffe0ed969c613f739609c86" dependencies = [ "ant-protocol", "bao", diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 850336bb..381c1e7c 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "092493d8a694213f086bca0329b38ef383fb56aa", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "282cf1405ecc26656ffe0ed969c613f739609c86", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "092493d8a694213f086bca0329b38ef383fb56aa", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "282cf1405ecc26656ffe0ed969c613f739609c86", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } From 89130fd9e71cda828623f26a80ed6f578a842457 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 14:38:44 +0900 Subject: [PATCH 04/11] fix(pointer): ask the whole group before an update, and trust no silence before a transfer An update read the pointer the ordinary way, which settles once enough peers agree, so a final state an earlier transfer left on one slow peer could go unheard. The owner then signed and paid for an update that peer would never take, splitting the group for good. An update now asks the whole close group first and refuses any final state a peer holds. A transfer treated an incomplete finality check as open, although a peer that did not answer could hold a final state, and payment is settled before nodes can refuse. A transfer now goes ahead only when every peer of the configured group answered. A transfer whose write reached a majority but whose acknowledgements were all lost was reported as failed, and a caller might pay for it again. When the group then shows the transfer's own state held by a majority, it is reported as done. The CLI's JSON for a transfer now always carries the address and the stored state beside the finality check. The README describes the unconfirmed status and what open means when not every peer answered. ant-protocol and ant-node are pinned at the heads of their companion changes, and #208's retry of a node's own storage error is merged in. --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 19 ++- ant-cli/src/commands/data/pointer.rs | 79 +++++---- ant-core/Cargo.toml | 4 +- ant-core/src/data/client/pointer.rs | 247 ++++++++++++++++++++------- ant-core/tests/e2e_pointer.rs | 13 ++ 7 files changed, 262 insertions(+), 106 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4010378..abe49b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=282cf1405ecc26656ffe0ed969c613f739609c86#282cf1405ecc26656ffe0ed969c613f739609c86" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6#b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6" dependencies = [ "ant-protocol", "bao", @@ -965,7 +965,7 @@ dependencies = [ [[package]] name = "ant-protocol" version = "3.1.0" -source = "git+https://github.com/WithAutonomi/ant-protocol?rev=9ad7fce38aa2b79ea6c532985930a3f347ab529b#9ad7fce38aa2b79ea6c532985930a3f347ab529b" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=7218b60533fad8e3ffe357a89f163a38b6dc100d#7218b60533fad8e3ffe357a89f163a38b6dc100d" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 8eb0afa3..c8f08e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,4 @@ resolver = "2" # state that ownership transfer relies on (ADR-0018 in ant-node). Pin the # companion revision until a release includes it. Versions are bumped at # release; this immutable revision remains 3.1.0. -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "9ad7fce38aa2b79ea6c532985930a3f347ab529b" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "7218b60533fad8e3ffe357a89f163a38b6dc100d" } diff --git a/README.md b/README.md index 45943a40..4fd1d7b2 100644 --- a/README.md +++ b/README.md @@ -362,18 +362,25 @@ Follow a chain of pointers to the target at its end. Hand the pointer over for good to the pointer at `RECIPIENT` (hex), which the new owner creates first, with a fresh key for each pointer they receive. -Requires `SECRET_KEY` to pay for the final state. Refused before paying if the -pointer is already final, or if `RECIPIENT` does not exist or leads back to -this pointer. Irreversible once stored. Prints the result of `finality`. +Requires `SECRET_KEY` to pay for the final state. Refused before paying if any +peer of the close group holds a final state for the pointer, if not every peer +of the group answers, or if `RECIPIENT` does not exist or leads back to this +pointer. Irreversible once stored. Prints the stored state and the result of +`finality`; a failed `finality` check after the write does not mean the +transfer failed. #### `ant pointer finality
` Ask every peer of the close group whether a pointer is final, and on what: -- `open` — not final; its owner can still move it. +- `open` — no peer that answered holds a final state; its owner can still + move it. Check `answered`: a peer that did not answer could hold one. - `settling` — one final state, not yet on a majority of the group. -- `final` — one final state, held by a majority, with no rival. The only - status to rely on before treating a transfer as done. +- `unconfirmed` — one final state on a majority, but not every peer answered, + so a rival could be unseen. Ask again. +- `final` — one final state, held by a majority, every peer answered and none + holds a rival. The only status to rely on before treating a transfer as + done. - `forked` — its owner signed more than one final state. Reads return the one a majority holds, or fail if none does. diff --git a/ant-cli/src/commands/data/pointer.rs b/ant-cli/src/commands/data/pointer.rs index 89a6d21f..35437c38 100644 --- a/ant-cli/src/commands/data/pointer.rs +++ b/ant-cli/src/commands/data/pointer.rs @@ -339,59 +339,64 @@ fn final_state_line(state: &FinalState, group: usize) -> String { fn print_transfer(transfer: &PointerTransfer, json: bool) { let address = hex::encode(transfer.address); let state = hex::encode(transfer.state_id); - match &transfer.finality { - Ok(finality) => { - if !json { - println!("transferred: pointer {address}, final state {state}"); - } - print_finality(finality, json); - } - Err(e) if json => println!( + if json { + let (finality, finality_error) = match &transfer.finality { + Ok(finality) => (Some(finality_json(finality)), None), + Err(e) => (None, Some(e.to_string())), + }; + println!( "{}", json!({ "address": address, "state": state, - "finality_error": e.to_string(), + "finality": finality, + "finality_error": finality_error, }) - ), + ); + return; + } + println!("transferred: pointer {address}, final state {state}"); + match &transfer.finality { + Ok(finality) => print_finality(finality, false), Err(e) => { - println!("transferred: pointer {address}, final state {state}"); println!("finality: unavailable ({e}); run `pointer finality {address}` again"); } } } +/// A finality check as JSON: the group, how many answered, and the status. +fn finality_json(finality: &PointerFinality) -> serde_json::Value { + let status = match &finality.status { + FinalityStatus::Open { counter } => json!({ "status": "open", "counter": counter }), + FinalityStatus::Settling(state) => { + json!({ "status": "settling", "state": final_state_json(state) }) + } + FinalityStatus::Final(state) => { + json!({ "status": "final", "state": final_state_json(state) }) + } + FinalityStatus::Unconfirmed(state) => { + json!({ "status": "unconfirmed", "state": final_state_json(state) }) + } + FinalityStatus::Forked { states, majority } => json!({ + "status": "forked", + "states": states.iter().map(final_state_json).collect::>(), + "majority": majority.as_ref().map(final_state_json), + }), + }; + json!({ + "address": hex::encode(finality.address), + "group": finality.group, + "answered": finality.answered, + "finality": status, + }) +} + fn print_finality(finality: &PointerFinality, json: bool) { let address = hex::encode(finality.address); let group = finality.group; let answered = finality.answered; if json { - let status = match &finality.status { - FinalityStatus::Open { counter } => json!({ "status": "open", "counter": counter }), - FinalityStatus::Settling(state) => { - json!({ "status": "settling", "state": final_state_json(state) }) - } - FinalityStatus::Final(state) => { - json!({ "status": "final", "state": final_state_json(state) }) - } - FinalityStatus::Unconfirmed(state) => { - json!({ "status": "unconfirmed", "state": final_state_json(state) }) - } - FinalityStatus::Forked { states, majority } => json!({ - "status": "forked", - "states": states.iter().map(final_state_json).collect::>(), - "majority": majority.as_ref().map(final_state_json), - }), - }; - println!( - "{}", - json!({ - "address": address, - "group": group, - "answered": answered, - "finality": status, - }) - ); + println!("{}", finality_json(finality)); return; } println!("address: {address}"); diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 381c1e7c..65b14eeb 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "282cf1405ecc26656ffe0ed969c613f739609c86", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "282cf1405ecc26656ffe0ed969c613f739609c86", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/src/data/client/pointer.rs b/ant-core/src/data/client/pointer.rs index 38e6865e..6378c73c 100644 --- a/ant-core/src/data/client/pointer.rs +++ b/ant-core/src/data/client/pointer.rs @@ -358,7 +358,10 @@ impl FinalState { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum FinalityStatus { - /// No peer holds a final state: the owner can still move the pointer. + /// No peer that answered holds a final state: the owner can still move + /// the pointer. A peer that did not answer could hold one, so this rules a + /// final state out only when every peer of the group answered + /// ([`PointerFinality::answered`] equals [`PointerFinality::group`]). /// `counter` is the corroborated state's, or `None` if no state is. Open { /// The counter of the state a read would return. @@ -418,16 +421,6 @@ impl PointerFinality { } } -/// A pointer read (see [`Client::pointer_get`]), with what it heard but -/// does not return. -struct PointerRead { - /// What the read returns. - current: Option, - /// A final state some answering peer named that the read does not return: - /// too few peers to decide a read, but signed by the owner all the same. - unreturned_final: Option, -} - /// What [`Client::pointer_transfer`] stored, and what the close group said /// about it afterwards. #[derive(Debug)] @@ -495,6 +488,65 @@ fn rival_final(finality: &PointerFinality, ours: &Pointer) -> Option { } } +/// The error for an update to a pointer on which any peer holds a final +/// state, if that is what `finality` shows. +/// +/// A final state even one peer holds was signed by the owner, and no node +/// holding it will take an update: a transfer or freeze that fell short. +fn final_already(finality: &PointerFinality) -> Option { + let address = hex::encode(finality.address); + match &finality.status { + FinalityStatus::Open { .. } => None, + FinalityStatus::Settling(state) + | FinalityStatus::Final(state) + | FinalityStatus::Unconfirmed(state) => Some(Error::PointerFinal(format!( + "pointer {address} has final state {} on {} of {} peers; an update cannot \ + replace it", + hex::encode(state.state_id), + state.holders, + finality.group + ))), + FinalityStatus::Forked { states, .. } => Some(Error::PointerForked(format!( + "pointer {address} holds {} different final states", + states.len() + ))), + } +} + +/// What a transfer whose write reported failure comes to, given the finality +/// check made after it. +/// +/// A rival final state is the reason, and is named. The transfer's own state +/// held by a majority means the write landed although its acknowledgements +/// were lost: the transfer is stored and final, so it is reported as done, not +/// failed, and nobody pays for it twice. Anything else is the write's own +/// error. +fn after_failed_transfer( + put: Error, + finality: Result, + record: &Pointer, +) -> Result { + let Ok(finality) = finality else { + return Err(put); + }; + if let Some(refusal) = rival_final(&finality, record) { + return Err(refusal); + } + let ours = record.state_id(); + match &finality.status { + FinalityStatus::Final(state) | FinalityStatus::Unconfirmed(state) + if state.state_id == ours => + { + Ok(PointerTransfer { + address: record.address(), + state_id: ours, + finality: Ok(finality), + }) + } + _ => Err(put), + } +} + /// What a finality check makes of a tally over a group of `group` peers, of /// which `answered` answered. fn finality_status(replies: &Replies, group: usize, answered: usize) -> FinalityStatus { @@ -838,10 +890,10 @@ impl Client { /// /// # Errors /// - /// Returns [`Error::PointerFinal`] if the pointer is final, or if any - /// peer the read heard from holds a final state for it, even one too few - /// peers hold for the read to return; otherwise an error if the current - /// record cannot be read or signing fails. + /// Returns [`Error::PointerFinal`] or [`Error::PointerForked`] if any peer + /// of the close group holds a final state for the pointer, even one too + /// few peers hold for a read to return; otherwise an error if the group + /// cannot be asked, the current record cannot be read, or signing fails. pub async fn pointer_sign_update( &self, secret_key: &MlDsaSecretKey, @@ -849,19 +901,18 @@ impl Client { target: PointerTarget, ) -> Result { let address = pointer_address(owner); - let read = self.pointer_read(&address).await?; - // A final state even one answering peer holds was signed by the owner, - // and no node holding it will take this update. That is a transfer or - // freeze that fell short: finish it rather than move the rest of the - // group somewhere those nodes never follow. - if let Some(lone) = read.unreturned_final.as_ref() { - return Err(Error::PointerFinal(format!( - "pointer {} has {} on some of its close group; an update cannot replace it", - hex::encode(address), - describe_final(lone) - ))); + // A final state even one peer holds was signed by the owner, and no + // node holding it will take this update: a transfer or freeze that + // fell short. A read settles once enough peers agree, before a slow + // peer holding it may have answered, so the whole group is asked: + // finish the final state rather than move the rest of the group + // somewhere those nodes never follow. An update can be replaced, so a + // peer that does not answer does not hold it up. + let finality = self.pointer_finality(&address).await?; + if let Some(refusal) = final_already(&finality) { + return Err(refusal); } - match read.current { + match self.pointer_get(&address).await? { Some(current) => current.update(secret_key, target).map_err(|e| match e { PointerError::CounterExhausted => Error::PointerFinal(format!( "pointer {} holds {}; nothing can update it", @@ -912,13 +963,10 @@ impl Client { let address = record.address(); if let Err(e) = self.pointer_put(&record).await { // Nodes refuse a final state when they hold, or their group - // proves, a different one. If that is why, say which. - if let Ok(finality) = self.pointer_finality(&address).await { - if let Some(refusal) = rival_final(&finality, &record) { - return Err(refusal); - } - } - return Err(e); + // proves, a different one; and a write whose acknowledgements + // were lost may have landed all the same. The group says which. + let finality = self.pointer_finality(&address).await; + return after_failed_transfer(e, finality, &record); } Ok(PointerTransfer { address, @@ -943,17 +991,17 @@ impl Client { /// final state past that one would fork the pointer for good. Any final /// state other than the one signed here refuses the transfer; the same /// state, left by an earlier attempt at this very transfer, does not. A - /// peer that does not answer cannot be asked, so this sees every final - /// state the answering peers hold, and the nodes' own look before a final - /// state covers the rest. + /// silent peer could hold one, so a transfer goes ahead only when every + /// peer of the configured close group answered. /// /// # Errors /// /// Returns [`Error::PointerFinal`] if the pointer is already final, or /// [`Error::PointerForked`] if its owner already forked it, /// [`Error::InvalidData`] for a recipient that is this pointer, does not - /// exist or leads back to it, or an error if either pointer cannot be - /// read, the group cannot be asked, or signing fails. + /// exist or leads back to it, [`Error::CloseGroupShortfall`] if not every + /// peer of the group answered, or an error if either pointer cannot be + /// read or signing fails. pub async fn pointer_sign_transfer( &self, secret_key: &MlDsaSecretKey, @@ -988,6 +1036,18 @@ impl Client { if let Some(refusal) = rival_final(&finality, &record) { return Err(refusal); } + // A transfer cannot be taken back, so no final state being seen is + // only enough when every peer of the group was asked and answered. + if finality.answered < finality.group { + return Err(Error::CloseGroupShortfall(format!( + "only {} of the {} peers of pointer {}'s close group answered, and a silent \ + one could hold a final state; a transfer cannot be taken back, so try again \ + when all of them can be asked", + finality.answered, + finality.group, + hex::encode(address) + ))); + } Ok(record) } @@ -1358,12 +1418,6 @@ impl Client { /// 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> { - self.pointer_read(address).await.map(|read| read.current) - } - - /// [`Self::pointer_get`], also naming a final state a peer reported that - /// the read does not return. - async fn pointer_read(&self, address: &XorName) -> Result { let peers = self.pointer_group(address).await?; // Ask the whole close group at once and keep the best answer. @@ -1429,19 +1483,7 @@ impl Client { answered.count ))); } - let unreturned_final = replies - .finals() - .map(|(record, _)| record) - .find(|record| { - corroborated - .as_ref() - .is_none_or(|current| current.state_id() != record.state_id()) - }) - .cloned(); - Ok(PointerRead { - current: corroborated, - unreturned_final, - }) + Ok(corroborated) } /// Follow a pointer chain to the chunk it ends at. @@ -2634,6 +2676,95 @@ mod tests { ); } + /// A transfer whose write reported failure, judged by the group after it: + /// a rival names the refusal, the transfer's own state on a majority is a + /// transfer that landed with its acknowledgements lost, and anything else + /// is the write's own error. + #[test] + fn a_transfer_the_group_holds_is_done_whatever_its_acknowledgements_said() { + let (pk, sk) = keypair(48); + let ours = handed_to(&pk, &sk, 0x77); + let theirs = handed_to(&pk, &sk, 0x01); + let finality = |status, answered| PointerFinality { + address: ours.address(), + group: 7, + answered, + status, + }; + let lost = || Error::Timeout("no acknowledgement".to_string()); + + let landed = after_failed_transfer( + lost(), + Ok(finality(FinalityStatus::Final(FinalState::of(&ours, 6)), 7)), + &ours, + ); + assert!( + matches!(&landed, Ok(done) if done.state_id == ours.state_id()), + "got {landed:?}" + ); + assert!(after_failed_transfer( + lost(), + Ok(finality( + FinalityStatus::Unconfirmed(FinalState::of(&ours, 4)), + 5 + )), + &ours, + ) + .is_ok()); + + assert!(matches!( + after_failed_transfer( + lost(), + Ok(finality( + FinalityStatus::Final(FinalState::of(&theirs, 6)), + 7 + )), + &ours, + ), + Err(Error::PointerFinal(_)) + )); + assert!(matches!( + after_failed_transfer( + lost(), + Ok(finality( + FinalityStatus::Settling(FinalState::of(&ours, 2)), + 7 + )), + &ours, + ), + Err(Error::Timeout(_)) + )); + assert!(matches!( + after_failed_transfer(lost(), Err(Error::Timeout("group".to_string())), &ours), + Err(Error::Timeout(message)) if message == "no acknowledgement" + )); + } + + /// An update is refused for any final state the group shows, however few + /// hold it. + #[test] + fn any_final_state_refuses_an_update() { + let (pk, sk) = keypair(49); + let transfer = handed_to(&pk, &sk, 0x77); + let finality = |status| PointerFinality { + address: transfer.address(), + group: 7, + answered: 7, + status, + }; + assert!(final_already(&finality(FinalityStatus::Open { counter: Some(3) })).is_none()); + for status in [ + FinalityStatus::Settling(FinalState::of(&transfer, 1)), + FinalityStatus::Unconfirmed(FinalState::of(&transfer, 4)), + FinalityStatus::Final(FinalState::of(&transfer, 7)), + ] { + assert!(matches!( + final_already(&finality(status)), + Some(Error::PointerFinal(_)) + )); + } + } + /// A transfer refused because the pointer was already final on another /// state is reported as that, and one that landed is not a refusal. #[test] diff --git a/ant-core/tests/e2e_pointer.rs b/ant-core/tests/e2e_pointer.rs index 9e2dc6c4..cedccfc6 100644 --- a/ant-core/tests/e2e_pointer.rs +++ b/ant-core/tests/e2e_pointer.rs @@ -1321,6 +1321,19 @@ async fn a_final_state_left_on_one_node_refuses_a_second_transfer_before_paying( "got {second:?}" ); assert_eq!(balance(&client).await, before, "nothing was paid"); + + // Nor will the owner update past it: the node holding it would never + // follow, so the group would split for good. + let update = client.pointer_update(&sk, &pk, chunk_target(3)).await; + assert!( + matches!(&update, Err(Error::PointerFinal(m)) if m.contains(&hex::encode(first.state_id()))), + "got {update:?}" + ); + assert_eq!( + balance(&client).await, + before, + "nothing was paid for the update" + ); assert_eq!( holders_anywhere(&testnet, &first).await, 1, From 736d4effc3a06c44a0153b78b158c251fe363bbd Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 15:14:53 +0900 Subject: [PATCH 05/11] fix(pointer): a browser transfer whose acknowledgements were lost is done, and one recovery path for both A browser transfer reported failure whenever its write did, even when every node had stored the final state and only the acknowledgements were lost, so a page could tell its user an irreversible transfer had failed. The browser now asks the group after a failed final write, exactly as a native transfer does, through one shared recovery path, and reports the transfer as done when the group holds it on a majority. The changelog now lists the unconfirmed finality status and no longer says the former owner's key can change nothing: no node that holds the final state gives it up. ant-node is pinned at the head of its companion change. --- CHANGELOG.md | 13 ++++--- Cargo.lock | 2 +- ant-core/Cargo.toml | 4 +- .../src/browser/wasm_transport/pointer.rs | 16 ++++++-- ant-core/src/data/client/pointer.rs | 37 ++++++++++++++----- ant-core/wasm-tests/pointer.test.mjs | 26 +++++++++++++ 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25017134..e0f879d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Pointer ownership transfer (ADR-0018 in `ant-node`). `ant pointer transfer --key ` signs the pointer's final state, at counter `u64::MAX`, pointing at the new - owner's pointer: readers of the address are redirected there, the address stays the same, and - the former owner's key can change nothing, because a final state is replaced by nothing. It is - refused before paying if the pointer is already final, or if the recipient does not exist or - leads back. `ant pointer finality
` asks the whole close group whether a pointer is - `open`, `settling`, `final` or `forked`, which is what a recipient checks before relying on a - transfer, and `ant pointer controller
` follows transfers to the pointer that now + owner's pointer: readers of the address are redirected there by every node that holds it, the + address stays the same, and no node that holds it gives it up, because a final state is + replaced by nothing. It is refused before paying if any peer of the close group holds a final + state for the pointer, if not every peer answers, or if the recipient does not exist or leads + back. `ant pointer finality
` asks the whole close group whether a pointer is `open`, + `settling`, `unconfirmed` (final on a majority, but not every peer answered), `final` or + `forked`, which is what a recipient checks before relying on a transfer, and `ant pointer controller
` follows transfers to the pointer that now decides. In `ant-core`: `Client::pointer_transfer`, `pointer_sign_transfer`, `pointer_finality` and `pointer_controller`, and the new `Error::PointerFinal` and `Error::PointerForked`. In the browser: `transferPointer` and `pointerFinality`. diff --git a/Cargo.lock b/Cargo.lock index abe49b02..26f455c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6#b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=27072923a2d482c3a6be737723153c07c398e95f#27072923a2d482c3a6be737723153c07c398e95f" dependencies = [ "ant-protocol", "bao", diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 65b14eeb..5e1d029b 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "27072923a2d482c3a6be737723153c07c398e95f", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "b13e9db56496c480c29b9fe9bbe9f9faacd3ffe6", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "27072923a2d482c3a6be737723153c07c398e95f", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 6be6d5b0..36813923 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -448,10 +448,18 @@ impl BrowserNetworkClient { crate::runtime::timeout(ON_PAID_TIMEOUT, hand_over_paid(on_paid, record, &proof)) .await; } - client - .pointer_put_paid(record, proof) - .await - .map_err(|error| error.to_string())?; + if let Err(error) = client.pointer_put_paid(record, proof).await { + // A final state cannot be taken back, and a write whose + // acknowledgements were all lost may have landed anyway; the + // group says, as it does for a native transfer. + if !record.is_terminal() { + return Err(error.to_string()); + } + client + .recover_final_write(record, error) + .await + .map_err(|error| error.to_string())?; + } Ok(BrowserPointerWrite { pointer: BrowserPointer::from(record), transaction_hash: submission.transaction_hash, diff --git a/ant-core/src/data/client/pointer.rs b/ant-core/src/data/client/pointer.rs index 6378c73c..24e66c8a 100644 --- a/ant-core/src/data/client/pointer.rs +++ b/ant-core/src/data/client/pointer.rs @@ -525,7 +525,7 @@ fn after_failed_transfer( put: Error, finality: Result, record: &Pointer, -) -> Result { +) -> Result { let Ok(finality) = finality else { return Err(put); }; @@ -537,11 +537,7 @@ fn after_failed_transfer( FinalityStatus::Final(state) | FinalityStatus::Unconfirmed(state) if state.state_id == ours => { - Ok(PointerTransfer { - address: record.address(), - state_id: ours, - finality: Ok(finality), - }) + Ok(finality) } _ => Err(put), } @@ -965,8 +961,12 @@ impl Client { // Nodes refuse a final state when they hold, or their group // proves, a different one; and a write whose acknowledgements // were lost may have landed all the same. The group says which. - let finality = self.pointer_finality(&address).await; - return after_failed_transfer(e, finality, &record); + let finality = self.recover_final_write(&record, e).await?; + return Ok(PointerTransfer { + address, + state_id: record.state_id(), + finality: Ok(finality), + }); } Ok(PointerTransfer { address, @@ -975,6 +975,23 @@ impl Client { }) } + /// Whether the final state `record`, whose write reported `error`, landed + /// anyway: the group holding it on a majority means its acknowledgements + /// were lost, not the write. See [`after_failed_transfer`]. + /// + /// # Errors + /// + /// The rival final state that refused it, or `error` itself when the + /// group does not show the record held by a majority. + pub(crate) async fn recover_final_write( + &self, + record: &Pointer, + error: Error, + ) -> Result { + let finality = self.pointer_finality(&record.address()).await; + after_failed_transfer(error, finality, record) + } + /// Sign the final state that hands `owner`'s pointer over to the pointer /// at `recipient`, without storing it. /// @@ -2699,7 +2716,9 @@ mod tests { &ours, ); assert!( - matches!(&landed, Ok(done) if done.state_id == ours.state_id()), + matches!(&landed, Ok(finality) + if matches!(&finality.status, FinalityStatus::Final(state) + if state.state_id == ours.state_id())), "got {landed:?}" ); assert!(after_failed_transfer( diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index ad4c1e3c..2a3ffc4f 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -333,6 +333,32 @@ test("nodes that cannot store just then do not end a paid write", async () => { } }); +test("a transfer whose acknowledgements are all lost is reported as done", async () => { + // Every node stores the transfer and drops its answer, every round. + let dropPuts = false; + const nodes = Array.from({ length: 7 }, () => ({ + respond: (_channel, method) => !(dropPuts && method === "put_pointer"), + })); + const rtc = mockWebRtc(nodes); + const client = new BrowserNetworkClient(rtc.endpoints); + const recipientSeed = new Uint8Array(32).fill(0x55); + try { + await client.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay); + const recipient = await client.createPointer(recipientSeed, target(2), "chunk", + paymentNetwork, wallet().pay); + dropPuts = true; + const signer = wallet(); + const done = await client.transferPointer(seed, recipient.pointer.address, paymentNetwork, + signer.pay); + assert.equal(done.pointer.address, pointerAddress(seed)); + assert.equal(signer.calls.length, 1, "paid once"); + const finality = await client.pointerFinality(pointerAddress(seed)); + assert.equal(finality.status, "final"); + } 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); From 89864ad5edd9421924e8d5c9857239269586e2fc Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 16:08:55 +0900 Subject: [PATCH 06/11] fix(pointer): a transfer stored again from its journal is done when the group holds it A page that journals a paid transfer through onPaid and stores it again after a reload goes through storePaidPointer, which reported failure when every acknowledgement was lost although the transfer had landed. It now takes the same recovery path as a transfer written the first time, and a browser test covers it. ant-node is pinned at the head of its companion change. --- Cargo.lock | 2 +- ant-core/Cargo.toml | 4 +-- .../src/browser/wasm_transport/pointer.rs | 16 +++++++--- ant-core/wasm-tests/pointer.test.mjs | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26f455c8..6d745b07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=27072923a2d482c3a6be737723153c07c398e95f#27072923a2d482c3a6be737723153c07c398e95f" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=7764c237c4eff2704d2c1e52cea4cbdea36974a9#7764c237c4eff2704d2c1e52cea4cbdea36974a9" dependencies = [ "ant-protocol", "bao", diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 5e1d029b..af6ccc56 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "27072923a2d482c3a6be737723153c07c398e95f", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "7764c237c4eff2704d2c1e52cea4cbdea36974a9", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "27072923a2d482c3a6be737723153c07c398e95f", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "7764c237c4eff2704d2c1e52cea4cbdea36974a9", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 36813923..3a0180f4 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -361,10 +361,18 @@ impl BrowserNetworkClient { 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()))?; + let client = self.paying_client(&payment_network); + if let Err(error) = client.pointer_put_paid(&record, proof.to_vec()).await { + // As for a transfer written the first time: a final state whose + // acknowledgements were all lost may have landed anyway. + if !record.is_terminal() { + return Err(JsValue::from_str(&error.to_string())); + } + client + .recover_final_write(&record, error) + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + } to_js(&BrowserPointer::from(&record)) } } diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index 2a3ffc4f..0a0de407 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -359,6 +359,38 @@ test("a transfer whose acknowledgements are all lost is reported as done", async } }); +test("a transfer stored again from onPaid is done even if every acknowledgement is lost", async () => { + // The page journals a paid transfer through onPaid. + const elsewhere = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const first = new BrowserNetworkClient(elsewhere.endpoints); + const recipientSeed = new Uint8Array(32).fill(0x56); + let paid; + try { + await first.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay); + const recipient = await first.createPointer(recipientSeed, target(2), "chunk", + paymentNetwork, wallet().pay); + await first.transferPointer(seed, recipient.pointer.address, paymentNetwork, wallet().pay, + value => { paid = value; }); + } finally { + first.close(); + } + assert.ok(paid, "the paid transfer was handed over"); + + // Stored again from the journal on a group that stores it and loses every + // acknowledgement. + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({ + respond: (_channel, method) => method !== "put_pointer", + }))); + const client = new BrowserNetworkClient(rtc.endpoints); + try { + const stored = await client.storePaidPointer(paid.record, paid.proof, paymentNetwork); + assert.equal(stored.address, pointerAddress(seed)); + assert.equal((await client.pointerFinality(pointerAddress(seed))).status, "final"); + } 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); From 7271b136036ed9e64d52821776f64cb2d433a188 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 16:39:43 +0900 Subject: [PATCH 07/11] chore(deps): pin ant-node at the head of its companion change The node change now counts repair votes by the whole state and drops a summary about another address. --- Cargo.lock | 2 +- ant-core/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d745b07..7a13d311 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=7764c237c4eff2704d2c1e52cea4cbdea36974a9#7764c237c4eff2704d2c1e52cea4cbdea36974a9" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=0917c5fa42f2ac9077e349dd92e80ac63bd018a1#0917c5fa42f2ac9077e349dd92e80ac63bd018a1" dependencies = [ "ant-protocol", "bao", diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index af6ccc56..73b9f0a9 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "7764c237c4eff2704d2c1e52cea4cbdea36974a9", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "0917c5fa42f2ac9077e349dd92e80ac63bd018a1", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "7764c237c4eff2704d2c1e52cea4cbdea36974a9", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "0917c5fa42f2ac9077e349dd92e80ac63bd018a1", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } From 0e538e585c7c5a43396b590a4b56f74553c9dff6 Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 17:21:38 +0900 Subject: [PATCH 08/11] chore(deps): pin ant-node at the head of its companion change The node change now names the remembered final state when it refuses a rival. --- Cargo.lock | 2 +- ant-core/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a13d311..aba6df48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=0917c5fa42f2ac9077e349dd92e80ac63bd018a1#0917c5fa42f2ac9077e349dd92e80ac63bd018a1" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=98b5bfb77584dc8c37ee92675724ae7aafb8c502#98b5bfb77584dc8c37ee92675724ae7aafb8c502" dependencies = [ "ant-protocol", "bao", diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 73b9f0a9..c374c82d 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "0917c5fa42f2ac9077e349dd92e80ac63bd018a1", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "98b5bfb77584dc8c37ee92675724ae7aafb8c502", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "0917c5fa42f2ac9077e349dd92e80ac63bd018a1", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "98b5bfb77584dc8c37ee92675724ae7aafb8c502", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } From 6c394a60d76269ecf77dd856f3c7175c3061dde7 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 2 Oct 2026 11:31:50 +0900 Subject: [PATCH 09/11] chore(deps): pin ant-protocol and ant-node at the heads of their companion changes --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- ant-core/Cargo.toml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aba6df48..413536cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=98b5bfb77584dc8c37ee92675724ae7aafb8c502#98b5bfb77584dc8c37ee92675724ae7aafb8c502" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=99eee0955f766b2351506bc0f0e31f5974c4656c#99eee0955f766b2351506bc0f0e31f5974c4656c" dependencies = [ "ant-protocol", "bao", @@ -965,7 +965,7 @@ dependencies = [ [[package]] name = "ant-protocol" version = "3.1.0" -source = "git+https://github.com/WithAutonomi/ant-protocol?rev=7218b60533fad8e3ffe357a89f163a38b6dc100d#7218b60533fad8e3ffe357a89f163a38b6dc100d" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=ab459e52b3db19c0db15ed33a97fe6282dfd4ae0#ab459e52b3db19c0db15ed33a97fe6282dfd4ae0" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index c8f08e78..6a5a9531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,4 @@ resolver = "2" # state that ownership transfer relies on (ADR-0018 in ant-node). Pin the # companion revision until a release includes it. Versions are bumped at # release; this immutable revision remains 3.1.0. -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "7218b60533fad8e3ffe357a89f163a38b6dc100d" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "ab459e52b3db19c0db15ed33a97fe6282dfd4ae0" } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index c374c82d..76fd81bc 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "98b5bfb77584dc8c37ee92675724ae7aafb8c502", optional = true } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "99eee0955f766b2351506bc0f0e31f5974c4656c", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "98b5bfb77584dc8c37ee92675724ae7aafb8c502", features = ["test-utils"] } +ant-node = { version = "0.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "99eee0955f766b2351506bc0f0e31f5974c4656c", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } From ee81ca90c513a27d5f848e6808ea0f9ae4ebea73 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 2 Oct 2026 11:28:50 +0900 Subject: [PATCH 10/11] test(pointer): hand a pointer over in real Chromium, and check it is final The real-Chromium suite created, updated, read and resolved pointers on real nodes, but never called transferPointer or pointerFinality, so the browser half of a transfer was covered only against mocked peers. The pointer test now also hands its pointer over to the second pointer it wrote, then asks the whole group for its finality and expects one final state, held by a majority, redirecting to that pointer. CI pins this suite's nodes to an earlier node revision, before the rule that keeps the first final state, so there it also shows a client of this change handing a pointer over on nodes that do not have it yet. The README no longer says the native ant-node dependency has no git revision: this change pins one. --- ant-core/browser-tests/README.md | 6 +++--- ant-core/browser-tests/harness.js | 11 ++++++++++- ant-core/browser-tests/webrtc.spec.js | 7 +++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ant-core/browser-tests/README.md b/ant-core/browser-tests/README.md index 32a1ebd2..2cf3c686 100644 --- a/ant-core/browser-tests/README.md +++ b/ant-core/browser-tests/README.md @@ -61,9 +61,9 @@ network, so the suite checks this client against older nodes. It is currently the 0.21.0 release: its one runtime change renames `fetch_update` to `try_update`, the same method. CI builds the pin without `-D warnings`, because each new toolchain can add warnings to an old release. This integration pin is -separate from the native `ant-node` registry dependency, which has no git -`rev`. Update the pin only with a passing paid browser integration run against -the replacement. In CI, the run fails unless every test ran and passed: +separate from the native `ant-node` dependency in `ant-core/Cargo.toml`. Update +the pin only with a passing paid browser integration run against the +replacement. In CI, the run fails unless every test ran and passed: `run-guard.js` fails a run with a skipped or unfinished test, and `.only` is rejected. Anvil must be installed and available on PATH. diff --git a/ant-core/browser-tests/harness.js b/ant-core/browser-tests/harness.js index 19e7a44b..c8b8f677 100644 --- a/ant-core/browser-tests/harness.js +++ b/ant-core/browser-tests/harness.js @@ -90,9 +90,18 @@ globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => { const resolved = await reader.resolvePointer(created.pointer.address); const absent = await reader.getPointer(chunk(0x42)); reader.close(); reader.free(); + // Hand the pointer over for good to the one `inner` controls (ADR-0018 in + // ant-node), then ask the whole group whether that is final. + const handover = new BrowserNetworkClient([endpoint]); + const transferred = await paced(() => handover.transferPointer(seed, end.pointer.address, payment, pay)); + handover.close(); handover.free(); + const checker = new BrowserNetworkClient([endpoint]); + const finality = await checker.pointerFinality(created.pointer.address); + checker.close(); checker.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 }; + resolved, absent, transferredCounter: transferred.pointer.counter, + finalityStatus: finality.status, finalityTransferredTo: finality.majority?.transferredTo }; }; diff --git a/ant-core/browser-tests/webrtc.spec.js b/ant-core/browser-tests/webrtc.spec.js index 0ec75d28..fb3be058 100644 --- a/ant-core/browser-tests/webrtc.spec.js +++ b/ant-core/browser-tests/webrtc.spec.js @@ -14,7 +14,7 @@ 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 }) => { +test("real Chromium creates, updates, reads, resolves and hands over 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)); @@ -23,12 +23,15 @@ test("real Chromium creates, updates, reads and resolves pointers on real nodes" 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.payments).toBe(5); 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(result.transferredCounter).toBe("18446744073709551615"); + expect(result.finalityStatus).toBe("final"); + expect(result.finalityTransferredTo).toBe(result.endAddress); expect(errors).toEqual([]); }); From 70052d0b753ac7970b49d46af6987fb41e13c206 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Tue, 13 Oct 2026 22:22:00 +0100 Subject: [PATCH 11/11] chore(deps): point the companion pins at the rebased heads Rebasing this change's companion branches onto `main` rewrote their commits, so the revisions pinned here no longer exist. Point both at the rebased heads. The rebase also carried the companions onto newer releases: ant-protocol is 4.0.0 and ant-node 0.22.0, so the version requirements move with the revisions. The one `ant-protocol` pin and both `ant-node` pins stay on the same version, so the graph still holds a single copy of each. ant-node 0.22.0 finished removing the LMDB chunk store: `ChunkStoreConfig` no longer carries the map size or a `MigrationConfig`, and `MigrationConfig` is gone from `ant_node::storage`. The devnet harness set both and already noted it gave no migration coverage, so it drops them and keeps the fields the file backend reads. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 45 +++++++++++++++++++++++++++-------- Cargo.toml | 6 ++--- ant-core/Cargo.toml | 6 ++--- ant-core/tests/support/mod.rs | 10 ++++---- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 413536cc..d0d96d10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,7 +869,7 @@ dependencies = [ "rand 0.8.6", "reqwest 0.12.28", "rmp-serde", - "saorsa-transport", + "saorsa-transport 0.37.0", "self-replace", "self_encryption", "semver 1.0.28", @@ -912,8 +912,8 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.21.0" -source = "git+https://github.com/WithAutonomi/ant-node.git?rev=99eee0955f766b2351506bc0f0e31f5974c4656c#99eee0955f766b2351506bc0f0e31f5974c4656c" +version = "0.22.0" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=beec6ba48e9a2107fad928c1b642b22655060523#beec6ba48e9a2107fad928c1b642b22655060523" dependencies = [ "ant-protocol", "bao", @@ -934,19 +934,20 @@ dependencies = [ "mimalloc", "objc2", "objc2-foundation", - "page_size", "parking_lot", "postcard", "rand 0.8.6", "reqwest 0.13.4", "rmp-serde", + "rustix", "saorsa-core", "saorsa-pqc", - "saorsa-transport", + "saorsa-transport 0.38.0", "self-replace", "self_encryption", "semver 1.0.28", "serde", + "serde_bytes", "serde_json", "sha2", "tar", @@ -964,8 +965,8 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "3.1.0" -source = "git+https://github.com/WithAutonomi/ant-protocol?rev=ab459e52b3db19c0db15ed33a97fe6282dfd4ae0#ab459e52b3db19c0db15ed33a97fe6282dfd4ae0" +version = "4.0.0" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=718f60682bb3704b68fbba0bea34f2a0416fa876#718f60682bb3704b68fbba0bea34f2a0416fa876" dependencies = [ "blake3", "bytes", @@ -5529,9 +5530,9 @@ dependencies = [ [[package]] name = "saorsa-core" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84923dc4955c42dfad8e06cfc34ef75b8e89d4d2fed048b18b81dc9ebadf790b" +checksum = "efd8c6152246053fa3497fa5e5d1ddc49ec1882fe24cf1ef3e529b03562c3c6a" dependencies = [ "anyhow", "async-trait", @@ -5548,7 +5549,7 @@ dependencies = [ "postcard", "rand 0.8.6", "saorsa-pqc", - "saorsa-transport", + "saorsa-transport 0.38.0", "serde", "serde_json", "tempfile", @@ -5601,6 +5602,30 @@ name = "saorsa-transport" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261ed20ee1ed8555594fef412b428fa20c21d2cbeebfe3f11868c7df9489a410" +dependencies = [ + "anyhow", + "base64", + "blake3", + "chacha20poly1305", + "core-foundation 0.9.4", + "hex", + "libc", + "saorsa-pqc", + "serde", + "serde_bytes", + "serde_json", + "system-configuration", + "thiserror 2.0.18", + "web-time", + "windows 0.61.3", + "zeroize", +] + +[[package]] +name = "saorsa-transport" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f201fce78c610eda6da22ee8d9ad7a84c4a7ffe8e91b5603b6c025f53742ce73" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 6a5a9531..b98dd8a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,8 @@ members = ["ant-core", "ant-cli"] resolver = "2" [patch.crates-io] -# The published ant-protocol 3.1.0 has pointers (ADR-0016) but not the final +# The published ant-protocol 4.0.0 has pointers (ADR-0016) but not the final # state that ownership transfer relies on (ADR-0018 in ant-node). Pin the # companion revision until a release includes it. Versions are bumped at -# release; this immutable revision remains 3.1.0. -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "ab459e52b3db19c0db15ed33a97fe6282dfd4ae0" } +# release; this immutable revision remains 4.0.0. +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "718f60682bb3704b68fbba0bea34f2a0416fa876" } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 76fd81bc..a098d898 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -59,7 +59,7 @@ tower-http = { version = "0.6.8", features = ["cors"], optional = true } # under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for # those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc # deps here or the version can skew between ant-client and ant-node. -ant-protocol = { version = "3.1.0", default-features = false, features = ["rpc"] } +ant-protocol = { version = "4.0.0", default-features = false, features = ["rpc"] } xor_name = { version = "5" } futures = { version = "0.3" } tracing = { version = "0.1" } @@ -81,7 +81,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "99eee0955f766b2351506bc0f0e31f5974c4656c", optional = true } +ant-node = { version = "0.22.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "beec6ba48e9a2107fad928c1b642b22655060523", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Browser bindings are optional so ordinary native consumers do not pull the @@ -172,7 +172,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.21.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "99eee0955f766b2351506bc0f0e31f5974c4656c", features = ["test-utils"] } +ant-node = { version = "0.22.0", git = "https://github.com/WithAutonomi/ant-node.git", rev = "beec6ba48e9a2107fad928c1b642b22655060523", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index f371bfce..c5135ed5 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -26,7 +26,7 @@ use ant_node::payment::{ }; use ant_node::pointer::{service::PointerService, store::PointerStore}; use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; -use ant_node::storage::{AntProtocol, ChunkStore, ChunkStoreConfig, MigrationConfig}; +use ant_node::storage::{AntProtocol, ChunkStore, ChunkStoreConfig}; // 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}; @@ -370,15 +370,13 @@ impl MiniTestnet { // Create the chunk store. ant-node #216 replaced the LMDB chunk store // with one file per chunk; `AntProtocol::new` now takes an - // `Arc`. These nodes start with no legacy environment, so - // the store comes up directly on the file backend and the migration - // never runs — this harness gives no LMDB-to-file migration coverage. + // `Arc`. ant-node 0.22.0 dropped the LMDB map size and the + // migration config along with the old backend, so the store comes up + // on the file backend with nothing to migrate. let storage_config = ChunkStoreConfig { root_dir: data_dir.to_path_buf(), verify_on_read: true, - max_map_size: 0, disk_reserve: 0, - migration: MigrationConfig::default(), }; let storage = Arc::new( ChunkStore::new(storage_config)