diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b78e32..e0f879d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ 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 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`. + - `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 +40,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 6d6dfc0f..0086c02d 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 036f9a2f..b8f2679b 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,9 +912,8 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afebae6037bc8d219f3089305cad8803bde439484aa9d00979486c5ea554271" +version = "0.22.0" +source = "git+https://github.com/WithAutonomi/ant-node.git?rev=beec6ba48e9a2107fad928c1b642b22655060523#beec6ba48e9a2107fad928c1b642b22655060523" dependencies = [ "ant-protocol", "bao", @@ -935,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", @@ -965,9 +965,8 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb1f2e060c4283457a718c094d5fd594efd1d4eeec7cc6ca3ea113df17cce382" +version = "4.0.0" +source = "git+https://github.com/WithAutonomi/ant-protocol?rev=718f60682bb3704b68fbba0bea34f2a0416fa876#718f60682bb3704b68fbba0bea34f2a0416fa876" dependencies = [ "blake3", "bytes", @@ -5543,9 +5542,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", @@ -5562,7 +5561,7 @@ dependencies = [ "postcard", "rand 0.8.6", "saorsa-pqc", - "saorsa-transport", + "saorsa-transport 0.38.0", "serde", "serde_json", "tempfile", @@ -5615,6 +5614,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 f3978b22..b98dd8a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,10 @@ [workspace] members = ["ant-core", "ant-cli"] resolver = "2" + +[patch.crates-io] +# 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 4.0.0. +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "718f60682bb3704b68fbba0bea34f2a0416fa876" } diff --git a/README.md b/README.md index 007e9267..2ade88cd 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,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; @@ -353,6 +355,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 @@ -395,6 +402,45 @@ 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 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` — 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. +- `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. + +``` +$ 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..35437c38 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, PointerTransfer, 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 transfer = client + .pointer_transfer(&secret, &owner, recipient) + .await + .map_err(|e| anyhow::anyhow!("Pointer transfer failed: {e}"))?; + print_transfer(&transfer, 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,129 @@ 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})") +} + +/// 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); + 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": 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!("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 { + println!("{}", finality_json(finality)); + 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::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 { + 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 +564,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 d435fdaf..5aaf890c 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", 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 @@ -175,7 +175,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.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/browser-tests/README.md b/ant-core/browser-tests/README.md index 229ecf45..76acdae2 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([]); }); diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 2eb72557..3a0180f4 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,84 @@ 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"`, `"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 { + 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::Unconfirmed(state) => { + ("unconfirmed", 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 +301,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)] @@ -234,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)) } } @@ -249,6 +384,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 +406,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) @@ -314,10 +456,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/mod.rs b/ant-core/src/data/client/mod.rs index 3e7115da..e46dfaf0 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -304,6 +304,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) @@ -342,6 +344,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 — @@ -1258,6 +1265,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..24e66c8a 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,272 @@ 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 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. + 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: 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 { + /// 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 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, + /// 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, + } + } +} + +/// 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)] +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) + | 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() + ))), + } +} + +/// 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(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 { + 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 && answered >= group => { + FinalityStatus::Final(only.clone()) + } + [only] if only.holders >= majority => FinalityStatus::Unconfirmed(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 { @@ -422,6 +743,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`. /// @@ -496,8 +886,10 @@ impl Client { /// /// # Errors /// - /// Returns an error if the current record cannot be read, the counter is - /// exhausted, 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, @@ -505,15 +897,286 @@ impl Client { target: PointerTarget, ) -> Result { let address = pointer_address(owner); + // 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 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. + /// + /// 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 + /// + /// 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; and a write whose acknowledgements + // were lost may have landed all the same. The group says which. + let finality = self.recover_final_write(&record, e).await?; + return Ok(PointerTransfer { + address, + state_id: record.state_id(), + finality: Ok(finality), + }); + } + Ok(PointerTransfer { + address, + state_id: record.state_id(), + finality: self.pointer_finality(&address).await, + }) + } + + /// 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. + /// + /// 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 + /// 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, [`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, + 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); + 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}")))?; + + let finality = self.pointer_finality(&address).await?; + 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) + } + + /// Refuse a recipient that does not exist or whose chain leads back to + /// `address` (see [`check_recipient_chain`]). + async fn check_recipient(&self, address: &XorName, recipient: &XorName) -> Result<()> { + 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 + /// 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, 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 + /// + /// 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; + + // 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 + .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: width, + answered: answered.count, + status: finality_status(&replies, width, answered.count), + }) + } + + /// 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 @@ -661,11 +1324,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()) ) } @@ -790,7 +1458,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 +1475,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 @@ -946,6 +1627,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]) @@ -1701,6 +2383,479 @@ 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, 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, + None, + ]), + group, + 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, + 3, + ); + 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, + 5, + ); + 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()); 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 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(finality) + if matches!(&finality.status, FinalityStatus::Final(state) + if state.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] + 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 ec3c8b1f..8fea3ece 100644 --- a/ant-core/src/data/error.rs +++ b/ant-core/src/data/error.rs @@ -190,6 +190,25 @@ pub enum Error { max_leaves: usize, }, + /// 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 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 + /// (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 @@ -269,6 +288,8 @@ impl Error { addresses: *addresses, max_leaves: *max_leaves, }, + Self::PointerFinal(m) => Self::PointerFinal(m.clone()), + Self::PointerForked(m) => Self::PointerForked(m.clone()), Self::CostEstimationInconclusive(m) => Self::CostEstimationInconclusive(m.clone()), Self::PartialUpload { stored, diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs index 93805ae0..d60af180 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,9 @@ pub use client::merkle::{ finalize_merkle_batch, MerkleBatchPaymentResult, PaymentMode, PreparedMerkleBatch, DEFAULT_MERKLE_THRESHOLD, }; +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 7dc1e46a..cedccfc6 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,338 @@ 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 transfer = 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!(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)); + + 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; +} + +/// 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"); + + // 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, + "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 +/// 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/tests/support/mod.rs b/ant-core/tests/support/mod.rs index df246676..5f2fc19b 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::{ChunkGetRequest, ChunkMessage, ChunkMessageBody}; @@ -415,15 +415,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) diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index d561ec65..0a0de407 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -333,6 +333,64 @@ 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 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); @@ -352,3 +410,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(); + } +});