From 3c204e6904277504c20f1a6cad90ad17d533d130 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:39:03 +0200 Subject: [PATCH 1/9] feat(browser): export manifests and recover pointer payment plans --- ant-core/src/browser.rs | 23 +++ ant-core/src/browser/file_manifest.rs | 186 ++++++++++++++++++ .../src/browser/wasm_transport/pointer.rs | 95 ++++++++- docs/adr/ADR-0007-browser-api-completion.md | 54 +++++ 4 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 ant-core/src/browser/file_manifest.rs create mode 100644 docs/adr/ADR-0007-browser-api-completion.md diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 2013625d..b9e04395 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -6,6 +6,7 @@ //! host adapter. Higher-level browser integration is owned by the companion //! `ant-client-browser-sdk` project. +pub mod file_manifest; pub mod manifest; pub mod payment; #[cfg(any(all(target_arch = "wasm32", feature = "browser-wasm"), test))] @@ -666,6 +667,28 @@ mod wasm { serde_wasm_bindgen::to_value(&view).map_err(|error| JsValue::from_str(&error.to_string())) } + /// Build canonical `.ant` bytes and a compact share link without network I/O. + #[wasm_bindgen(js_name = createManifest)] + pub fn create_manifest_wasm(input: JsValue) -> Result { + let input: super::file_manifest::ManifestInput = serde_wasm_bindgen::from_value(input) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let result = input.create().map_err(|error| JsValue::from_str(&error))?; + serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Format a public file link using the shared link grammar. + #[wasm_bindgen(js_name = createFileLink)] + pub fn create_file_link_wasm(address: &str) -> Result { + match crate::data::manifest::parse_link(address) + .map_err(|e| JsValue::from_str(&e.to_string()))? + { + crate::data::manifest::Link::File(address) => { + Ok(crate::data::manifest::file_link(&address)) + } + _ => Err(JsValue::from_str("expected a public file address")), + } + } + /// Validate and normalize browser bootstrap and public-file metadata. #[wasm_bindgen(js_name = parseBrowserManifest)] pub fn parse_browser_manifest_wasm(value: JsValue) -> Result { diff --git a/ant-core/src/browser/file_manifest.rs b/ant-core/src/browser/file_manifest.rs new file mode 100644 index 00000000..a3d86e61 --- /dev/null +++ b/ant-core/src/browser/file_manifest.rs @@ -0,0 +1,186 @@ +// Copyright 2026 MaidSafe.net limited. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Browser inputs for the shared manifest encoder. No network or filesystem I/O. + +use super::BrowserManifestView; +use crate::data::manifest::{self, ContentRef, Manifest, ManifestEntry, TorrentReference}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManifestInput { + pub name: Option, + pub torrent: Option, + pub entries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TorrentInput { + pub info_hash_v1: Option, + pub info_hash_v2: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum EntryInput { + Public { + path: Option, + size: Option, + address: String, + }, + Embedded { + path: Option, + size: Option, + #[serde(rename = "dataMap", with = "serde_bytes")] + data_map: Vec, + }, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatedManifest { + pub manifest: BrowserManifestView, + #[serde(with = "serde_bytes")] + pub bytes: Vec, + pub link: String, + pub link_recommended: bool, +} + +impl ManifestInput { + pub fn create(self) -> Result { + if self.entries.len() > manifest::MAX_MANIFEST_ENTRIES { + return Err("too many manifest entries".into()); + } + let torrent = self + .torrent + .map(|input| -> Result<_, String> { + fn hash(value: Option) -> Result, String> { + value + .map(|s| { + hex::decode(s) + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| format!("torrent hash must be {N} bytes")) + }) + .transpose() + } + Ok(TorrentReference { + info_hash_v1: hash(input.info_hash_v1)?, + info_hash_v2: hash(input.info_hash_v2)?, + }) + }) + .transpose()?; + let mut entries = Vec::with_capacity(self.entries.len()); + let mut data_map_bytes = 0usize; + for entry in self.entries { + let (path, size, source) = match entry { + EntryInput::Public { + path, + size, + address, + } => { + let manifest::Link::File(address) = + manifest::parse_link(&address).map_err(|e| e.to_string())? + else { + return Err("public entry requires a file address".into()); + }; + (path, size, ContentRef::Public { address }) + } + EntryInput::Embedded { + path, + size, + data_map, + } => { + data_map_bytes = data_map_bytes + .checked_add(data_map.len()) + .ok_or("DataMap size overflow")?; + if data_map_bytes > manifest::MAX_MANIFEST_BYTES { + return Err("embedded DataMaps exceed the manifest size limit".into()); + } + let decoded = rmp_serde::from_slice(&data_map) + .map_err(|e| format!("invalid DataMap: {e}"))?; + if rmp_serde::to_vec(&decoded).map_err(|e| e.to_string())? != data_map { + return Err("DataMap must use canonical MessagePack encoding".into()); + } + (path, size, ContentRef::Embedded { data_map: decoded }) + } + }; + if size.is_some_and(|n| n > super::manifest::MAX_SAFE_JS_INTEGER) { + return Err("size exceeds JavaScript's safe integer range".into()); + } + entries.push(ManifestEntry { + path, + size, + sha256: None, + source, + }); + } + let mut manifest = Manifest { + name: self.name, + torrent, + entries, + }; + manifest.canonicalize().map_err(|e| e.to_string())?; + let bytes = manifest.encode().map_err(|e| e.to_string())?; + let link_bytes = manifest::manifest_link_bytes(&manifest).map_err(|e| e.to_string())?; + Ok(CreatedManifest { + manifest: BrowserManifestView::from_manifest(&manifest).map_err(|e| e.to_string())?, + bytes, + link: manifest::manifest_link_from_bytes(&link_bytes), + link_recommended: link_bytes.len() <= manifest::MANIFEST_LINK_RECOMMENDED_MAX_BYTES, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_inputs_round_trip_through_native_format() { + let created = ManifestInput { + name: Some("files".into()), + torrent: None, + entries: vec![ + EntryInput::Public { + path: Some("z.txt".into()), + size: Some(20), + address: "ab".repeat(32), + }, + EntryInput::Public { + path: Some("a.txt".into()), + size: None, + address: "cd".repeat(32), + }, + ], + } + .create() + .expect("manifest"); + let decoded = Manifest::decode(&created.bytes).expect("native decode"); + assert_eq!(decoded.entries[0].path.as_deref(), Some("a.txt")); + assert_eq!( + manifest::parse_link(&created.link).expect("link"), + manifest::Link::Manifest(decoded) + ); + assert!(created.link_recommended); + } + + #[test] + fn browser_inputs_use_portable_paths_and_exact_sizes() { + for (path, size) in [("../escape", 10), ("ok", u64::MAX)] { + assert!(ManifestInput { + name: None, + torrent: None, + entries: vec![EntryInput::Public { + path: Some(path.into()), + size: Some(size), + address: "ab".repeat(32), + }] + } + .create() + .is_err()); + } + } +} diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 3a0180f4..fdbc3cd1 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -245,7 +245,8 @@ impl BrowserNetworkClient { /// `on_paid`, if given, is called with `{ record, proof }` once the state /// is paid for and before it is stored. Keep both: if storing fails, or the /// page is lost, [`Self::store_paid_pointer`] stores the same state without - /// paying again. + /// paying again. `on_prepared` is awaited with an opaque record/plan checkpoint + /// before any payment; persist it durably to recover a later receipt. #[wasm_bindgen(js_name = createPointer)] pub async fn create_pointer( &self, @@ -255,6 +256,7 @@ impl BrowserNetworkClient { payment_network: JsValue, pay_for_quotes: js_sys::Function, on_paid: Option, + on_prepared: Option, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -266,6 +268,7 @@ impl BrowserNetworkClient { &payment_network, &pay_for_quotes, on_paid.as_ref(), + on_prepared.as_ref(), ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -284,6 +287,7 @@ impl BrowserNetworkClient { payment_network: JsValue, pay_for_quotes: js_sys::Function, on_paid: Option, + on_prepared: Option, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -295,6 +299,7 @@ impl BrowserNetworkClient { &payment_network, &pay_for_quotes, on_paid.as_ref(), + on_prepared.as_ref(), ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -318,6 +323,7 @@ impl BrowserNetworkClient { payment_network: JsValue, pay_for_quotes: js_sys::Function, on_paid: Option, + on_prepared: Option, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -329,6 +335,7 @@ impl BrowserNetworkClient { &payment_network, &pay_for_quotes, on_paid.as_ref(), + on_prepared.as_ref(), ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -399,6 +406,7 @@ impl BrowserNetworkClient { payment_network: &BrowserPaymentNetwork, pay_for_quotes: &js_sys::Function, on_paid: Option<&js_sys::Function>, + on_prepared: Option<&js_sys::Function>, ) -> Result { let (owner, secret) = owner_keys(owner_seed)?; let target = parse_target(target, kind)?; @@ -413,8 +421,15 @@ impl BrowserNetworkClient { } } .map_err(|error| error.to_string())?; - self.store_pointer(&client, &record, payment_network, pay_for_quotes, on_paid) - .await + self.store_pointer( + &client, + &record, + payment_network, + pay_for_quotes, + on_paid, + on_prepared, + ) + .await } /// A client whose quotes and paid writes go only to nodes on the page's @@ -437,11 +452,27 @@ impl BrowserNetworkClient { payment_network: &BrowserPaymentNetwork, pay_for_quotes: &js_sys::Function, on_paid: Option<&js_sys::Function>, + on_prepared: Option<&js_sys::Function>, ) -> Result { let plan = client .prepare_pointer_payment(record) .await .map_err(|error| error.to_string())?; + if let Some(on_prepared) = on_prepared { + let checkpoint = serde_json::to_string(&PointerPaymentCheckpoint { + version: 1, + record: record.to_bytes(), + plan: plan.clone(), + }) + .map_err(|e| e.to_string())?; + // This callback MUST finish durably before the wallet can be invoked. + let returned = on_prepared + .call1(&JsValue::NULL, &JsValue::from_str(&checkpoint)) + .map_err(js_error_message)?; + JsFuture::from(Promise::resolve(&returned)) + .await + .map_err(js_error_message)?; + } let (transactions, submission) = self .pay_plan(&plan, payment_network, pay_for_quotes) .await?; @@ -481,8 +512,9 @@ impl BrowserNetworkClient { /// is recovered rather than repeated. A pointer write pays one quote and /// keeps no journal of its own: once paid, the page is handed the record /// and proof through `on_paid`, and from then on nothing is paid twice. A - /// page lost between the wallet's broadcast and its receipt pays again on - /// retry. + /// page supplies `on_prepared` to retain the original record and plan before + /// invoking a wallet, and uses `recoverPointerPayment` with the observed + /// receipt after an interruption. Calling a fresh write is a new payment. async fn pay_plan( &self, plan: &ChunkPaymentPlan, @@ -536,3 +568,56 @@ async fn hand_over_paid( JsFuture::from(Promise::resolve(&returned)).await?; Ok(()) } + +/// Opaque recovery input. Contains no owner seed or secret key. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PointerPaymentCheckpoint { + version: u8, + record: Vec, + plan: ChunkPaymentPlan, +} + +/// Rebuild an already-paid proof from the original plan and a confirmed receipt. +/// Does not quote, sign, submit payment, or contact the network. Only use an +/// application's own persisted checkpoint; this never authorizes fresh payment. +#[wasm_bindgen(js_name = recoverPointerPayment)] +pub fn recover_pointer_payment(checkpoint: &str, receipt: JsValue) -> Result { + let recover = || -> Result { + if checkpoint.len() > 4 * 1024 * 1024 { + return Err("pointer checkpoint exceeds 4 MiB".into()); + } + let saved: PointerPaymentCheckpoint = + serde_json::from_str(checkpoint).map_err(|e| e.to_string())?; + if saved.version != 1 { + return Err("unsupported pointer checkpoint version".into()); + } + let record = Pointer::from_bytes(&saved.record).map_err(|e| e.to_string())?; + if saved.plan.address != record.state_id() + || saved.plan.data_size != saved.record.len() as u64 + { + return Err("pointer record differs from its payment plan".into()); + } + let receipt: BrowserPaymentSubmission = + serde_wasm_bindgen::from_value(receipt).map_err(|e| e.to_string())?; + let verified = + wallet_quotes(std::slice::from_ref(&saved.plan)).map_err(|e| e.to_string())?; + let transactions = paid_transactions(&verified, &receipt).map_err(|e| e.to_string())?; + let proof = saved.plan.proof(&transactions).map_err(|e| e.to_string())?; + let paid = js_sys::Object::new(); + js_sys::Reflect::set( + &paid, + &JsValue::from_str("record"), + &Uint8Array::from(saved.record.as_slice()), + ) + .map_err(js_error_message)?; + js_sys::Reflect::set( + &paid, + &JsValue::from_str("proof"), + &Uint8Array::from(proof.as_slice()), + ) + .map_err(js_error_message)?; + Ok(paid.into()) + }; + recover().map_err(|e| JsValue::from_str(&e)) +} diff --git a/docs/adr/ADR-0007-browser-api-completion.md b/docs/adr/ADR-0007-browser-api-completion.md new file mode 100644 index 00000000..e05936ac --- /dev/null +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -0,0 +1,54 @@ +# ADR-0007: Browser manifest creation and pointer payment recovery + +- **Status:** Proposed +- **Date:** 2026-10-15 +- **Decision owners:** Mick +- **Reviewers:** Pending human engineering review +- **Supersedes:** none +- **Superseded by:** none +- **Related:** ADR-0004; ADR-0006 + +## Context + +Browser applications need shared-core manifest exports and pointer operations with recoverable payments. Losing a page after broadcast must not authorize another payment. + +## Decision Drivers + +- Keep protocol validation and payment proofs in Rust. +- Retain durable evidence across failures and cancellation. +- Preserve compatibility with existing readers and wallet adapters. + +## Considered Options + +1. Expose raw bindings and leave each application to reconstruct the protocol. +2. Persist only paid proofs, leaving broadcast-to-confirmation failures unresolved. +3. Typed wrappers and pre-payment checkpoints with observation-only recovery (chosen). + +## Decision + +Export manifest construction through the existing shared format encoder. Extend pointer writes with an awaited pre-payment checkpoint containing the signed record and verified plan. The browser durably journals that checkpoint, submission evidence and receipts before proceeding. Export a recovery function that builds the paid proof from the original plan and receipt, without signing, quoting or submitting another transaction. Existing pointer write arguments remain compatible. A failed pre-payment checkpoint prevents payment; a failed paid checkpoint must not prevent storing an already paid state. + +## Consequences + +### Positive + +- Applications can recover interrupted writes without an automatic second payment. +- Native and browser formats and pointer policy remain shared. + +### Negative / Trade-offs + +- Applications must supply durable persistence and an observation-only recovery provider for unresolved payments. A crash before submission may require explicit verification that nothing was broadcast. +- Checkpoints contain signed records and transaction evidence and must be treated as sensitive application data. + +### Neutral / Operational + +- Additive APIs; no network protocol change. New checkpoint schemas are versioned. +- SDK releases require the companion Rust changes on ant-client main. + +## Validation + +Test malformed manifests, canonical round trips, pending and paid recovery, callback failures, network mismatches, cancellation and real-browser paid pointer writes and streaming saves. Record exact revisions and limitations in a dated audit. Human engineering review is required. + +## Notes for AI-assisted work + +This ADR remains Proposed until human engineering review. From ec5f93168d2ccdd83f68a4a4a0420aa56af7797b Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:43:02 +0200 Subject: [PATCH 2/9] fix(browser): preserve pointer ABI clippy exemptions --- ant-core/src/browser/wasm_transport/pointer.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index fdbc3cd1..67e8ac59 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -247,6 +247,7 @@ impl BrowserNetworkClient { /// page is lost, [`Self::store_paid_pointer`] stores the same state without /// paying again. `on_prepared` is awaited with an opaque record/plan checkpoint /// before any payment; persist it durably to recover a later receipt. + #[allow(clippy::too_many_arguments)] // Preserve the existing positional browser ABI. #[wasm_bindgen(js_name = createPointer)] pub async fn create_pointer( &self, @@ -278,6 +279,7 @@ impl BrowserNetworkClient { /// Point the pointer `owner_seed` controls at `target`, one past the /// counter the network serves (or create it), and pay for the new state. /// `on_paid` is as for [`Self::create_pointer`]. + #[allow(clippy::too_many_arguments)] // Preserve the existing positional browser ABI. #[wasm_bindgen(js_name = updatePointer)] pub async fn update_pointer( &self, From 36b01628bc1a65664e86cbed4252d0f86d1175e1 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:04:17 +0200 Subject: [PATCH 3/9] fix(browser): address review of manifest export and pointer recovery Manifests: - The shared encoder refuses an embedded DataMap without chunks, which the decoder already refused, so no writer can produce an unreadable .ant file or link. - Browser input follows native writer policy: a root DataMap is embedded only up to MAX_EMBEDDED_ROOT_MAP_BYTES, and a size hint must match a root map's exact size. - Torrent hashes use the shared parser; the duplicate entry-count check is gone. - canonicalize() computes effective names once, and the browser view derives each address once. Path-less embedded entries self-encrypt their DataMap far fewer times. - CreatedManifest and the manifest views derive Deserialize. Pointers: - create/update/transfer take a trailing { onPrepared, onPaid } options object instead of a seventh positional callback. A bare function is still taken as onPaid, and unknown options are refused before paying. - The settlement-refusal check runs again just before the wallet is invoked, after onPrepared may have waited. - recoverPointerPayment requires the receipt's transactionHashes to name the checkpoint's quote, no longer needs totalAmount, and reports a newer checkpoint as an unsupported version. - A checkpoint recovery could not read is refused before payment, and the plan is no longer cloned to serialize it. - Documented that onSubmission records nothing for pointer writes, so the wallet must keep the broadcast hash; restored the pay-twice warning. - WASM tests cover checkpoint ordering, callback failure, option validation, the refusal re-check and recovery. Co-Authored-By: Claude Opus 5.5 --- ant-core/src/browser.rs | 19 +- ant-core/src/browser/file_manifest.rs | 320 ++++++++++++++---- .../src/browser/wasm_transport/pointer.rs | 304 +++++++++++------ .../browser/wasm_transport/upload_adapter.rs | 29 +- ant-core/src/data/manifest/mod.rs | 36 +- ant-core/src/data/manifest/wire.rs | 14 + ant-core/wasm-tests/pointer.test.mjs | 125 ++++++- docs/adr/ADR-0007-browser-api-completion.md | 4 +- 8 files changed, 649 insertions(+), 202 deletions(-) diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index b9e04395..94c1a1ff 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -1103,7 +1103,7 @@ pub enum BrowserLinkView { /// A manifest shaped for JavaScript: every entry carries its effective /// name, kind and address, and embedded entries carry the DataMap bytes a /// private download takes. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BrowserManifestView { /// Suggested root directory name. @@ -1116,7 +1116,7 @@ pub struct BrowserManifestView { } /// A manifest's torrent reference shaped for JavaScript. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BrowserTorrentView { /// Hex SHA-1 info hash of a v1 torrent. @@ -1128,7 +1128,7 @@ pub struct BrowserTorrentView { } /// One manifest entry shaped for JavaScript. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BrowserManifestEntryView { /// Effective name: the path, or the hex content address. @@ -1143,11 +1143,11 @@ pub struct BrowserManifestEntryView { #[serde(skip_serializing_if = "Option::is_none")] pub sha256: Option, /// `embedded` or `public`. - pub kind: &'static str, + pub kind: String, /// Hex content address. pub address: String, /// Canonical DataMap bytes for an embedded entry. - #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")] + #[serde(default, skip_serializing_if = "Option::is_none", with = "serde_bytes")] pub data_map: Option>, } @@ -1164,14 +1164,17 @@ impl BrowserManifestView { ), crate::data::manifest::ContentRef::Public { .. } => None, }; + // An embedded entry's address comes from self-encrypting its + // DataMap; derive it once and name a path-less entry by it. + let address = hex::encode(entry.source.content_address()?); entries.push(BrowserManifestEntryView { - name: entry.effective_name()?, + name: entry.path.clone().unwrap_or_else(|| address.clone()), path: entry.path.clone(), size: entry.size, known_size: entry.known_size(), sha256: entry.sha256.map(hex::encode), - kind: entry.source.kind(), - address: hex::encode(entry.source.content_address()?), + kind: entry.source.kind().to_owned(), + address, data_map, }); } diff --git a/ant-core/src/browser/file_manifest.rs b/ant-core/src/browser/file_manifest.rs index a3d86e61..d1038a14 100644 --- a/ant-core/src/browser/file_manifest.rs +++ b/ant-core/src/browser/file_manifest.rs @@ -3,125 +3,102 @@ //! Browser inputs for the shared manifest encoder. No network or filesystem I/O. +use super::manifest::MAX_SAFE_JS_INTEGER; use super::BrowserManifestView; -use crate::data::manifest::{self, ContentRef, Manifest, ManifestEntry, TorrentReference}; +use crate::data::manifest::{ + self, embedded_len, ContentRef, Link, Manifest, ManifestEntry, TorrentReference, + MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, MAX_MANIFEST_BYTES, +}; +use self_encryption::DataMap; use serde::{Deserialize, Serialize}; +#[cfg(test)] +use self_encryption::ChunkInfo; +#[cfg(test)] +use xor_name::XorName; + +/// What `createManifest` takes: metadata the creator supplies. The address of +/// an embedded entry is derived from its DataMap, never taken on trust. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ManifestInput { + /// Suggested root directory name. pub name: Option, + /// The BitTorrent identity of the same files. pub torrent: Option, + /// The files, in any order; encoding sorts them. pub entries: Vec, } +/// Hex BitTorrent info hashes. At least one is required. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct TorrentInput { + /// SHA-1 info hash of a v1 torrent. pub info_hash_v1: Option, + /// SHA-256 info hash of a v2 or hybrid torrent. pub info_hash_v2: Option, } +/// One file of a manifest being created. #[derive(Debug, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] pub enum EntryInput { + /// A public file, by its hex DataMap address or `ant://` file link. Public { + /// Relative path, subject to the portable path rules. path: Option, + /// Plaintext size hint. size: Option, + /// The file's public address. address: String, }, + /// A file by its DataMap, in self_encryption's canonical MessagePack as + /// the browser client hands it out. Grants access to a private file. Embedded { + /// Relative path, subject to the portable path rules. path: Option, + /// Plaintext size; must match a root DataMap's exact size. size: Option, + /// The serialized DataMap. #[serde(rename = "dataMap", with = "serde_bytes")] data_map: Vec, }, } -#[derive(Debug, Serialize)] +/// What `createManifest` returns. +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreatedManifest { + /// The manifest as encoded, entries in canonical order. pub manifest: BrowserManifestView, + /// Canonical `.ant` file bytes. #[serde(with = "serde_bytes")] pub bytes: Vec, + /// `ant://manifest/...` link carrying the manifest's link form. pub link: String, + /// Whether the link is short enough to share in a chat message. A longer + /// link still works; share the `.ant` file instead. pub link_recommended: bool, } impl ManifestInput { + /// Validate the input and encode it with the shared encoder, so the bytes + /// and link are exactly those a native writer produces. pub fn create(self) -> Result { - if self.entries.len() > manifest::MAX_MANIFEST_ENTRIES { - return Err("too many manifest entries".into()); - } - let torrent = self - .torrent - .map(|input| -> Result<_, String> { - fn hash(value: Option) -> Result, String> { - value - .map(|s| { - hex::decode(s) - .map_err(|e| e.to_string())? - .try_into() - .map_err(|_| format!("torrent hash must be {N} bytes")) - }) - .transpose() - } - Ok(TorrentReference { - info_hash_v1: hash(input.info_hash_v1)?, - info_hash_v2: hash(input.info_hash_v2)?, - }) - }) - .transpose()?; - let mut entries = Vec::with_capacity(self.entries.len()); - let mut data_map_bytes = 0usize; - for entry in self.entries { - let (path, size, source) = match entry { - EntryInput::Public { - path, - size, - address, - } => { - let manifest::Link::File(address) = - manifest::parse_link(&address).map_err(|e| e.to_string())? - else { - return Err("public entry requires a file address".into()); - }; - (path, size, ContentRef::Public { address }) - } - EntryInput::Embedded { - path, - size, - data_map, - } => { - data_map_bytes = data_map_bytes - .checked_add(data_map.len()) - .ok_or("DataMap size overflow")?; - if data_map_bytes > manifest::MAX_MANIFEST_BYTES { - return Err("embedded DataMaps exceed the manifest size limit".into()); - } - let decoded = rmp_serde::from_slice(&data_map) - .map_err(|e| format!("invalid DataMap: {e}"))?; - if rmp_serde::to_vec(&decoded).map_err(|e| e.to_string())? != data_map { - return Err("DataMap must use canonical MessagePack encoding".into()); - } - (path, size, ContentRef::Embedded { data_map: decoded }) - } - }; - if size.is_some_and(|n| n > super::manifest::MAX_SAFE_JS_INTEGER) { - return Err("size exceeds JavaScript's safe integer range".into()); - } - entries.push(ManifestEntry { - path, - size, - sha256: None, - source, - }); - } + let torrent = self.torrent.map(TorrentInput::into_reference).transpose()?; + let mut embedded_bytes = 0; + let entries = self + .entries + .into_iter() + .map(|entry| entry.into_entry(&mut embedded_bytes)) + .collect::>()?; let mut manifest = Manifest { name: self.name, torrent, entries, }; + // Sorted here too so the view lists entries in the order the bytes do. manifest.canonicalize().map_err(|e| e.to_string())?; let bytes = manifest.encode().map_err(|e| e.to_string())?; let link_bytes = manifest::manifest_link_bytes(&manifest).map_err(|e| e.to_string())?; @@ -129,15 +106,146 @@ impl ManifestInput { manifest: BrowserManifestView::from_manifest(&manifest).map_err(|e| e.to_string())?, bytes, link: manifest::manifest_link_from_bytes(&link_bytes), - link_recommended: link_bytes.len() <= manifest::MANIFEST_LINK_RECOMMENDED_MAX_BYTES, + link_recommended: link_bytes.len() <= MANIFEST_LINK_RECOMMENDED_MAX_BYTES, + }) + } +} + +impl TorrentInput { + fn into_reference(self) -> Result { + Ok(TorrentReference { + info_hash_v1: torrent_hash(self.info_hash_v1, "infoHashV1", |t| t.info_hash_v1)?, + info_hash_v2: torrent_hash(self.info_hash_v2, "infoHashV2", |t| t.info_hash_v2)?, }) } } +impl EntryInput { + /// The manifest entry. `embedded_bytes` totals the DataMap input so far, + /// so a page cannot make this decode more than a manifest can hold. + fn into_entry(self, embedded_bytes: &mut usize) -> Result { + let entry = match self { + Self::Public { + path, + size, + address, + } => { + let Link::File(address) = + manifest::parse_link(&address).map_err(|e| e.to_string())? + else { + return Err("public entry requires a file address".into()); + }; + ManifestEntry { + path, + size, + sha256: None, + source: ContentRef::Public { address }, + } + } + Self::Embedded { + path, + size, + data_map, + } => { + *embedded_bytes = embedded_bytes + .checked_add(data_map.len()) + .filter(|total| *total <= MAX_MANIFEST_BYTES) + .ok_or("embedded DataMaps exceed the manifest size limit")?; + ManifestEntry { + path, + size, + sha256: None, + source: ContentRef::Embedded { + data_map: embeddable_data_map(&data_map)?, + }, + } + } + }; + if entry.size.is_some_and(|size| size > MAX_SAFE_JS_INTEGER) { + return Err("size exceeds JavaScript's safe integer range".into()); + } + // A root DataMap lists every chunk, so its size is exact. + if entry + .known_size() + .zip(entry.size) + .is_some_and(|(known, hint)| known != hint) + { + return Err("size does not match the embedded DataMap".into()); + } + Ok(entry) + } +} + +/// Parse `hex` with the shared torrent hash parser, keeping the hash `pick` +/// selects: a hash of the other version's length is refused. +fn torrent_hash( + hex: Option, + field: &str, + pick: impl FnOnce(TorrentReference) -> Option, +) -> Result, String> { + hex.map(|hex| { + let parsed = TorrentReference::parse_hex(&hex).map_err(|e| e.to_string())?; + pick(parsed).ok_or_else(|| format!("{field} has the length of the other torrent version")) + }) + .transpose() +} + +/// Decode a canonical DataMap and hold it to what a native writer embeds: a +/// root map only while it is small, otherwise the shrunk map an upload returns. +fn embeddable_data_map(bytes: &[u8]) -> Result { + let data_map: DataMap = + rmp_serde::from_slice(bytes).map_err(|e| format!("invalid DataMap: {e}"))?; + if rmp_serde::to_vec(&data_map).map_err(|e| e.to_string())? != bytes { + return Err("DataMap must use canonical MessagePack encoding".into()); + } + let len = embedded_len(&data_map).map_err(|e| e.to_string())?; + if !data_map.is_child() && len > MAX_EMBEDDED_ROOT_MAP_BYTES { + return Err(format!( + "a root DataMap of {len} bytes exceeds the {MAX_EMBEDDED_ROOT_MAP_BYTES}-byte \ + embedding limit; embed the shrunk DataMap its upload returned" + )); + } + Ok(data_map) +} + #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + /// Plaintext size of each chunk in a test DataMap. + const CHUNK_SIZE: usize = 100; + /// Enough chunk records to take a root map over the embedding limit. + const OVERSIZED_CHUNKS: usize = MAX_EMBEDDED_ROOT_MAP_BYTES / manifest::CHUNK_RECORD_LEN + 1; + + fn data_map_bytes(chunks: usize, child: Option) -> Vec { + let infos = (0..chunks) + .map(|index| ChunkInfo { + index, + dst_hash: XorName::from_content(&index.to_be_bytes()), + src_hash: XorName::from_content(&(index + chunks).to_be_bytes()), + src_size: CHUNK_SIZE, + }) + .collect(); + let data_map = match child { + Some(level) => DataMap::with_child(infos, level), + None => DataMap::new(infos), + }; + rmp_serde::to_vec(&data_map).unwrap() + } + + fn embedded(size: Option, data_map: Vec) -> ManifestInput { + ManifestInput { + name: None, + torrent: None, + entries: vec![EntryInput::Embedded { + path: Some("file".into()), + size, + data_map, + }], + } + } + #[test] fn browser_inputs_round_trip_through_native_format() { let created = ManifestInput { @@ -183,4 +291,76 @@ mod tests { .is_err()); } } + + #[test] + fn an_empty_data_map_is_refused_rather_than_written_unreadable() { + let error = embedded(None, data_map_bytes(0, None)) + .create() + .unwrap_err(); + assert!(error.contains("at least one chunk"), "{error}"); + } + + #[test] + fn a_root_data_map_fixes_the_size_and_decodes_natively() { + let chunks = 3; + let exact = u64::try_from(chunks * CHUNK_SIZE).unwrap(); + let error = embedded(Some(exact - 1), data_map_bytes(chunks, None)) + .create() + .unwrap_err(); + assert!(error.contains("does not match"), "{error}"); + + for size in [None, Some(exact)] { + let created = embedded(size, data_map_bytes(chunks, None)) + .create() + .expect("manifest"); + assert_eq!(created.manifest.entries[0].known_size, Some(exact)); + let decoded = Manifest::decode(&created.bytes).expect("native decode"); + assert_eq!(decoded.entries[0].size, size); + } + } + + #[test] + fn only_a_small_root_data_map_is_embedded() { + let error = embedded(None, data_map_bytes(OVERSIZED_CHUNKS, None)) + .create() + .unwrap_err(); + assert!(error.contains("embedding limit"), "{error}"); + + // A shrunk map carries no exact size, so any hint is kept as given. + let created = embedded(Some(1), data_map_bytes(OVERSIZED_CHUNKS, Some(1))) + .create() + .expect("a shrunk map of any size is embedded"); + assert_eq!(created.manifest.entries[0].known_size, None); + Manifest::decode(&created.bytes).expect("native decode"); + } + + #[test] + fn torrent_hashes_are_parsed_by_version() { + let v1 = "ab".repeat(manifest::TORRENT_INFO_HASH_V1_LEN); + let v2 = "cd".repeat(manifest::TORRENT_INFO_HASH_V2_LEN); + let with_torrent = |info_hash_v1: Option<&str>, info_hash_v2: Option<&str>| ManifestInput { + name: None, + torrent: Some(TorrentInput { + info_hash_v1: info_hash_v1.map(Into::into), + info_hash_v2: info_hash_v2.map(Into::into), + }), + entries: Vec::new(), + }; + + let created = with_torrent(Some(&v1), Some(&v2)) + .create() + .expect("manifest"); + let torrent = created.manifest.torrent.expect("torrent"); + assert_eq!(torrent.info_hash_v1.as_deref(), Some(v1.as_str())); + assert_eq!(torrent.info_hash_v2.as_deref(), Some(v2.as_str())); + + for (info_hash_v1, info_hash_v2) in [(Some(&v2), None), (None, Some(&v1)), (None, None)] { + assert!(with_torrent( + info_hash_v1.map(String::as_str), + info_hash_v2.map(String::as_str) + ) + .create() + .is_err()); + } + } } diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 67e8ac59..f8c61eef 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -11,7 +11,7 @@ //! page keeps 32 bytes rather than a 4,032-byte secret key. The page is trusted //! with that seed exactly as it is trusted with its wallet. -use super::upload_adapter::{paid_transactions, wallet_quotes}; +use super::upload_adapter::{paid_transactions, quote_transactions, wallet_quotes}; use super::*; use crate::data::client::batch::ChunkPaymentPlan; use crate::data::{ @@ -19,10 +19,26 @@ use crate::data::{ Pointer, PointerFinality, PointerTarget, PointerTargetKind, }; use ant_protocol::evm::{QuoteHash, TxHash}; +use std::borrow::Cow; /// Length of an owner key seed. const OWNER_SEED_LEN: usize = 32; +/// Format of the checkpoint `onPrepared` is handed and +/// [`recover_pointer_payment`] reads. +const POINTER_CHECKPOINT_VERSION: u64 = 1; + +/// Largest checkpoint [`recover_pointer_payment`] reads. One signed record +/// and the quotes of one close group take a small fraction of it; a write +/// whose checkpoint would not fit is refused before anything is paid. +const MAX_POINTER_CHECKPOINT_BYTES: usize = 4 * 1024 * 1024; + +/// The pointer write option awaited with the checkpoint before paying. +const ON_PREPARED_OPTION: &str = "onPrepared"; + +/// The pointer write option handed the paid state before storing it. +const ON_PAID_OPTION: &str = "onPaid"; + /// How long a paid state waits on the page's `onPaid` callback before it is /// stored anyway. The payment is spent either way, so a callback that never /// settles must not keep the state from being stored. @@ -242,12 +258,22 @@ impl BrowserNetworkClient { /// `kind` is `"chunk"` or `"pointer"`. Refused before anything is paid if /// the pointer already exists. /// - /// `on_paid`, if given, is called with `{ record, proof }` once the state - /// is paid for and before it is stored. Keep both: if storing fails, or the - /// page is lost, [`Self::store_paid_pointer`] stores the same state without - /// paying again. `on_prepared` is awaited with an opaque record/plan checkpoint - /// before any payment; persist it durably to recover a later receipt. - #[allow(clippy::too_many_arguments)] // Preserve the existing positional browser ABI. + /// `options` is `{ onPrepared, onPaid }`, each optional; a bare function + /// is taken as `onPaid`, as earlier releases took it. + /// + /// - `onPrepared` is awaited with an opaque checkpoint, the signed record + /// and its payment plan (never the owner seed), before the wallet is + /// asked to pay. If it fails, nothing is paid. With the receipt of the + /// payment that follows, [`recover_pointer_payment`] rebuilds the paid + /// state, so persist it durably before it settles. + /// - `onPaid` is called with `{ record, proof }` once the state is paid + /// for and before it is stored. Keep both: if storing fails, or the page + /// is lost, [`Self::store_paid_pointer`] stores the same state without + /// paying again. + /// + /// The `onSubmission` the wallet is handed records nothing for a pointer + /// write, unlike an upload's: the wallet must keep the transaction hash + /// it broadcasts, or a page lost before the receipt pays again on retry. #[wasm_bindgen(js_name = createPointer)] pub async fn create_pointer( &self, @@ -256,8 +282,7 @@ impl BrowserNetworkClient { kind: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - on_paid: Option, - on_prepared: Option, + options: JsValue, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -268,8 +293,7 @@ impl BrowserNetworkClient { kind, &payment_network, &pay_for_quotes, - on_paid.as_ref(), - on_prepared.as_ref(), + &options, ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -278,8 +302,7 @@ impl BrowserNetworkClient { /// Point the pointer `owner_seed` controls at `target`, one past the /// counter the network serves (or create it), and pay for the new state. - /// `on_paid` is as for [`Self::create_pointer`]. - #[allow(clippy::too_many_arguments)] // Preserve the existing positional browser ABI. + /// `options` is as for [`Self::create_pointer`]. #[wasm_bindgen(js_name = updatePointer)] pub async fn update_pointer( &self, @@ -288,8 +311,7 @@ impl BrowserNetworkClient { kind: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - on_paid: Option, - on_prepared: Option, + options: JsValue, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -300,8 +322,7 @@ impl BrowserNetworkClient { kind, &payment_network, &pay_for_quotes, - on_paid.as_ref(), - on_prepared.as_ref(), + &options, ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -309,8 +330,8 @@ impl BrowserNetworkClient { } /// 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`]. + /// `recipient`, for good, and pay for the final state. `options` 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. @@ -324,8 +345,7 @@ impl BrowserNetworkClient { recipient: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - on_paid: Option, - on_prepared: Option, + options: JsValue, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -336,8 +356,7 @@ impl BrowserNetworkClient { "pointer", &payment_network, &pay_for_quotes, - on_paid.as_ref(), - on_prepared.as_ref(), + &options, ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -397,6 +416,62 @@ enum PointerWrite { Transfer, } +/// The callbacks a pointer write takes in its trailing `options` argument. +#[derive(Default)] +struct PointerWriteOptions { + /// Awaited with the recovery checkpoint before the wallet is asked to pay. + on_prepared: Option, + /// Handed `{ record, proof }` once the state is paid for. + on_paid: Option, +} + +impl PointerWriteOptions { + /// Read `{ onPrepared, onPaid }`, or a bare `onPaid` function as earlier + /// releases took it. An unknown option is refused rather than ignored: a + /// misspelt `onPrepared` would otherwise pay with no checkpoint kept. + fn parse(value: &JsValue) -> Result { + if value.is_undefined() || value.is_null() { + return Ok(Self::default()); + } + if let Some(on_paid) = value.dyn_ref::() { + return Ok(Self { + on_paid: Some(on_paid.clone()), + ..Self::default() + }); + } + if !value.is_object() { + return Err("pointer write options must be an object".into()); + } + let options: &js_sys::Object = value.unchecked_ref(); + for key in js_sys::Object::keys(options).iter() { + let key = key.as_string().unwrap_or_default(); + if key != ON_PREPARED_OPTION && key != ON_PAID_OPTION { + return Err(format!("unknown pointer write option {key:?}")); + } + } + Ok(Self { + on_prepared: option_function(options, ON_PREPARED_OPTION)?, + on_paid: option_function(options, ON_PAID_OPTION)?, + }) + } +} + +/// The function `options` holds at `name`, if any. +fn option_function( + options: &js_sys::Object, + name: &str, +) -> Result, String> { + let value = + js_sys::Reflect::get(options, &JsValue::from_str(name)).map_err(js_error_message)?; + if value.is_undefined() || value.is_null() { + return Ok(None); + } + value + .dyn_into() + .map(Some) + .map_err(|_| format!("{name} must be a function")) +} + impl BrowserNetworkClient { #[allow(clippy::too_many_arguments)] // Mirrors the JS arguments. async fn write_pointer( @@ -407,9 +482,9 @@ impl BrowserNetworkClient { kind: &str, payment_network: &BrowserPaymentNetwork, pay_for_quotes: &js_sys::Function, - on_paid: Option<&js_sys::Function>, - on_prepared: Option<&js_sys::Function>, + options: &JsValue, ) -> Result { + let options = PointerWriteOptions::parse(options)?; let (owner, secret) = owner_keys(owner_seed)?; let target = parse_target(target, kind)?; let client = self.paying_client(payment_network); @@ -423,15 +498,8 @@ impl BrowserNetworkClient { } } .map_err(|error| error.to_string())?; - self.store_pointer( - &client, - &record, - payment_network, - pay_for_quotes, - on_paid, - on_prepared, - ) - .await + self.store_pointer(&client, &record, payment_network, pay_for_quotes, &options) + .await } /// A client whose quotes and paid writes go only to nodes on the page's @@ -453,21 +521,26 @@ impl BrowserNetworkClient { record: &Pointer, payment_network: &BrowserPaymentNetwork, pay_for_quotes: &js_sys::Function, - on_paid: Option<&js_sys::Function>, - on_prepared: Option<&js_sys::Function>, + options: &PointerWriteOptions, ) -> Result { let plan = client .prepare_pointer_payment(record) .await .map_err(|error| error.to_string())?; - if let Some(on_prepared) = on_prepared { + if let Some(on_prepared) = &options.on_prepared { let checkpoint = serde_json::to_string(&PointerPaymentCheckpoint { - version: 1, - record: record.to_bytes(), - plan: plan.clone(), + version: POINTER_CHECKPOINT_VERSION, + record: Cow::Owned(record.to_bytes()), + plan: Cow::Borrowed(&plan), }) .map_err(|e| e.to_string())?; - // This callback MUST finish durably before the wallet can be invoked. + if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { + return Err(format!( + "pointer checkpoint exceeds the {MAX_POINTER_CHECKPOINT_BYTES} bytes recovery \ + reads; payment was not submitted" + )); + } + // Nothing is paid until the page has kept the checkpoint. let returned = on_prepared .call1(&JsValue::NULL, &JsValue::from_str(&checkpoint)) .map_err(js_error_message)?; @@ -476,12 +549,12 @@ impl BrowserNetworkClient { .map_err(js_error_message)?; } let (transactions, submission) = self - .pay_plan(&plan, payment_network, pay_for_quotes) + .pay_plan(client, &plan, payment_network, pay_for_quotes) .await?; let proof = plan .proof(&transactions) .map_err(|error| error.to_string())?; - if let Some(on_paid) = on_paid { + if let Some(on_paid) = &options.on_paid { // The payment is spent whatever the page does with this, so a // callback that fails, or never settles, does not stop the state // being stored. @@ -511,14 +584,16 @@ impl BrowserNetworkClient { /// Ask the page's wallet to pay one pointer state. /// /// The upload path journals each broadcast so that an interrupted payment - /// is recovered rather than repeated. A pointer write pays one quote and - /// keeps no journal of its own: once paid, the page is handed the record - /// and proof through `on_paid`, and from then on nothing is paid twice. A - /// page supplies `on_prepared` to retain the original record and plan before - /// invoking a wallet, and uses `recoverPointerPayment` with the observed - /// receipt after an interruption. Calling a fresh write is a new payment. + /// is recovered rather than repeated. A pointer write keeps no journal of + /// its own, and the `onSubmission` it hands the wallet records nothing. + /// The page keeps the `onPrepared` checkpoint and the transaction its + /// wallet broadcasts, from which [`recover_pointer_payment`] rebuilds the + /// paid state; once paid, `onPaid` hands it the record and proof, and + /// from then on nothing is paid twice. A page that keeps neither and is + /// lost between the broadcast and its receipt pays again on retry. async fn pay_plan( &self, + client: &crate::data::Client, plan: &ChunkPaymentPlan, payment_network: &BrowserPaymentNetwork, wallet: &js_sys::Function, @@ -530,6 +605,12 @@ impl BrowserNetworkClient { Promise::resolve(&JsValue::UNDEFINED) }) .into_js_value(); + // Checked again here, not only when quoting: `onPrepared` may have + // taken long enough for another write to learn that this build + // cannot settle. + if let Some(refusal) = client.corroborated_settlement_refusal() { + return Err(crate::data::Error::ClientUpdateRequired(refusal).to_string()); + } if self.inner.pool.availability.closed.get() { return Err("browser client is closed; payment was not submitted".to_string()); } @@ -559,67 +640,94 @@ async fn hand_over_paid( record: &Pointer, proof: &[u8], ) -> Result<(), JsValue> { + let returned = on_paid.call1(&JsValue::NULL, &paid_state(&record.to_bytes(), proof)?)?; + JsFuture::from(Promise::resolve(&returned)).await?; + Ok(()) +} + +/// A paid state as `onPaid` is handed it and [`BrowserNetworkClient::store_paid_pointer`] +/// takes it: `{ record, proof }`. +fn paid_state(record: &[u8], proof: &[u8]) -> Result { let paid = js_sys::Object::new(); js_sys::Reflect::set( &paid, &JsValue::from_str("record"), - &Uint8Array::from(record.to_bytes().as_slice()), + &Uint8Array::from(record), )?; js_sys::Reflect::set(&paid, &JsValue::from_str("proof"), &Uint8Array::from(proof))?; - let returned = on_paid.call1(&JsValue::NULL, &paid)?; - JsFuture::from(Promise::resolve(&returned)).await?; - Ok(()) + Ok(paid.into()) } -/// Opaque recovery input. Contains no owner seed or secret key. +/// What `onPrepared` is handed, as JSON: the signed record and the plan its +/// payment follows. Holds no owner seed or secret key. #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] -struct PointerPaymentCheckpoint { - version: u8, - record: Vec, - plan: ChunkPaymentPlan, +struct PointerPaymentCheckpoint<'a> { + version: u64, + record: Cow<'a, [u8]>, + plan: Cow<'a, ChunkPaymentPlan>, +} + +/// A checkpoint's version alone, read first so that a checkpoint from a +/// newer release is reported as unsupported rather than as malformed. +#[derive(Deserialize)] +struct CheckpointVersion { + version: u64, +} + +/// What recovery reads of a receipt: the transaction paying each quote, +/// keyed by quote hash, so a receipt is matched to the quote it paid rather +/// than taken for whichever checkpoint it comes with. Other fields, such as +/// `totalAmount`, are ignored. +#[derive(Deserialize)] +struct PointerPaymentReceipt { + #[serde(rename = "transactionHashes")] + transaction_hashes: HashMap, } -/// Rebuild an already-paid proof from the original plan and a confirmed receipt. -/// Does not quote, sign, submit payment, or contact the network. Only use an -/// application's own persisted checkpoint; this never authorizes fresh payment. +/// Rebuild an already-paid state, `{ record, proof }` for +/// [`BrowserNetworkClient::store_paid_pointer`], from the checkpoint a write +/// handed `onPrepared` and the receipt of the payment that followed it, as +/// `{ transactionHashes: { : } }`. +/// +/// Does not quote, sign, submit payment, or contact the network, and checks +/// only that the receipt names this checkpoint's quote: confirm the +/// transaction on chain first. Only use an application's own persisted +/// checkpoint; this never authorizes fresh payment. #[wasm_bindgen(js_name = recoverPointerPayment)] pub fn recover_pointer_payment(checkpoint: &str, receipt: JsValue) -> Result { - let recover = || -> Result { - if checkpoint.len() > 4 * 1024 * 1024 { - return Err("pointer checkpoint exceeds 4 MiB".into()); - } - let saved: PointerPaymentCheckpoint = - serde_json::from_str(checkpoint).map_err(|e| e.to_string())?; - if saved.version != 1 { - return Err("unsupported pointer checkpoint version".into()); - } - let record = Pointer::from_bytes(&saved.record).map_err(|e| e.to_string())?; - if saved.plan.address != record.state_id() - || saved.plan.data_size != saved.record.len() as u64 - { - return Err("pointer record differs from its payment plan".into()); - } - let receipt: BrowserPaymentSubmission = - serde_wasm_bindgen::from_value(receipt).map_err(|e| e.to_string())?; - let verified = - wallet_quotes(std::slice::from_ref(&saved.plan)).map_err(|e| e.to_string())?; - let transactions = paid_transactions(&verified, &receipt).map_err(|e| e.to_string())?; - let proof = saved.plan.proof(&transactions).map_err(|e| e.to_string())?; - let paid = js_sys::Object::new(); - js_sys::Reflect::set( - &paid, - &JsValue::from_str("record"), - &Uint8Array::from(saved.record.as_slice()), - ) - .map_err(js_error_message)?; - js_sys::Reflect::set( - &paid, - &JsValue::from_str("proof"), - &Uint8Array::from(proof.as_slice()), - ) - .map_err(js_error_message)?; - Ok(paid.into()) - }; - recover().map_err(|e| JsValue::from_str(&e)) + let receipt: PointerPaymentReceipt = serde_wasm_bindgen::from_value(receipt) + .map_err(|e| JsValue::from_str(&format!("invalid pointer payment receipt: {e}")))?; + let (record, proof) = + recover_paid_state(checkpoint, &receipt).map_err(|e| JsValue::from_str(&e))?; + paid_state(&record, &proof) +} + +/// The record and proof [`recover_pointer_payment`] returns. +fn recover_paid_state( + checkpoint: &str, + receipt: &PointerPaymentReceipt, +) -> Result<(Vec, Vec), String> { + if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { + return Err(format!( + "pointer checkpoint exceeds {MAX_POINTER_CHECKPOINT_BYTES} bytes" + )); + } + let CheckpointVersion { version } = + serde_json::from_str(checkpoint).map_err(|e| format!("invalid pointer checkpoint: {e}"))?; + if version != POINTER_CHECKPOINT_VERSION { + return Err(format!("unsupported pointer checkpoint version {version}")); + } + let saved: PointerPaymentCheckpoint = + serde_json::from_str(checkpoint).map_err(|e| format!("invalid pointer checkpoint: {e}"))?; + let record = Pointer::from_bytes(&saved.record).map_err(|e| e.to_string())?; + if saved.plan.address != record.state_id() || saved.plan.data_size != saved.record.len() as u64 + { + return Err("pointer record differs from its payment plan".into()); + } + let verified = wallet_quotes(std::slice::from_ref(&saved.plan)).map_err(|e| e.to_string())?; + let transactions = quote_transactions(&verified, &receipt.transaction_hashes, None) + .map_err(|e| format!("receipt does not pay this checkpoint's quote: {e}"))?; + let proof = saved.plan.proof(&transactions).map_err(|e| e.to_string())?; + Ok((saved.record.into_owned(), proof)) } diff --git a/ant-core/src/browser/wasm_transport/upload_adapter.rs b/ant-core/src/browser/wasm_transport/upload_adapter.rs index e71be705..a197f002 100644 --- a/ant-core/src/browser/wasm_transport/upload_adapter.rs +++ b/ant-core/src/browser/wasm_transport/upload_adapter.rs @@ -57,20 +57,31 @@ pub(super) fn wallet_quotes(plans: &[ChunkPaymentPlan]) -> DataResult DataResult> { + quote_transactions( + verified, + &payment.transaction_hashes, + payment.transaction_hash.as_deref(), + ) +} + +/// The transaction paying each quote: from `by_quote`, keyed by quote hash +/// with or without `0x`, or, when that names none, `single`, one transaction +/// paying them all. +pub(super) fn quote_transactions( + verified: &[VerifiedStorageQuote], + by_quote: &HashMap, + single: Option<&str>, ) -> DataResult> { let mut transactions = HashMap::new(); for quote in verified { - let hash = if payment.transaction_hashes.is_empty() { - payment.transaction_hash.as_ref() + let hash = if by_quote.is_empty() { + single } else { - payment - .transaction_hashes + by_quote .get("e.quote_hash) - .or_else(|| { - payment - .transaction_hashes - .get(&format!("0x{}", quote.quote_hash)) - }) + .or_else(|| by_quote.get(&format!("0x{}", quote.quote_hash))) + .map(String::as_str) } .ok_or_else(|| Error::Payment("wallet returned no transaction for a paid quote".into()))?; transactions.insert( diff --git a/ant-core/src/data/manifest/mod.rs b/ant-core/src/data/manifest/mod.rs index f7d5777f..4fa989d1 100644 --- a/ant-core/src/data/manifest/mod.rs +++ b/ant-core/src/data/manifest/mod.rs @@ -415,6 +415,24 @@ impl Manifest { /// Check the entry count, the name, every path and every collision rule. pub fn validate(&self) -> Result<(), ManifestError> { + self.validated_names()?; + Ok(()) + } + + /// Validate, then sort entries by effective name so encoding is + /// deterministic. + pub fn canonicalize(&mut self) -> Result<(), ManifestError> { + let names = self.validated_names()?; + let mut keyed: Vec<_> = names.into_iter().zip(self.entries.drain(..)).collect(); + keyed.sort_by(|a, b| a.0.cmp(&b.0)); + self.entries = keyed.into_iter().map(|(_, entry)| entry).collect(); + Ok(()) + } + + /// [`Self::validate`], returning each entry's effective name in entry + /// order. A path-less embedded entry's name comes from self-encrypting + /// its DataMap, so callers that need the names reuse these. + fn validated_names(&self) -> Result, ManifestError> { if self.entries.len() > MAX_MANIFEST_ENTRIES { return Err(ManifestError::TooManyEntries { count: self.entries.len(), @@ -443,21 +461,9 @@ impl Manifest { } names.push(entry.effective_name()?); } - path::check_collisions(names.iter().map(String::as_str)).map_err(ManifestError::Conflict) - } - - /// Validate, then sort entries by effective name so encoding is - /// deterministic. - pub fn canonicalize(&mut self) -> Result<(), ManifestError> { - self.validate()?; - let mut keyed = self - .entries - .drain(..) - .map(|entry| entry.effective_name().map(|name| (name, entry))) - .collect::, _>>()?; - keyed.sort_by(|a, b| a.0.cmp(&b.0)); - self.entries = keyed.into_iter().map(|(_, entry)| entry).collect(); - Ok(()) + path::check_collisions(names.iter().map(String::as_str)) + .map_err(ManifestError::Conflict)?; + Ok(names) } /// The manifest as a link carries it: every embedded DataMap replaced by diff --git a/ant-core/src/data/manifest/wire.rs b/ant-core/src/data/manifest/wire.rs index 5fd3d988..c3eabf1f 100644 --- a/ant-core/src/data/manifest/wire.rs +++ b/ant-core/src/data/manifest/wire.rs @@ -109,6 +109,13 @@ struct DataMapIn { /// Serialise a DataMap in the manifest layout. Used through /// `#[serde(with = "wire")]`. pub fn serialize(data_map: &DataMap, serializer: S) -> Result { + // `deserialize` refuses a map without chunks, so writing one would + // produce a manifest nobody can read. + if data_map.infos().is_empty() { + return Err(S::Error::custom( + "an embedded DataMap must list at least one chunk", + )); + } let mut chunks = Vec::with_capacity(data_map.len() * CHUNK_RECORD_LEN); for (position, info) in data_map.infos().iter().enumerate() { if info.index != position { @@ -418,6 +425,13 @@ mod tests { })) .unwrap(); assert!(rmp_serde::from_slice::(&empty).is_err()); + let unreadable = Holder { + data_map: DataMap::new(Vec::new()), + }; + assert!( + rmp_serde::to_vec_named(&unreadable).is_err(), + "what the decoder refuses is never written" + ); let gapped = Holder { data_map: DataMap::new(vec![chunk(0, 1), chunk(2, 2)]), diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index 0a0de407..4a42f6e0 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -4,7 +4,7 @@ import { BrowserNetworkClient } from "./client-fixture.mjs"; import assert from "node:assert/strict"; import test from "node:test"; -import { pointerAddress } from "./pkg/ant_core.js"; +import { pointerAddress, recoverPointerPayment } from "./pkg/ant_core.js"; import { mockWebRtc, paymentNetwork } from "./mock-webrtc.mjs"; const seed = new Uint8Array(32).fill(7); @@ -478,3 +478,126 @@ test("an open pointer reports its counter, and a transfer nowhere is refused bef client.close(); } }); + +const transaction = `0x${"ab".repeat(32)}`; +/** The receipt recovery takes: the transaction keyed by the quote it paid. */ +const receiptFor = (quote) => ({ transactionHashes: { [quote.quoteHash]: transaction } }); + +test("onPrepared is awaited before the wallet, and one that fails pays nothing", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + const order = []; + const pay = (...args) => { order.push("wallet"); return signer.pay(...args); }; + try { + await client.createPointer(seed, target(1), "chunk", paymentNetwork, pay, { + onPrepared: async checkpoint => { + assert.equal(JSON.parse(checkpoint).version, 1); + order.push("prepared"); + await new Promise(resolve => setTimeout(resolve, 20)); + order.push("kept"); + }, + }); + assert.deepEqual(order, ["prepared", "kept", "wallet"], "the wallet waits for the checkpoint"); + + const before = puts(rtc).length; + await assert.rejects( + client.updatePointer(seed, target(2), "chunk", paymentNetwork, pay, { + onPrepared: async () => { throw new Error("disk full"); }, + }), + /disk full/, + ); + assert.equal(signer.calls.length, 1, "a checkpoint that was not kept is not paid for"); + assert.equal(puts(rtc).length, before, "and nothing is sent"); + } finally { + client.close(); + } +}); + +test("pointer write options are checked before anything is paid", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + for (const [options, error] of [ + [{ onPrepare: () => {} }, /unknown pointer write option "onPrepare"/], + [{ onPaid: 5 }, /onPaid must be a function/], + [7, /must be an object/], + ]) { + await assert.rejects( + client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, options), + error, + ); + } + assert.equal(signer.calls.length, 0); + } finally { + client.close(); + } +}); + +test("a settlement refusal learned while onPrepared runs stops the payment", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + await assert.rejects( + client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, { + onPrepared: () => client.test_refuse_settlement(), + }), + /test settlement refusal/, + ); + assert.equal(signer.calls.length, 0, "a write this build cannot settle is not paid for"); + } finally { + client.close(); + } +}); + +test("recoverPointerPayment rebuilds the paid state from its checkpoint and receipt", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + const checkpoints = []; + let paid; + const options = { onPrepared: value => { checkpoints.push(value); }, onPaid: value => { paid = value; } }; + try { + const written = await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, options); + const [checkpoint] = checkpoints; + const quote = signer.calls[0].quotes[0]; + + // Other receipt fields, such as totalAmount, are neither needed nor read. + const recovered = recoverPointerPayment(checkpoint, { ...receiptFor(quote), walletAddress: "0x01" }); + assert.deepEqual(recovered.record, paid.record); + assert.deepEqual(recovered.proof, paid.proof, "the same proof the write paid for"); + assert.deepEqual( + await client.storePaidPointer(recovered.record, recovered.proof, paymentNetwork), + written.pointer, + ); + assert.equal(signer.calls.length, 1, "recovering pays nothing"); + + await assert.rejects( + async () => recoverPointerPayment(checkpoint, { transactionHash: transaction, totalAmount: quote.amount }), + /transactionHashes/, + "a receipt that does not say which quote it paid is refused", + ); + await assert.rejects( + async () => recoverPointerPayment(checkpoint, { transactionHashes: { ["cd".repeat(32)]: transaction } }), + /does not pay this checkpoint's quote/, + ); + + const saved = JSON.parse(checkpoint); + await assert.rejects( + async () => recoverPointerPayment(JSON.stringify({ ...saved, version: 2, added: true }), receiptFor(quote)), + /unsupported pointer checkpoint version 2/, + "a newer checkpoint is reported as such, not as malformed", + ); + + await client.updatePointer(seed, target(2), "chunk", paymentNetwork, signer.pay, options); + const other = JSON.parse(checkpoints[1]); + await assert.rejects( + async () => recoverPointerPayment(JSON.stringify({ ...saved, record: other.record }), receiptFor(quote)), + /pointer record differs from its payment plan/, + ); + } finally { + client.close(); + } +}); diff --git a/docs/adr/ADR-0007-browser-api-completion.md b/docs/adr/ADR-0007-browser-api-completion.md index e05936ac..6e276ab1 100644 --- a/docs/adr/ADR-0007-browser-api-completion.md +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -26,7 +26,9 @@ Browser applications need shared-core manifest exports and pointer operations wi ## Decision -Export manifest construction through the existing shared format encoder. Extend pointer writes with an awaited pre-payment checkpoint containing the signed record and verified plan. The browser durably journals that checkpoint, submission evidence and receipts before proceeding. Export a recovery function that builds the paid proof from the original plan and receipt, without signing, quoting or submitting another transaction. Existing pointer write arguments remain compatible. A failed pre-payment checkpoint prevents payment; a failed paid checkpoint must not prevent storing an already paid state. +Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to native writer policy: a root DataMap is embedded only up to the native size limit, and a size hint must match a root DataMap's exact size. + +Pointer writes take a trailing options object, `{ onPrepared, onPaid }`; a bare function is still accepted as `onPaid`, and unknown options are refused. `onPrepared` is awaited with a versioned checkpoint containing the signed record and verified plan before the wallet is invoked. The settlement-refusal check is repeated after it, immediately before the wallet is invoked. The application durably keeps that checkpoint, and its wallet keeps each broadcast's transaction hash: only the wallet observes the broadcast, and the core's `onSubmission` records nothing for a pointer write. Export a recovery function that builds the paid proof from the original plan and a receipt naming the transaction by quote hash (`transactionHashes`). It checks that the receipt pays the checkpoint's quote, without signing, quoting or submitting another transaction. A failed pre-payment checkpoint prevents payment; a failed paid checkpoint must not prevent storing an already paid state. ## Consequences From b97d4aae547f83d09a7a6067b8db18eddecc900b Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:53 +0200 Subject: [PATCH 4/9] feat(browser)!: forward wallet submissions and harden pointer recovery Pointers: - Pointer writes take `options?: { onPrepared, onSubmission, onPaid }` (`Option`), so the TypeScript parameter is optional again. - New `onSubmission` option: the core forwards every transaction the wallet reports broadcasting, and the wallet waits on it. A page lost between broadcast and receipt can therefore recover without each wallet keeping the hash itself. - recoverPointerPayment accepts `{ transactionHash }` as well as keyed `{ transactionHashes }`, exactly as the write does, so a reported submission or a single-hash receipt recovers. A keyed receipt must still name the checkpoint's quote. - onPrepared is bounded by the 30 s callback timeout (shared with onPaid). Every failure before the wallet is invoked, including the re-checked settlement refusal, ends "payment was not submitted"; nothing is paid unless the wallet is called. - The checkpoint stays a versioned JSON string, but the record is base64 and the plan base64 MessagePack rather than JSON number arrays: 87,297 instead of 156,250 characters against the mock close group. - storePaidPointer says a proof expired when a refused proof's quotes are past the storers' age limit, rather than a generic store error. - A failure building onPaid's argument can no longer abort a paid write. Manifests: - ManifestInput::create returns a typed thiserror ManifestInputError instead of String. - The entry count is checked again before any entry is parsed. - createManifest checks top-level and torrent keys itself: serde_wasm_bindgen reads only declared struct fields, so deny_unknown_fields never refused unknown keys from JavaScript. - Validation and sorting run once: the bytes and link form are encoded through Manifest::encode_canonical. - ManifestEntry::known_size sums chunk sizes as u64, so it no longer overflows past 4 GiB on wasm32. The browser view names entries through ManifestEntry::effective_name_with instead of a copy of the rule. - Documented that an embedded root DataMap must be one an upload shrank, since the link carries its shrunk form. WASM tests cover createManifest/createFileLink input from JavaScript, onSubmission ordering and recovery from it, single-hash recovery, and the onPrepared timeout. ADR-0007 is updated to match. BREAKING CHANGE: createPointer, updatePointer and transferPointer no longer accept a bare function as their last argument; pass `{ onPaid }` instead. ManifestInput::create now returns `Result` instead of `Result`. Co-Authored-By: Claude Opus 5.5 --- ant-core/src/browser.rs | 22 +- ant-core/src/browser/file_manifest.rs | 212 +++++++++-- ant-core/src/browser/wasm_transport.rs | 16 + .../src/browser/wasm_transport/pointer.rs | 348 +++++++++++------- ant-core/src/data/client/batch.rs | 40 +- ant-core/src/data/manifest/mod.rs | 45 ++- ant-core/wasm-tests/manifest-link.test.mjs | 56 ++- ant-core/wasm-tests/pointer.test.mjs | 99 +++-- docs/adr/ADR-0007-browser-api-completion.md | 16 +- 9 files changed, 642 insertions(+), 212 deletions(-) diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 94c1a1ff..9f4bb01e 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -272,12 +272,14 @@ fn browser_chunk_info(info: &ChunkInfo) -> BrowserChunkInfo { #[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] mod wasm { + use super::file_manifest::{MANIFEST_INPUT_FIELDS, TORRENT_FIELD, TORRENT_INPUT_FIELDS}; use super::manifest::parse_browser_manifest; use super::payment::{payment_quote_hash, verify_storage_quote, BrowserQuoteArtifact}; use super::protocol::{ ice_password_from_sdp, parse_response_frame, parse_webrtc_direct_multiaddr, server_answer_sdp, v2_server_ice_credential, BrowserEndpointInput, }; + use super::wasm_transport::reject_unknown_keys; use super::{ chunk_infos, content_address, decrypt_public_file, encrypt_public_file, verify_record, BrowserLinkView, BrowserManifestView, BrowserRecord, BrowserRecordInfo, BrowserStagedFile, @@ -670,9 +672,17 @@ mod wasm { /// Build canonical `.ant` bytes and a compact share link without network I/O. #[wasm_bindgen(js_name = createManifest)] pub fn create_manifest_wasm(input: JsValue) -> Result { + reject_unknown_keys(&input, &MANIFEST_INPUT_FIELDS).map_err(|e| JsValue::from_str(&e))?; + if input.is_object() { + let torrent = js_sys::Reflect::get(&input, &JsValue::from_str(TORRENT_FIELD))?; + reject_unknown_keys(&torrent, &TORRENT_INPUT_FIELDS) + .map_err(|e| JsValue::from_str(&e))?; + } let input: super::file_manifest::ManifestInput = serde_wasm_bindgen::from_value(input) .map_err(|error| JsValue::from_str(&error.to_string()))?; - let result = input.create().map_err(|error| JsValue::from_str(&error))?; + let result = input + .create() + .map_err(|error| JsValue::from_str(&error.to_string()))?; serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) } @@ -680,7 +690,7 @@ mod wasm { #[wasm_bindgen(js_name = createFileLink)] pub fn create_file_link_wasm(address: &str) -> Result { match crate::data::manifest::parse_link(address) - .map_err(|e| JsValue::from_str(&e.to_string()))? + .map_err(|error| JsValue::from_str(&error.to_string()))? { crate::data::manifest::Link::File(address) => { Ok(crate::data::manifest::file_link(&address)) @@ -1165,16 +1175,16 @@ impl BrowserManifestView { crate::data::manifest::ContentRef::Public { .. } => None, }; // An embedded entry's address comes from self-encrypting its - // DataMap; derive it once and name a path-less entry by it. - let address = hex::encode(entry.source.content_address()?); + // DataMap; derive it once for both the name and the address. + let address = entry.source.content_address()?; entries.push(BrowserManifestEntryView { - name: entry.path.clone().unwrap_or_else(|| address.clone()), + name: entry.effective_name_with(&address), path: entry.path.clone(), size: entry.size, known_size: entry.known_size(), sha256: entry.sha256.map(hex::encode), kind: entry.source.kind().to_owned(), - address, + address: hex::encode(address), data_map, }); } diff --git a/ant-core/src/browser/file_manifest.rs b/ant-core/src/browser/file_manifest.rs index d1038a14..68f82f8b 100644 --- a/ant-core/src/browser/file_manifest.rs +++ b/ant-core/src/browser/file_manifest.rs @@ -6,17 +6,82 @@ use super::manifest::MAX_SAFE_JS_INTEGER; use super::BrowserManifestView; use crate::data::manifest::{ - self, embedded_len, ContentRef, Link, Manifest, ManifestEntry, TorrentReference, + self, embedded_len, ContentRef, Link, Manifest, ManifestEntry, ManifestError, TorrentReference, MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, MAX_MANIFEST_BYTES, + MAX_MANIFEST_ENTRIES, }; use self_encryption::DataMap; use serde::{Deserialize, Serialize}; +use thiserror::Error; #[cfg(test)] use self_encryption::ChunkInfo; #[cfg(test)] use xor_name::XorName; +/// The [`ManifestInput`] field holding a [`TorrentInput`], as JavaScript +/// names it. +pub const TORRENT_FIELD: &str = "torrent"; + +/// Every [`ManifestInput`] field as JavaScript names it. A JavaScript caller +/// checks keys against this: `serde_wasm_bindgen` reads only declared fields, +/// so `deny_unknown_fields` cannot refuse the others there. +pub const MANIFEST_INPUT_FIELDS: [&str; 3] = ["name", TORRENT_FIELD, "entries"]; + +/// Every [`TorrentInput`] field as JavaScript names it. +pub const TORRENT_INPUT_FIELDS: [&str; 2] = ["infoHashV1", "infoHashV2"]; + +/// Why [`ManifestInput::create`] refused its input. +#[derive(Debug, Error)] +pub enum ManifestInputError { + /// The shared encoder refused the manifest. + #[error(transparent)] + Manifest(#[from] ManifestError), + /// A public entry named something other than a file. + #[error("public entry requires a file address")] + NotAFileAddress, + /// The embedded DataMaps alone exceed what one manifest can hold. + #[error("embedded DataMaps exceed the {max}-byte manifest size limit")] + EmbeddedTooLarge { + /// The limit. + max: usize, + }, + /// An embedded DataMap did not decode. + #[error("invalid DataMap: {0}")] + InvalidDataMap(String), + /// An embedded DataMap decoded, but not from its canonical encoding. + #[error("DataMap must use canonical MessagePack encoding")] + NonCanonicalDataMap, + /// A root DataMap too large to embed, as a native writer would refuse. + #[error( + "a root DataMap of {len} bytes exceeds the {max}-byte embedding limit; \ + embed the shrunk DataMap its upload returned" + )] + RootDataMapTooLarge { + /// Embedded length of the map. + len: usize, + /// The limit. + max: usize, + }, + /// A size hint JavaScript cannot represent exactly. + #[error("size {0} exceeds JavaScript's safe integer range")] + UnsafeSize(u64), + /// A size hint that contradicts the size a root DataMap fixes. + #[error("size {hint} does not match the embedded DataMap's {known} bytes")] + SizeMismatch { + /// The size given. + hint: u64, + /// The size the DataMap lists. + known: u64, + }, + /// A torrent hash given under the other version's field. + #[error("{field} has the length of the other torrent version")] + TorrentHashVersion { + /// The input field. + field: &'static str, + }, +} + /// What `createManifest` takes: metadata the creator supplies. The address of /// an embedded entry is derived from its DataMap, never taken on trust. #[derive(Debug, Deserialize)] @@ -55,6 +120,11 @@ pub enum EntryInput { }, /// A file by its DataMap, in self_encryption's canonical MessagePack as /// the browser client hands it out. Grants access to a private file. + /// + /// The `.ant` bytes carry the map as given; the link carries its shrunk + /// form, which a reader resolves through the wrapper records an upload + /// stores. So a root map must be one an upload shrank, as every upload + /// does, never one encrypted without being uploaded. Embedded { /// Relative path, subject to the portable path rules. path: Option, @@ -85,7 +155,15 @@ pub struct CreatedManifest { impl ManifestInput { /// Validate the input and encode it with the shared encoder, so the bytes /// and link are exactly those a native writer produces. - pub fn create(self) -> Result { + pub fn create(self) -> Result { + // Counted before any entry is parsed or any DataMap decoded. + if self.entries.len() > MAX_MANIFEST_ENTRIES { + return Err(ManifestError::TooManyEntries { + count: self.entries.len(), + max: MAX_MANIFEST_ENTRIES, + } + .into()); + } let torrent = self.torrent.map(TorrentInput::into_reference).transpose()?; let mut embedded_bytes = 0; let entries = self @@ -98,12 +176,14 @@ impl ManifestInput { torrent, entries, }; - // Sorted here too so the view lists entries in the order the bytes do. - manifest.canonicalize().map_err(|e| e.to_string())?; - let bytes = manifest.encode().map_err(|e| e.to_string())?; - let link_bytes = manifest::manifest_link_bytes(&manifest).map_err(|e| e.to_string())?; + // Validated and sorted once: the view lists entries in the order the + // bytes do, and the link form keeps every entry's name, so it stays + // canonical too. + manifest.canonicalize()?; + let bytes = manifest.encode_canonical()?; + let link_bytes = manifest.link_form()?.encode_canonical()?; Ok(CreatedManifest { - manifest: BrowserManifestView::from_manifest(&manifest).map_err(|e| e.to_string())?, + manifest: BrowserManifestView::from_manifest(&manifest)?, bytes, link: manifest::manifest_link_from_bytes(&link_bytes), link_recommended: link_bytes.len() <= MANIFEST_LINK_RECOMMENDED_MAX_BYTES, @@ -112,7 +192,7 @@ impl ManifestInput { } impl TorrentInput { - fn into_reference(self) -> Result { + fn into_reference(self) -> Result { Ok(TorrentReference { info_hash_v1: torrent_hash(self.info_hash_v1, "infoHashV1", |t| t.info_hash_v1)?, info_hash_v2: torrent_hash(self.info_hash_v2, "infoHashV2", |t| t.info_hash_v2)?, @@ -123,17 +203,15 @@ impl TorrentInput { impl EntryInput { /// The manifest entry. `embedded_bytes` totals the DataMap input so far, /// so a page cannot make this decode more than a manifest can hold. - fn into_entry(self, embedded_bytes: &mut usize) -> Result { + fn into_entry(self, embedded_bytes: &mut usize) -> Result { let entry = match self { Self::Public { path, size, address, } => { - let Link::File(address) = - manifest::parse_link(&address).map_err(|e| e.to_string())? - else { - return Err("public entry requires a file address".into()); + let Link::File(address) = manifest::parse_link(&address)? else { + return Err(ManifestInputError::NotAFileAddress); }; ManifestEntry { path, @@ -150,7 +228,9 @@ impl EntryInput { *embedded_bytes = embedded_bytes .checked_add(data_map.len()) .filter(|total| *total <= MAX_MANIFEST_BYTES) - .ok_or("embedded DataMaps exceed the manifest size limit")?; + .ok_or(ManifestInputError::EmbeddedTooLarge { + max: MAX_MANIFEST_BYTES, + })?; ManifestEntry { path, size, @@ -161,16 +241,14 @@ impl EntryInput { } } }; - if entry.size.is_some_and(|size| size > MAX_SAFE_JS_INTEGER) { - return Err("size exceeds JavaScript's safe integer range".into()); + if let Some(size) = entry.size.filter(|size| *size > MAX_SAFE_JS_INTEGER) { + return Err(ManifestInputError::UnsafeSize(size)); } // A root DataMap lists every chunk, so its size is exact. - if entry - .known_size() - .zip(entry.size) - .is_some_and(|(known, hint)| known != hint) - { - return Err("size does not match the embedded DataMap".into()); + if let Some((known, hint)) = entry.known_size().zip(entry.size) { + if known != hint { + return Err(ManifestInputError::SizeMismatch { hint, known }); + } } Ok(entry) } @@ -180,30 +258,32 @@ impl EntryInput { /// selects: a hash of the other version's length is refused. fn torrent_hash( hex: Option, - field: &str, + field: &'static str, pick: impl FnOnce(TorrentReference) -> Option, -) -> Result, String> { +) -> Result, ManifestInputError> { hex.map(|hex| { - let parsed = TorrentReference::parse_hex(&hex).map_err(|e| e.to_string())?; - pick(parsed).ok_or_else(|| format!("{field} has the length of the other torrent version")) + let parsed = TorrentReference::parse_hex(&hex)?; + pick(parsed).ok_or(ManifestInputError::TorrentHashVersion { field }) }) .transpose() } /// Decode a canonical DataMap and hold it to what a native writer embeds: a /// root map only while it is small, otherwise the shrunk map an upload returns. -fn embeddable_data_map(bytes: &[u8]) -> Result { - let data_map: DataMap = - rmp_serde::from_slice(bytes).map_err(|e| format!("invalid DataMap: {e}"))?; - if rmp_serde::to_vec(&data_map).map_err(|e| e.to_string())? != bytes { - return Err("DataMap must use canonical MessagePack encoding".into()); +fn embeddable_data_map(bytes: &[u8]) -> Result { + let data_map: DataMap = rmp_serde::from_slice(bytes) + .map_err(|e| ManifestInputError::InvalidDataMap(e.to_string()))?; + let canonical = rmp_serde::to_vec(&data_map) + .map_err(|e| ManifestInputError::InvalidDataMap(e.to_string()))?; + if canonical != bytes { + return Err(ManifestInputError::NonCanonicalDataMap); } - let len = embedded_len(&data_map).map_err(|e| e.to_string())?; + let len = embedded_len(&data_map)?; if !data_map.is_child() && len > MAX_EMBEDDED_ROOT_MAP_BYTES { - return Err(format!( - "a root DataMap of {len} bytes exceeds the {MAX_EMBEDDED_ROOT_MAP_BYTES}-byte \ - embedding limit; embed the shrunk DataMap its upload returned" - )); + return Err(ManifestInputError::RootDataMapTooLarge { + len, + max: MAX_EMBEDDED_ROOT_MAP_BYTES, + }); } Ok(data_map) } @@ -292,11 +372,59 @@ mod tests { } } + #[test] + fn the_field_lists_name_every_input_field() { + // `deny_unknown_fields` holds through serde_json, so input naming + // exactly the listed fields parses only if the lists are complete. + let torrent: serde_json::Map<_, _> = TORRENT_INPUT_FIELDS + .iter() + .map(|field| (field.to_string(), serde_json::Value::Null)) + .collect(); + let input = serde_json::json!({ + "name": null, + TORRENT_FIELD: torrent, + "entries": [], + }); + let keys: Vec<_> = input.as_object().unwrap().keys().cloned().collect(); + assert_eq!(keys.len(), MANIFEST_INPUT_FIELDS.len()); + assert!(keys + .iter() + .all(|key| MANIFEST_INPUT_FIELDS.contains(&key.as_str()))); + serde_json::from_value::(input).expect("every listed field is known"); + } + + #[test] + fn the_entry_count_is_checked_before_any_entry_is_parsed() { + // Unparseable addresses: only the count can refuse these first. + let entries = (0..=MAX_MANIFEST_ENTRIES) + .map(|_| EntryInput::Public { + path: None, + size: None, + address: String::new(), + }) + .collect(); + let error = ManifestInput { + name: None, + torrent: None, + entries, + } + .create() + .unwrap_err(); + assert!( + matches!( + error, + ManifestInputError::Manifest(ManifestError::TooManyEntries { .. }) + ), + "{error}" + ); + } + #[test] fn an_empty_data_map_is_refused_rather_than_written_unreadable() { let error = embedded(None, data_map_bytes(0, None)) .create() - .unwrap_err(); + .unwrap_err() + .to_string(); assert!(error.contains("at least one chunk"), "{error}"); } @@ -307,7 +435,10 @@ mod tests { let error = embedded(Some(exact - 1), data_map_bytes(chunks, None)) .create() .unwrap_err(); - assert!(error.contains("does not match"), "{error}"); + assert!( + matches!(error, ManifestInputError::SizeMismatch { hint, known } if hint == exact - 1 && known == exact), + "{error}" + ); for size in [None, Some(exact)] { let created = embedded(size, data_map_bytes(chunks, None)) @@ -324,7 +455,10 @@ mod tests { let error = embedded(None, data_map_bytes(OVERSIZED_CHUNKS, None)) .create() .unwrap_err(); - assert!(error.contains("embedding limit"), "{error}"); + assert!( + matches!(error, ManifestInputError::RootDataMapTooLarge { .. }), + "{error}" + ); // A shrunk map carries no exact size, so any hint is kept as given. let created = embedded(Some(1), data_map_bytes(OVERSIZED_CHUNKS, Some(1))) diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index cb859c82..d2b77c23 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -3454,6 +3454,22 @@ fn pass_ice_gathering_turn(connection: RtcPeerConnection, turn: Option Result<(), String> { + let Some(object) = value.dyn_ref::() else { + return Ok(()); + }; + for key in js_sys::Object::keys(object).iter() { + let key = key.as_string().unwrap_or_default(); + if !known.contains(&key.as_str()) { + return Err(format!("unknown field `{key}`, expected one of {known:?}")); + } + } + Ok(()) +} + fn js_error_message(value: JsValue) -> String { value .as_string() diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index f8c61eef..871228a9 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -13,13 +13,14 @@ use super::upload_adapter::{paid_transactions, quote_transactions, wallet_quotes}; use super::*; -use crate::data::client::batch::ChunkPaymentPlan; +use crate::data::client::batch::{paid_proof_is_safely_fresh, ChunkPaymentPlan}; use crate::data::{ ml_dsa_65, pointer_address, FinalState, FinalityStatus, MlDsaPublicKey, MlDsaSecretKey, Pointer, PointerFinality, PointerTarget, PointerTargetKind, }; use ant_protocol::evm::{QuoteHash, TxHash}; -use std::borrow::Cow; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; /// Length of an owner key seed. const OWNER_SEED_LEN: usize = 32; @@ -33,22 +34,30 @@ const POINTER_CHECKPOINT_VERSION: u64 = 1; /// whose checkpoint would not fit is refused before anything is paid. const MAX_POINTER_CHECKPOINT_BYTES: usize = 4 * 1024 * 1024; +/// How long a write waits on a page callback. An `onPrepared` that does not +/// settle in time fails the write before anything is paid; a paid state is +/// stored anyway once `onPaid` has had this long, since the payment is spent +/// either way. +#[cfg(not(feature = "test-utils"))] +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(30); + +/// Short enough that a test can wait out a callback that never settles. +#[cfg(feature = "test-utils")] +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(1); + +/// Ends every error a write returns before its wallet is invoked: nothing +/// was paid, and retrying cannot pay twice. +const NOT_SUBMITTED: &str = "payment was not submitted"; + /// The pointer write option awaited with the checkpoint before paying. const ON_PREPARED_OPTION: &str = "onPrepared"; +/// The pointer write option handed each transaction the wallet broadcasts. +const ON_SUBMISSION_OPTION: &str = "onSubmission"; + /// The pointer write option handed the paid state before storing it. const ON_PAID_OPTION: &str = "onPaid"; -/// How long a paid state waits on the page's `onPaid` callback before it is -/// stored anyway. The payment is spent either way, so a callback that never -/// settles must not keep the state from being stored. -#[cfg(not(feature = "test-utils"))] -const ON_PAID_TIMEOUT: Duration = Duration::from_secs(30); - -/// Short enough that a test can wait out a callback that never settles. -#[cfg(feature = "test-utils")] -const ON_PAID_TIMEOUT: Duration = Duration::from_secs(1); - /// A pointer as JavaScript sees it. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -258,22 +267,27 @@ impl BrowserNetworkClient { /// `kind` is `"chunk"` or `"pointer"`. Refused before anything is paid if /// the pointer already exists. /// - /// `options` is `{ onPrepared, onPaid }`, each optional; a bare function - /// is taken as `onPaid`, as earlier releases took it. + /// `options` is `{ onPrepared, onSubmission, onPaid }`, each optional. + /// An unknown option is refused before anything is paid. /// - /// - `onPrepared` is awaited with an opaque checkpoint, the signed record - /// and its payment plan (never the owner seed), before the wallet is - /// asked to pay. If it fails, nothing is paid. With the receipt of the - /// payment that follows, [`recover_pointer_payment`] rebuilds the paid - /// state, so persist it durably before it settles. + /// - `onPrepared` is awaited with an opaque checkpoint string, the signed + /// record and its payment plan (never the owner seed), before the + /// wallet is asked to pay. Persist it durably before it settles; if it + /// fails, or does not settle within 30 seconds, nothing is paid. + /// - `onSubmission` is handed what the wallet reports for each + /// transaction it broadcasts, `{ transactionHash }` or + /// `{ transactionHashes }`, and the wallet waits on its promise. Keep + /// it: with the checkpoint, [`recover_pointer_payment`] rebuilds the + /// paid state once the transaction is confirmed, so a page lost before + /// the receipt never pays twice. /// - `onPaid` is called with `{ record, proof }` once the state is paid /// for and before it is stored. Keep both: if storing fails, or the page /// is lost, [`Self::store_paid_pointer`] stores the same state without /// paying again. /// - /// The `onSubmission` the wallet is handed records nothing for a pointer - /// write, unlike an upload's: the wallet must keep the transaction hash - /// it broadcasts, or a page lost before the receipt pays again on retry. + /// Nothing is paid unless `pay_for_quotes` is called. A write that fails + /// before calling it says "payment was not submitted"; any later failure + /// may have paid, and is recovered from what the callbacks were handed. #[wasm_bindgen(js_name = createPointer)] pub async fn create_pointer( &self, @@ -282,7 +296,7 @@ impl BrowserNetworkClient { kind: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - options: JsValue, + options: Option, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -293,7 +307,7 @@ impl BrowserNetworkClient { kind, &payment_network, &pay_for_quotes, - &options, + options, ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -311,7 +325,7 @@ impl BrowserNetworkClient { kind: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - options: JsValue, + options: Option, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -322,7 +336,7 @@ impl BrowserNetworkClient { kind, &payment_network, &pay_for_quotes, - &options, + options, ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -345,7 +359,7 @@ impl BrowserNetworkClient { recipient: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - options: JsValue, + options: Option, ) -> Result { let payment_network = parse_payment_network(payment_network)?; let written = self @@ -356,7 +370,7 @@ impl BrowserNetworkClient { "pointer", &payment_network, &pay_for_quotes, - &options, + options, ) .await .map_err(|error| JsValue::from_str(&error))?; @@ -378,7 +392,11 @@ impl BrowserNetworkClient { } /// Store a pointer state already paid for, from the `record` and `proof` - /// an earlier write handed to its `on_paid` callback. Pays nothing. + /// an earlier write handed to its `onPaid` callback or + /// [`recover_pointer_payment`] rebuilt. Pays nothing. + /// + /// Storers accept a proof only while its quotes are under a day old; a + /// refusal of an older proof says it expired. #[wasm_bindgen(js_name = storePaidPointer)] pub async fn store_paid_pointer( &self, @@ -390,16 +408,17 @@ impl BrowserNetworkClient { let record = Pointer::from_bytes(record) .map_err(|error| JsValue::from_str(&format!("invalid pointer record: {error}")))?; let client = self.paying_client(&payment_network); + let refused = |error: crate::data::Error| JsValue::from_str(&explain_refusal(proof, error)); 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())); + return Err(refused(error)); } client .recover_final_write(&record, error) .await - .map_err(|error| JsValue::from_str(&error.to_string()))?; + .map_err(refused)?; } to_js(&BrowserPointer::from(&record)) } @@ -421,46 +440,39 @@ enum PointerWrite { struct PointerWriteOptions { /// Awaited with the recovery checkpoint before the wallet is asked to pay. on_prepared: Option, + /// Handed each transaction the wallet reports broadcasting. + on_submission: Option, /// Handed `{ record, proof }` once the state is paid for. on_paid: Option, } impl PointerWriteOptions { - /// Read `{ onPrepared, onPaid }`, or a bare `onPaid` function as earlier - /// releases took it. An unknown option is refused rather than ignored: a - /// misspelt `onPrepared` would otherwise pay with no checkpoint kept. - fn parse(value: &JsValue) -> Result { - if value.is_undefined() || value.is_null() { + /// Read `{ onPrepared, onSubmission, onPaid }`. An unknown option is + /// refused rather than ignored: a misspelt `onPrepared` would otherwise + /// pay with no checkpoint kept. + fn parse(value: Option) -> Result { + let Some(value) = value.filter(|value| !value.is_undefined() && !value.is_null()) else { return Ok(Self::default()); - } - if let Some(on_paid) = value.dyn_ref::() { - return Ok(Self { - on_paid: Some(on_paid.clone()), - ..Self::default() - }); - } + }; + // A function is not an object here: earlier releases took a bare + // `onPaid`, which must not now be read as an empty options object. if !value.is_object() { return Err("pointer write options must be an object".into()); } - let options: &js_sys::Object = value.unchecked_ref(); - for key in js_sys::Object::keys(options).iter() { - let key = key.as_string().unwrap_or_default(); - if key != ON_PREPARED_OPTION && key != ON_PAID_OPTION { - return Err(format!("unknown pointer write option {key:?}")); - } - } + reject_unknown_keys( + &value, + &[ON_PREPARED_OPTION, ON_SUBMISSION_OPTION, ON_PAID_OPTION], + )?; Ok(Self { - on_prepared: option_function(options, ON_PREPARED_OPTION)?, - on_paid: option_function(options, ON_PAID_OPTION)?, + on_prepared: option_function(&value, ON_PREPARED_OPTION)?, + on_submission: option_function(&value, ON_SUBMISSION_OPTION)?, + on_paid: option_function(&value, ON_PAID_OPTION)?, }) } } /// The function `options` holds at `name`, if any. -fn option_function( - options: &js_sys::Object, - name: &str, -) -> Result, String> { +fn option_function(options: &JsValue, name: &str) -> Result, String> { let value = js_sys::Reflect::get(options, &JsValue::from_str(name)).map_err(js_error_message)?; if value.is_undefined() || value.is_null() { @@ -482,7 +494,7 @@ impl BrowserNetworkClient { kind: &str, payment_network: &BrowserPaymentNetwork, pay_for_quotes: &js_sys::Function, - options: &JsValue, + options: Option, ) -> Result { let options = PointerWriteOptions::parse(options)?; let (owner, secret) = owner_keys(owner_seed)?; @@ -528,28 +540,34 @@ impl BrowserNetworkClient { .await .map_err(|error| error.to_string())?; if let Some(on_prepared) = &options.on_prepared { - let checkpoint = serde_json::to_string(&PointerPaymentCheckpoint { - version: POINTER_CHECKPOINT_VERSION, - record: Cow::Owned(record.to_bytes()), - plan: Cow::Borrowed(&plan), - }) - .map_err(|e| e.to_string())?; - if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { - return Err(format!( - "pointer checkpoint exceeds the {MAX_POINTER_CHECKPOINT_BYTES} bytes recovery \ - reads; payment was not submitted" - )); - } + let checkpoint = PointerPaymentCheckpoint::encode(record, &plan)?; // Nothing is paid until the page has kept the checkpoint. - let returned = on_prepared - .call1(&JsValue::NULL, &JsValue::from_str(&checkpoint)) - .map_err(js_error_message)?; - JsFuture::from(Promise::resolve(&returned)) - .await - .map_err(js_error_message)?; + crate::runtime::timeout( + CALLBACK_TIMEOUT, + call_and_wait(on_prepared, &JsValue::from_str(&checkpoint)), + ) + .await + .map_err(|_| { + format!( + "onPrepared did not settle within {}s; {NOT_SUBMITTED}", + CALLBACK_TIMEOUT.as_secs() + ) + })? + .map_err(|error| { + format!( + "onPrepared failed: {}; {NOT_SUBMITTED}", + js_error_message(error) + ) + })?; } let (transactions, submission) = self - .pay_plan(client, &plan, payment_network, pay_for_quotes) + .pay_plan( + client, + &plan, + payment_network, + pay_for_quotes, + options.on_submission.as_ref(), + ) .await?; let proof = plan .proof(&transactions) @@ -558,9 +576,11 @@ impl BrowserNetworkClient { // The payment is spent whatever the page does with this, so a // callback that fails, or never settles, does not stop the state // being stored. - let _ = - crate::runtime::timeout(ON_PAID_TIMEOUT, hand_over_paid(on_paid, record, &proof)) - .await; + let hand_over = async { + let paid = paid_state(&record.to_bytes(), &proof)?; + call_and_wait(on_paid, &paid).await + }; + let _ = crate::runtime::timeout(CALLBACK_TIMEOUT, hand_over).await; } if let Err(error) = client.pointer_put_paid(record, proof).await { // A final state cannot be taken back, and a write whose @@ -585,34 +605,47 @@ impl BrowserNetworkClient { /// /// The upload path journals each broadcast so that an interrupted payment /// is recovered rather than repeated. A pointer write keeps no journal of - /// its own, and the `onSubmission` it hands the wallet records nothing. - /// The page keeps the `onPrepared` checkpoint and the transaction its - /// wallet broadcasts, from which [`recover_pointer_payment`] rebuilds the - /// paid state; once paid, `onPaid` hands it the record and proof, and - /// from then on nothing is paid twice. A page that keeps neither and is - /// lost between the broadcast and its receipt pays again on retry. + /// its own: the page keeps the `onPrepared` checkpoint and each broadcast + /// the wallet reports, forwarded to `on_submission`, from which + /// [`recover_pointer_payment`] rebuilds the paid state. Once paid, + /// `onPaid` hands it the record and proof, and from then on nothing is + /// paid twice. async fn pay_plan( &self, client: &crate::data::Client, plan: &ChunkPaymentPlan, payment_network: &BrowserPaymentNetwork, wallet: &js_sys::Function, + on_submission: Option<&js_sys::Function>, ) -> Result<(HashMap, BrowserPaymentSubmission), String> { let verified = wallet_quotes(std::slice::from_ref(plan)).map_err(|e| e.to_string())?; let network = serde_wasm_bindgen::to_value(payment_network).map_err(|e| e.to_string())?; let quotes = serde_wasm_bindgen::to_value(&verified).map_err(|e| e.to_string())?; - let on_submission = Closure:: Promise>::new(|_: JsValue| { - Promise::resolve(&JsValue::UNDEFINED) - }) - .into_js_value(); + let on_submission = on_submission.cloned(); + // The wallet waits on the promise this returns, so the page has kept + // the transaction before the wallet goes on. + let on_submission = + Closure:: Promise>::new( + move |sent: JsValue| match &on_submission { + Some(callback) => match callback.call1(&JsValue::NULL, &sent) { + Ok(returned) => Promise::resolve(&returned), + Err(error) => Promise::reject(&error), + }, + None => Promise::resolve(&JsValue::UNDEFINED), + }, + ) + .into_js_value(); // Checked again here, not only when quoting: `onPrepared` may have // taken long enough for another write to learn that this build // cannot settle. if let Some(refusal) = client.corroborated_settlement_refusal() { - return Err(crate::data::Error::ClientUpdateRequired(refusal).to_string()); + return Err(format!( + "{}; {NOT_SUBMITTED}", + crate::data::Error::ClientUpdateRequired(refusal) + )); } if self.inner.pool.availability.closed.get() { - return Err("browser client is closed; payment was not submitted".to_string()); + return Err(format!("browser client is closed; {NOT_SUBMITTED}")); } let returned = wallet .call4( @@ -633,14 +666,10 @@ impl BrowserNetworkClient { } } -/// Hand a paid state to the page as `{ record, proof }`, waiting for it if -/// the callback returns a promise. -async fn hand_over_paid( - on_paid: &js_sys::Function, - record: &Pointer, - proof: &[u8], -) -> Result<(), JsValue> { - let returned = on_paid.call1(&JsValue::NULL, &paid_state(&record.to_bytes(), proof)?)?; +/// Call a page callback with `argument`, waiting for it if it returns a +/// promise. +async fn call_and_wait(callback: &js_sys::Function, argument: &JsValue) -> Result<(), JsValue> { + let returned = callback.call1(&JsValue::NULL, argument)?; JsFuture::from(Promise::resolve(&returned)).await?; Ok(()) } @@ -658,14 +687,72 @@ fn paid_state(record: &[u8], proof: &[u8]) -> Result { Ok(paid.into()) } +/// A storer's refusal of a paid state, saying so when the proof has expired: +/// that refusal is final, unlike a network failure worth retrying. +fn explain_refusal(proof: &[u8], error: crate::data::Error) -> String { + match paid_proof_is_safely_fresh(proof, crate::runtime::system_time()) { + Some(false) => format!( + "paid proof expired: its quotes are older than storers accept, so this state \ + cannot be stored without paying again ({error})" + ), + _ => error.to_string(), + } +} + /// What `onPrepared` is handed, as JSON: the signed record and the plan its -/// payment follows. Holds no owner seed or secret key. +/// payment follows, each base64 so that their bytes are not spelt out as +/// JSON numbers, the plan in MessagePack. Holds no owner seed or secret key. #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] -struct PointerPaymentCheckpoint<'a> { +struct PointerPaymentCheckpoint { version: u64, - record: Cow<'a, [u8]>, - plan: Cow<'a, ChunkPaymentPlan>, + record: String, + plan: String, +} + +impl PointerPaymentCheckpoint { + /// The checkpoint string, refused if recovery could not read it back. + fn encode(record: &Pointer, plan: &ChunkPaymentPlan) -> Result { + let plan = rmp_serde::to_vec_named(plan).map_err(|e| e.to_string())?; + let checkpoint = serde_json::to_string(&Self { + version: POINTER_CHECKPOINT_VERSION, + record: URL_SAFE_NO_PAD.encode(record.to_bytes()), + plan: URL_SAFE_NO_PAD.encode(plan), + }) + .map_err(|e| e.to_string())?; + if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { + return Err(format!( + "pointer checkpoint exceeds the {MAX_POINTER_CHECKPOINT_BYTES} bytes recovery \ + reads; {NOT_SUBMITTED}" + )); + } + Ok(checkpoint) + } + + /// The record bytes and plan a checkpoint string holds. + fn decode(checkpoint: &str) -> Result<(Vec, ChunkPaymentPlan), String> { + let invalid = + |error: &dyn std::fmt::Display| format!("invalid pointer checkpoint: {error}"); + if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { + return Err(format!( + "pointer checkpoint exceeds {MAX_POINTER_CHECKPOINT_BYTES} bytes" + )); + } + let CheckpointVersion { version } = + serde_json::from_str(checkpoint).map_err(|e| invalid(&e))?; + if version != POINTER_CHECKPOINT_VERSION { + return Err(format!("unsupported pointer checkpoint version {version}")); + } + let saved: Self = serde_json::from_str(checkpoint).map_err(|e| invalid(&e))?; + let record = URL_SAFE_NO_PAD + .decode(saved.record) + .map_err(|e| invalid(&e))?; + let plan = URL_SAFE_NO_PAD + .decode(saved.plan) + .map_err(|e| invalid(&e))?; + let plan = rmp_serde::from_slice(&plan).map_err(|e| invalid(&e))?; + Ok((record, plan)) + } } /// A checkpoint's version alone, read first so that a checkpoint from a @@ -675,25 +762,28 @@ struct CheckpointVersion { version: u64, } -/// What recovery reads of a receipt: the transaction paying each quote, -/// keyed by quote hash, so a receipt is matched to the quote it paid rather -/// than taken for whichever checkpoint it comes with. Other fields, such as -/// `totalAmount`, are ignored. +/// What recovery reads of a receipt, or of a submission the wallet reported: +/// `transactionHashes`, keyed by the quote each paid, or `transactionHash`, +/// one transaction paying the checkpoint's one quote, as the write itself +/// accepts. Other fields, such as `totalAmount`, are ignored. #[derive(Deserialize)] struct PointerPaymentReceipt { - #[serde(rename = "transactionHashes")] + #[serde(default, rename = "transactionHashes")] transaction_hashes: HashMap, + #[serde(default, rename = "transactionHash")] + transaction_hash: Option, } /// Rebuild an already-paid state, `{ record, proof }` for /// [`BrowserNetworkClient::store_paid_pointer`], from the checkpoint a write -/// handed `onPrepared` and the receipt of the payment that followed it, as +/// handed `onPrepared` and the receipt of the payment that followed it, or +/// the submission `onSubmission` was handed: `{ transactionHash }` or /// `{ transactionHashes: { : } }`. /// -/// Does not quote, sign, submit payment, or contact the network, and checks -/// only that the receipt names this checkpoint's quote: confirm the -/// transaction on chain first. Only use an application's own persisted -/// checkpoint; this never authorizes fresh payment. +/// Does not quote, sign, submit payment, or contact the network. A keyed +/// receipt must name this checkpoint's quote; beyond that the transaction is +/// not checked, so confirm it on chain first. Only use an application's own +/// persisted checkpoint; this never authorizes fresh payment. #[wasm_bindgen(js_name = recoverPointerPayment)] pub fn recover_pointer_payment(checkpoint: &str, receipt: JsValue) -> Result { let receipt: PointerPaymentReceipt = serde_wasm_bindgen::from_value(receipt) @@ -708,26 +798,18 @@ fn recover_paid_state( checkpoint: &str, receipt: &PointerPaymentReceipt, ) -> Result<(Vec, Vec), String> { - if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { - return Err(format!( - "pointer checkpoint exceeds {MAX_POINTER_CHECKPOINT_BYTES} bytes" - )); - } - let CheckpointVersion { version } = - serde_json::from_str(checkpoint).map_err(|e| format!("invalid pointer checkpoint: {e}"))?; - if version != POINTER_CHECKPOINT_VERSION { - return Err(format!("unsupported pointer checkpoint version {version}")); - } - let saved: PointerPaymentCheckpoint = - serde_json::from_str(checkpoint).map_err(|e| format!("invalid pointer checkpoint: {e}"))?; - let record = Pointer::from_bytes(&saved.record).map_err(|e| e.to_string())?; - if saved.plan.address != record.state_id() || saved.plan.data_size != saved.record.len() as u64 - { + let (record_bytes, plan) = PointerPaymentCheckpoint::decode(checkpoint)?; + let record = Pointer::from_bytes(&record_bytes).map_err(|e| e.to_string())?; + if plan.address != record.state_id() || plan.data_size != record_bytes.len() as u64 { return Err("pointer record differs from its payment plan".into()); } - let verified = wallet_quotes(std::slice::from_ref(&saved.plan)).map_err(|e| e.to_string())?; - let transactions = quote_transactions(&verified, &receipt.transaction_hashes, None) - .map_err(|e| format!("receipt does not pay this checkpoint's quote: {e}"))?; - let proof = saved.plan.proof(&transactions).map_err(|e| e.to_string())?; - Ok((saved.record.into_owned(), proof)) + let verified = wallet_quotes(std::slice::from_ref(&plan)).map_err(|e| e.to_string())?; + let transactions = quote_transactions( + &verified, + &receipt.transaction_hashes, + receipt.transaction_hash.as_deref(), + ) + .map_err(|e| format!("receipt does not pay this checkpoint's quote: {e}"))?; + let proof = plan.proof(&transactions).map_err(|e| e.to_string())?; + Ok((record_bytes, proof)) } diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index 3928eb1d..d39d893c 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -17,7 +17,6 @@ use ant_protocol::evm::{ Amount, EncodedPeerId, PayForQuotesError, PaymentQuote, ProofOfPayment, QuoteHash, RewardsAddress, TxHash, Wallet, }; -#[cfg(any(feature = "native", test))] use ant_protocol::payment::deserialize_proof; use ant_protocol::payment::{serialize_single_node_proof, PaymentProof, QuotePaymentInfo}; use ant_protocol::transport::{MultiAddr, PeerId}; @@ -886,9 +885,7 @@ fn prune_locally_expired_proofs( proofs: HashMap<[u8; 32], Vec>, ) -> HashMap> { let now = std::time::SystemTime::now(); - let max_safe_age = Duration::from_secs( - CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS), - ); + let max_safe_age = max_safe_proof_age(); let mut kept: HashMap> = HashMap::with_capacity(proofs.len()); // Pair each expired address with the EXACT bytes we observed at // load time. The cache-side drop only removes the entry if those @@ -923,6 +920,21 @@ fn prune_locally_expired_proofs( kept } +/// The quote age past which a proof is too close to the storer's limit to +/// trust. +fn max_safe_proof_age() -> Duration { + Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS)) +} + +/// Whether serialized proof bytes are still young enough for storers to +/// accept, judged as a cached proof is. `None` if the bytes are not a +/// single-node proof. +#[cfg(any(all(target_arch = "wasm32", feature = "browser-wasm"), test))] +pub(crate) fn paid_proof_is_safely_fresh(proof: &[u8], now: std::time::SystemTime) -> Option { + let (proof, _) = deserialize_proof(proof).ok()?; + Some(proof_is_safely_fresh(&proof, now, max_safe_proof_age())) +} + /// True iff every quote in the proof has a timestamp not older than /// `now - max_safe_age`. /// @@ -1214,6 +1226,26 @@ mod tests { ProofOfPayment { peer_quotes } } + #[test] + fn paid_proof_bytes_are_judged_by_their_oldest_quote() { + let now = std::time::SystemTime::now(); + let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS); + let bytes = |timestamps: &[std::time::SystemTime]| { + serialize_single_node_proof(&PaymentProof { + proof_of_payment: make_proof_with_timestamps(timestamps), + tx_hashes: Vec::new(), + commitment_sidecars: Vec::new(), + }) + .unwrap() + }; + assert_eq!(paid_proof_is_safely_fresh(&bytes(&[now]), now), Some(true)); + assert_eq!( + paid_proof_is_safely_fresh(&bytes(&[now, stale]), now), + Some(false) + ); + assert_eq!(paid_proof_is_safely_fresh(b"not a proof", now), None); + } + #[test] #[cfg(any(feature = "native", test))] fn proof_is_safely_fresh_accepts_recent_quote() { diff --git a/ant-core/src/data/manifest/mod.rs b/ant-core/src/data/manifest/mod.rs index 4fa989d1..cc7602f3 100644 --- a/ant-core/src/data/manifest/mod.rs +++ b/ant-core/src/data/manifest/mod.rs @@ -377,10 +377,15 @@ impl ManifestEntry { /// The plaintext size the file is known to have: from an embedded root /// DataMap, which lists every chunk. `None` for a `Public` entry or a /// shrunk map, whose size is not known until it is resolved. + /// + /// Summed as `u64`: `DataMap::original_file_size` sums `usize`, which + /// overflows past 4 GiB on 32-bit targets such as `wasm32`. pub fn known_size(&self) -> Option { match &self.source { ContentRef::Embedded { data_map } if !data_map.is_child() => { - u64::try_from(data_map.original_file_size()).ok() + data_map.infos().iter().try_fold(0u64, |total, info| { + total.checked_add(u64::try_from(info.src_size).ok()?) + }) } _ => None, } @@ -391,9 +396,16 @@ impl ManifestEntry { pub fn effective_name(&self) -> Result { match &self.path { Some(path) => Ok(path.clone()), - None => Ok(hex::encode(self.source.content_address()?)), + None => Ok(self.effective_name_with(&self.source.content_address()?)), } } + + /// [`Self::effective_name`] for a caller that has already derived the + /// entry's content address, which for an embedded entry means + /// self-encrypting its DataMap. + pub fn effective_name_with(&self, address: &[u8; ADDRESS_LEN]) -> String { + self.path.clone().unwrap_or_else(|| hex::encode(address)) + } } impl Manifest { @@ -485,8 +497,14 @@ impl Manifest { pub fn encode(&self) -> Result, ManifestError> { let mut canonical = self.clone(); canonical.canonicalize()?; - let body = rmp_serde::to_vec_named(&canonical) - .map_err(|e| ManifestError::Encode(e.to_string()))?; + canonical.encode_canonical() + } + + /// Encode a manifest [`Self::canonicalize`] has already validated and + /// sorted, without doing either again. Only the size limit is checked. + pub(crate) fn encode_canonical(&self) -> Result, ManifestError> { + let body = + rmp_serde::to_vec_named(self).map_err(|e| ManifestError::Encode(e.to_string()))?; let mut bytes = Vec::with_capacity(MANIFEST_HEADER_LEN + body.len()); bytes.extend_from_slice(&MANIFEST_MAGIC); bytes.push(MANIFEST_FORMAT_VERSION); @@ -969,6 +987,25 @@ mod tests { } } + #[test] + fn known_size_is_unknown_rather_than_wrapped_when_sizes_overflow() { + let chunk = |index| ChunkInfo { + index, + dst_hash: XorName::from_content(&[0]), + src_hash: XorName::from_content(&[1]), + src_size: usize::MAX, + }; + let entry = ManifestEntry { + path: None, + size: None, + sha256: None, + source: ContentRef::Embedded { + data_map: DataMap::new(vec![chunk(0), chunk(1)]), + }, + }; + assert_eq!(entry.known_size(), None); + } + #[test] fn known_size_comes_from_a_root_map_only() { let root = large_root(); diff --git a/ant-core/wasm-tests/manifest-link.test.mjs b/ant-core/wasm-tests/manifest-link.test.mjs index 6b804ff6..46e07d08 100644 --- a/ant-core/wasm-tests/manifest-link.test.mjs +++ b/ant-core/wasm-tests/manifest-link.test.mjs @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { decodeManifest, parseManifestLink } from "./pkg/ant_core.js"; +import { + createFileLink, + createManifest, + decodeManifest, + parseManifestLink, +} from "./pkg/ant_core.js"; // A manifest built by `ant manifest create --embed-public ADDRESS=file.bin` // against mainnet: one embedded entry whose DataMap is a shrunk child map, @@ -48,3 +53,52 @@ test("decodeManifest requires the full header", () => { wrongVersion[4] = 2; assert.throws(() => decodeManifest(wrongVersion), /version/); }); + +test("createManifest takes JavaScript input and writes what the decoders read", () => { + const embedded = parseManifestLink(LINK).manifest.entries[0]; + const created = createManifest({ + name: "files", + torrent: { infoHashV1: "ab".repeat(20) }, + entries: [ + { kind: "public", path: "z.txt", size: 20, address: `ant://${"cd".repeat(32)}` }, + { kind: "embedded", path: "a.bin", dataMap: embedded.dataMap }, + ], + }); + assert.ok(created.bytes instanceof Uint8Array); + assert.deepEqual([...created.bytes.subarray(0, 4)], MAGIC); + assert.equal(created.linkRecommended, true); + assert.deepEqual( + created.manifest.entries.map(({ name, kind }) => [name, kind]), + [["a.bin", "embedded"], ["z.txt", "public"]], + "entries are listed in the order the bytes hold them", + ); + assert.equal(created.manifest.entries[0].address, ADDRESS); + assert.equal(created.manifest.entries[1].size, 20); + assert.equal(created.manifest.torrent.infoHashV1, "ab".repeat(20)); + + assert.deepEqual(decodeManifest(created.bytes), created.manifest); + assert.deepEqual(parseManifestLink(created.link), { kind: "manifest", manifest: created.manifest }); +}); + +test("createManifest refuses input it does not know", () => { + const address = "cd".repeat(32); + for (const [input, error] of [ + [{ entries: [], extra: true }, /unknown field `extra`/], + [{ entries: [{ kind: "public", address, extra: true }] }, /unknown field `extra`/], + [{ entries: [{ kind: "Public", address }] }, /unknown variant `Public`/], + [{ entries: [{ kind: "embedded", dataMap: [1, 2, 3] }] }, /invalid DataMap/], + [{ entries: [{ kind: "public", address: LINK }] }, /public entry requires a file address/], + [{ torrent: { infoHashV1: "ab".repeat(20), infoHashV3: "" }, entries: [] }, /unknown field `infoHashV3`/], + [{ entries: [{ kind: "public", address, size: 2 ** 53 }] }, /expected u64/], + [{ entries: [{ kind: "public", address, size: 2n ** 53n }] }, /safe integer/], + ]) { + assert.throws(() => createManifest(input), error); + } +}); + +test("createFileLink formats a public address in the shared link grammar", () => { + assert.equal(createFileLink(ADDRESS), `ant://${ADDRESS}`); + assert.equal(createFileLink(`ant://${ADDRESS.toUpperCase()}`), `ant://${ADDRESS}`); + assert.throws(() => createFileLink(LINK), /expected a public file address/); + assert.throws(() => createFileLink("not an address")); +}); diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index 4a42f6e0..c8594175 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -197,7 +197,7 @@ test("one peer claiming the pointer moved on does not end a paid write", async ( try { await other.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay); await other.updatePointer(seed, target(2), "chunk", paymentNetwork, wallet().pay, - value => { newer = value.record; }); + { onPaid: value => { newer = value.record; } }); } finally { other.close(); } @@ -208,7 +208,7 @@ test("one peer claiming the pointer moved on does not end a paid write", async ( const first = new BrowserNetworkClient(rtc.endpoints); try { await first.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay, - value => { paid = value; }); + { onPaid: value => { paid = value; } }); } finally { first.close(); } @@ -251,7 +251,7 @@ test("an onPaid callback that never settles does not keep a paid state from bein try { const written = await Promise.race([ client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, - value => { paid = value; return new Promise(() => {}); }), + { onPaid: value => { paid = value; return new Promise(() => {}); } }), new Promise((_, reject) => deadline = setTimeout( () => reject(new Error("the write waited on onPaid")), 10000)), ]); @@ -272,7 +272,7 @@ test("one node refusing while the rest miss a round does not end a paid write", let paid; try { await first.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay, - value => { paid = value; }); + { onPaid: value => { paid = value; } }); } finally { first.close(); } @@ -306,7 +306,7 @@ test("nodes that cannot store just then do not end a paid write", async () => { let paid; try { await first.createPointer(seed, target(1), "chunk", paymentNetwork, wallet().pay, - value => { paid = value; }); + { onPaid: value => { paid = value; } }); } finally { first.close(); } @@ -370,7 +370,7 @@ test("a transfer stored again from onPaid is done even if every acknowledgement 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; }); + { onPaid: value => { paid = value; } }); } finally { first.close(); } @@ -398,7 +398,7 @@ test("a paid state handed to onPaid is stored again without paying", async () => let paid; try { const written = await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, - value => { paid = value; }); + { onPaid: value => { paid = value; } }); assert.ok(paid.record instanceof Uint8Array && paid.proof instanceof Uint8Array); const before = puts(rtc).length; @@ -480,7 +480,7 @@ test("an open pointer reports its counter, and a transfer nowhere is refused bef }); const transaction = `0x${"ab".repeat(32)}`; -/** The receipt recovery takes: the transaction keyed by the quote it paid. */ +/** A receipt that keys the transaction by the quote it paid. */ const receiptFor = (quote) => ({ transactionHashes: { [quote.quoteHash]: transaction } }); test("onPrepared is awaited before the wallet, and one that fails pays nothing", async () => { @@ -492,7 +492,9 @@ test("onPrepared is awaited before the wallet, and one that fails pays nothing", try { await client.createPointer(seed, target(1), "chunk", paymentNetwork, pay, { onPrepared: async checkpoint => { - assert.equal(JSON.parse(checkpoint).version, 1); + const saved = JSON.parse(checkpoint); + assert.equal(saved.version, 1); + assert.equal(typeof saved.plan, "string", "the plan's bytes are not spelt out as JSON numbers"); order.push("prepared"); await new Promise(resolve => setTimeout(resolve, 20)); order.push("kept"); @@ -505,7 +507,7 @@ test("onPrepared is awaited before the wallet, and one that fails pays nothing", client.updatePointer(seed, target(2), "chunk", paymentNetwork, pay, { onPrepared: async () => { throw new Error("disk full"); }, }), - /disk full/, + /onPrepared failed: disk full; payment was not submitted/, ); assert.equal(signer.calls.length, 1, "a checkpoint that was not kept is not paid for"); assert.equal(puts(rtc).length, before, "and nothing is sent"); @@ -520,9 +522,12 @@ test("pointer write options are checked before anything is paid", async () => { const signer = wallet(); try { for (const [options, error] of [ - [{ onPrepare: () => {} }, /unknown pointer write option "onPrepare"/], + [{ onPrepare: () => {} }, /unknown field `onPrepare`/], [{ onPaid: 5 }, /onPaid must be a function/], + [{ onSubmission: "x" }, /onSubmission must be a function/], [7, /must be an object/], + // Earlier releases took a bare `onPaid` function here. + [() => {}, /must be an object/], ]) { await assert.rejects( client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, options), @@ -544,7 +549,7 @@ test("a settlement refusal learned while onPrepared runs stops the payment", asy client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, { onPrepared: () => client.test_refuse_settlement(), }), - /test settlement refusal/, + /test settlement refusal.*; payment was not submitted/, ); assert.equal(signer.calls.length, 0, "a write this build cannot settle is not paid for"); } finally { @@ -574,15 +579,14 @@ test("recoverPointerPayment rebuilds the paid state from its checkpoint and rece ); assert.equal(signer.calls.length, 1, "recovering pays nothing"); - await assert.rejects( - async () => recoverPointerPayment(checkpoint, { transactionHash: transaction, totalAmount: quote.amount }), - /transactionHashes/, - "a receipt that does not say which quote it paid is refused", - ); - await assert.rejects( - async () => recoverPointerPayment(checkpoint, { transactionHashes: { ["cd".repeat(32)]: transaction } }), - /does not pay this checkpoint's quote/, - ); + const single = recoverPointerPayment(checkpoint, { transactionHash: transaction, totalAmount: quote.amount }); + assert.deepEqual(single.proof, paid.proof, "one transaction pays the checkpoint's one quote"); + for (const receipt of [{ transactionHashes: { ["cd".repeat(32)]: transaction } }, {}]) { + await assert.rejects( + async () => recoverPointerPayment(checkpoint, receipt), + /does not pay this checkpoint's quote/, + ); + } const saved = JSON.parse(checkpoint); await assert.rejects( @@ -601,3 +605,56 @@ test("recoverPointerPayment rebuilds the paid state from its checkpoint and rece client.close(); } }); + +test("onSubmission keeps each broadcast before the wallet goes on, and recovers from it", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const order = []; + // A wallet lost after broadcasting: it reports the transaction, then fails. + const pay = async (_network, quotes, onSubmission) => { + await onSubmission({ transactionHash: transaction }); + order.push("wallet resumed"); + throw new Error(`page lost before the receipt for ${quotes.length} quote`); + }; + let checkpoint; + let submitted; + try { + await assert.rejects( + client.createPointer(seed, target(1), "chunk", paymentNetwork, pay, { + onPrepared: value => { checkpoint = value; }, + onSubmission: async value => { + await new Promise(resolve => setTimeout(resolve, 20)); + submitted = value; + order.push("kept"); + }, + }), + /page lost before the receipt/, + ); + assert.deepEqual(order, ["kept", "wallet resumed"], "the wallet waits for the page to keep it"); + assert.deepEqual(submitted, { transactionHash: transaction }); + + const recovered = recoverPointerPayment(checkpoint, submitted); + const stored = await client.storePaidPointer(recovered.record, recovered.proof, paymentNetwork); + assert.equal(stored.target, target(1)); + assert.equal((await client.getPointer(stored.address)).counter, "0"); + } finally { + client.close(); + } +}); + +test("an onPrepared that never settles fails the write without paying", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + await assert.rejects( + client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay, { + onPrepared: () => new Promise(() => {}), + }), + /onPrepared did not settle within \d+s; payment was not submitted/, + ); + assert.equal(signer.calls.length, 0); + } finally { + client.close(); + } +}); diff --git a/docs/adr/ADR-0007-browser-api-completion.md b/docs/adr/ADR-0007-browser-api-completion.md index 6e276ab1..ffc3961b 100644 --- a/docs/adr/ADR-0007-browser-api-completion.md +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -26,9 +26,17 @@ Browser applications need shared-core manifest exports and pointer operations wi ## Decision -Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to native writer policy: a root DataMap is embedded only up to the native size limit, and a size hint must match a root DataMap's exact size. +Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to native writer policy: a root DataMap is embedded only up to the native size limit, and a size hint must match a root DataMap's exact size. The entry count is checked before any entry is parsed. Refusals are a typed `ManifestInputError`. A link carries each embedded map's shrunk form, so an embedded root map must be one an upload shrank and stored the wrapper records for, as every upload does. -Pointer writes take a trailing options object, `{ onPrepared, onPaid }`; a bare function is still accepted as `onPaid`, and unknown options are refused. `onPrepared` is awaited with a versioned checkpoint containing the signed record and verified plan before the wallet is invoked. The settlement-refusal check is repeated after it, immediately before the wallet is invoked. The application durably keeps that checkpoint, and its wallet keeps each broadcast's transaction hash: only the wallet observes the broadcast, and the core's `onSubmission` records nothing for a pointer write. Export a recovery function that builds the paid proof from the original plan and a receipt naming the transaction by quote hash (`transactionHashes`). It checks that the receipt pays the checkpoint's quote, without signing, quoting or submitting another transaction. A failed pre-payment checkpoint prevents payment; a failed paid checkpoint must not prevent storing an already paid state. +Pointer writes take an optional trailing options object, `{ onPrepared, onSubmission, onPaid }`; unknown options are refused before anything is paid. The bare `onPaid` function earlier releases took is no longer accepted. + +- `onPrepared` is awaited, for at most 30 seconds, with a versioned checkpoint string containing the signed record and verified plan (never the owner seed) before the wallet is invoked. Its byte fields are base64 and the plan MessagePack, so it is a fraction of the size of JSON number arrays. A failed or unsettled `onPrepared` prevents payment. +- `onSubmission` is handed each transaction the wallet reports broadcasting, through the `onSubmission` callback the wallet is given, and the wallet waits on it. The page therefore keeps the transaction before the wallet goes on, without every wallet having to. +- `onPaid` is handed `{ record, proof }`; its failure must not prevent storing an already paid state. + +The settlement-refusal check is repeated immediately before the wallet is invoked. Nothing is paid unless the wallet is invoked, and every failure before that ends "payment was not submitted"; any later failure may have paid and is recovered from what the callbacks were handed. + +Export a recovery function that builds the paid proof from the original plan and the wallet's receipt or reported submission: `transactionHashes` keyed by quote hash, which must name the checkpoint's quote, or a single `transactionHash` paying its one quote, exactly as the write accepts. It does not sign, quote or submit another transaction, and the transaction must be confirmed on chain first. Storing a paid state whose quotes are older than storers accept reports that the proof expired, rather than a generic store failure. ## Consequences @@ -44,12 +52,12 @@ Pointer writes take a trailing options object, `{ onPrepared, onPaid }`; a bare ### Neutral / Operational -- Additive APIs; no network protocol change. New checkpoint schemas are versioned. +- Additive APIs except the pointer write options, which no longer accept a bare `onPaid` function; no network protocol change. New checkpoint schemas are versioned. - SDK releases require the companion Rust changes on ant-client main. ## Validation -Test malformed manifests, canonical round trips, pending and paid recovery, callback failures, network mismatches, cancellation and real-browser paid pointer writes and streaming saves. Record exact revisions and limitations in a dated audit. Human engineering review is required. +Test malformed manifests and their JavaScript input, canonical round trips, pending and paid recovery (from a receipt and from a reported submission), callback failures and timeouts, network mismatches, cancellation and real-browser paid pointer writes and streaming saves. Record exact revisions and limitations in a dated audit. Human engineering review is required. ## Notes for AI-assisted work From 583923992505d3c3ce28ecec4e57ad7f5a93d997 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:02:51 +0200 Subject: [PATCH 5/9] fix(browser): bound onSubmission and type pointer write failures Pointers: - onSubmission is bounded by the callback timeout and never rejects to the wallet: the transaction is already broadcast, so a page that cannot keep it must not hold up or fail the payment, as for onPaid. - The write waits for every reported broadcast to be kept before it goes on or fails, whether or not the wallet waited, as the upload adapter does. - Write failures are a typed PointerWriteError split at the wallet call: every failure before it, including invalid input, an existing pointer and a refused settlement, ends "payment was not submitted"; later failures are reported as they are. - write_pointer takes the write with its target, dropping the too_many_arguments exemption. - storePaidPointer notes a proof past the storers' real 24 h quote age (no safety margin), by this device's clock, alongside the storer's own error, instead of replacing it with a pay-again instruction. batch::paid_proof_has_expired is public and ungated, so no build config leaves its proof import unused. - Restored the warning that a write with neither onPrepared nor onSubmission keeps nothing to recover from. Manifests: - One embedding policy, data::manifest::is_embeddable, serves native and browser writers: a small root map, or a published map of at most MAX_PUBLISHED_MAP_CHUNKS. Browser input refuses an intermediate shrink level. - createManifest reads accepted keys from each input struct's serde derive (struct_fields) instead of hand-kept lists. - The created view takes addresses from the link form, whose maps are already shrunk, instead of shrinking each embedded map again. ADR-0007 is updated and listed in the ADR index. Co-Authored-By: Claude Opus 5.5 --- ant-core/src/browser.rs | 39 ++- ant-core/src/browser/file_manifest.rs | 159 ++++++--- .../src/browser/wasm_transport/pointer.rs | 331 ++++++++++-------- ant-core/src/data/client/batch.rs | 41 ++- ant-core/src/data/manifest/embed.rs | 7 +- ant-core/src/data/manifest/mod.rs | 16 + ant-core/src/data/mod.rs | 8 +- ant-core/wasm-tests/pointer.test.mjs | 70 ++++ docs/adr/ADR-0007-browser-api-completion.md | 8 +- docs/adr/README.md | 1 + 10 files changed, 456 insertions(+), 224 deletions(-) diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 9f4bb01e..66478b3d 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -272,7 +272,7 @@ fn browser_chunk_info(info: &ChunkInfo) -> BrowserChunkInfo { #[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] mod wasm { - use super::file_manifest::{MANIFEST_INPUT_FIELDS, TORRENT_FIELD, TORRENT_INPUT_FIELDS}; + use super::file_manifest::{struct_fields, ManifestInput, TorrentInput, TORRENT_FIELD}; use super::manifest::parse_browser_manifest; use super::payment::{payment_quote_hash, verify_storage_quote, BrowserQuoteArtifact}; use super::protocol::{ @@ -672,13 +672,14 @@ mod wasm { /// Build canonical `.ant` bytes and a compact share link without network I/O. #[wasm_bindgen(js_name = createManifest)] pub fn create_manifest_wasm(input: JsValue) -> Result { - reject_unknown_keys(&input, &MANIFEST_INPUT_FIELDS).map_err(|e| JsValue::from_str(&e))?; + reject_unknown_keys(&input, struct_fields::()) + .map_err(|e| JsValue::from_str(&e))?; if input.is_object() { let torrent = js_sys::Reflect::get(&input, &JsValue::from_str(TORRENT_FIELD))?; - reject_unknown_keys(&torrent, &TORRENT_INPUT_FIELDS) + reject_unknown_keys(&torrent, struct_fields::()) .map_err(|e| JsValue::from_str(&e))?; } - let input: super::file_manifest::ManifestInput = serde_wasm_bindgen::from_value(input) + let input: ManifestInput = serde_wasm_bindgen::from_value(input) .map_err(|error| JsValue::from_str(&error.to_string()))?; let result = input .create() @@ -1164,9 +1165,35 @@ pub struct BrowserManifestEntryView { impl BrowserManifestView { pub fn from_manifest( manifest: &crate::data::manifest::Manifest, + ) -> Result { + Self::addressed_from(manifest, manifest) + } + + /// [`Self::from_manifest`] for a caller holding the manifest's + /// [`link_form`](crate::data::manifest::Manifest::link_form) too. Its + /// DataMaps are already shrunk, so addresses come from it without + /// self-encrypting any embedded DataMap again. + pub(crate) fn from_manifest_and_link( + manifest: &crate::data::manifest::Manifest, + link: &crate::data::manifest::Manifest, + ) -> Result { + if link.entries.len() != manifest.entries.len() { + return Err(crate::data::manifest::ManifestError::Encode( + "link form does not match its manifest".into(), + )); + } + Self::addressed_from(manifest, link) + } + + /// The view of `manifest`, taking each entry's address from the entry + /// at the same position in `addresses`, a manifest with the same + /// content addresses in the same order. + fn addressed_from( + manifest: &crate::data::manifest::Manifest, + addresses: &crate::data::manifest::Manifest, ) -> Result { let mut entries = Vec::with_capacity(manifest.entries.len()); - for entry in &manifest.entries { + for (entry, addressed) in manifest.entries.iter().zip(&addresses.entries) { let data_map = match &entry.source { crate::data::manifest::ContentRef::Embedded { data_map } => Some( rmp_serde::to_vec(data_map) @@ -1176,7 +1203,7 @@ impl BrowserManifestView { }; // An embedded entry's address comes from self-encrypting its // DataMap; derive it once for both the name and the address. - let address = entry.source.content_address()?; + let address = addressed.source.content_address()?; entries.push(BrowserManifestEntryView { name: entry.effective_name_with(&address), path: entry.path.clone(), diff --git a/ant-core/src/browser/file_manifest.rs b/ant-core/src/browser/file_manifest.rs index 68f82f8b..d393b595 100644 --- a/ant-core/src/browser/file_manifest.rs +++ b/ant-core/src/browser/file_manifest.rs @@ -6,12 +6,13 @@ use super::manifest::MAX_SAFE_JS_INTEGER; use super::BrowserManifestView; use crate::data::manifest::{ - self, embedded_len, ContentRef, Link, Manifest, ManifestEntry, ManifestError, TorrentReference, - MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, MAX_MANIFEST_BYTES, - MAX_MANIFEST_ENTRIES, + self, embedded_len, is_embeddable, ContentRef, Link, Manifest, ManifestEntry, ManifestError, + TorrentReference, MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, + MAX_MANIFEST_BYTES, MAX_MANIFEST_ENTRIES, MAX_PUBLISHED_MAP_CHUNKS, }; use self_encryption::DataMap; -use serde::{Deserialize, Serialize}; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; use thiserror::Error; #[cfg(test)] @@ -23,14 +24,6 @@ use xor_name::XorName; /// names it. pub const TORRENT_FIELD: &str = "torrent"; -/// Every [`ManifestInput`] field as JavaScript names it. A JavaScript caller -/// checks keys against this: `serde_wasm_bindgen` reads only declared fields, -/// so `deny_unknown_fields` cannot refuse the others there. -pub const MANIFEST_INPUT_FIELDS: [&str; 3] = ["name", TORRENT_FIELD, "entries"]; - -/// Every [`TorrentInput`] field as JavaScript names it. -pub const TORRENT_INPUT_FIELDS: [&str; 2] = ["infoHashV1", "infoHashV2"]; - /// Why [`ManifestInput::create`] refused its input. #[derive(Debug, Error)] pub enum ManifestInputError { @@ -63,6 +56,18 @@ pub enum ManifestInputError { /// The limit. max: usize, }, + /// A shrunk DataMap that is not the published form an upload returns, + /// such as an intermediate shrink level. + #[error( + "a shrunk DataMap of {chunks} chunks is not the published form an upload returns, \ + which lists at most {max}" + )] + UnpublishedDataMap { + /// Chunks the map lists. + chunks: usize, + /// The most a published map lists. + max: usize, + }, /// A size hint JavaScript cannot represent exactly. #[error("size {0} exceeds JavaScript's safe integer range")] UnsafeSize(u64), @@ -82,6 +87,44 @@ pub enum ManifestInputError { }, } +/// The field names a struct deserializes from, exactly as its serde derive +/// declares them, after any `rename_all`. A JavaScript caller checks keys +/// against these: `serde_wasm_bindgen` reads only the declared fields, so +/// `deny_unknown_fields` cannot refuse the others there. +pub fn struct_fields<'de, T: Deserialize<'de>>() -> &'static [&'static str] { + let mut recorder = FieldRecorder(&[]); + // Fails by design: the recorder only notes the names it is handed. + let _ = T::deserialize(&mut recorder); + recorder.0 +} + +/// A deserializer that records the field names a struct asks for. +struct FieldRecorder(&'static [&'static str]); + +impl<'de> Deserializer<'de> for &mut FieldRecorder { + type Error = de::value::Error; + + fn deserialize_any>(self, _: V) -> Result { + Err(de::Error::custom("not a struct")) + } + + fn deserialize_struct>( + self, + _: &'static str, + fields: &'static [&'static str], + _: V, + ) -> Result { + self.0 = fields; + Err(de::Error::custom("field names recorded")) + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes + byte_buf option unit unit_struct newtype_struct seq tuple tuple_struct map enum + identifier ignored_any + } +} + /// What `createManifest` takes: metadata the creator supplies. The address of /// an embedded entry is derived from its DataMap, never taken on trust. #[derive(Debug, Deserialize)] @@ -181,9 +224,10 @@ impl ManifestInput { // canonical too. manifest.canonicalize()?; let bytes = manifest.encode_canonical()?; - let link_bytes = manifest.link_form()?.encode_canonical()?; + let link = manifest.link_form()?; + let link_bytes = link.encode_canonical()?; Ok(CreatedManifest { - manifest: BrowserManifestView::from_manifest(&manifest)?, + manifest: BrowserManifestView::from_manifest_and_link(&manifest, &link)?, bytes, link: manifest::manifest_link_from_bytes(&link_bytes), link_recommended: link_bytes.len() <= MANIFEST_LINK_RECOMMENDED_MAX_BYTES, @@ -269,7 +313,8 @@ fn torrent_hash( } /// Decode a canonical DataMap and hold it to what a native writer embeds: a -/// root map only while it is small, otherwise the shrunk map an upload returns. +/// root map only while it is small, otherwise the published map an upload +/// returns. fn embeddable_data_map(bytes: &[u8]) -> Result { let data_map: DataMap = rmp_serde::from_slice(bytes) .map_err(|e| ManifestInputError::InvalidDataMap(e.to_string()))?; @@ -278,14 +323,20 @@ fn embeddable_data_map(bytes: &[u8]) -> Result { if canonical != bytes { return Err(ManifestInputError::NonCanonicalDataMap); } - let len = embedded_len(&data_map)?; - if !data_map.is_child() && len > MAX_EMBEDDED_ROOT_MAP_BYTES { - return Err(ManifestInputError::RootDataMapTooLarge { - len, - max: MAX_EMBEDDED_ROOT_MAP_BYTES, - }); + if is_embeddable(&data_map)? { + return Ok(data_map); } - Ok(data_map) + Err(if data_map.is_child() { + ManifestInputError::UnpublishedDataMap { + chunks: data_map.len(), + max: MAX_PUBLISHED_MAP_CHUNKS, + } + } else { + ManifestInputError::RootDataMapTooLarge { + len: embedded_len(&data_map)?, + max: MAX_EMBEDDED_ROOT_MAP_BYTES, + } + }) } #[cfg(test)] @@ -373,24 +424,35 @@ mod tests { } #[test] - fn the_field_lists_name_every_input_field() { - // `deny_unknown_fields` holds through serde_json, so input naming - // exactly the listed fields parses only if the lists are complete. - let torrent: serde_json::Map<_, _> = TORRENT_INPUT_FIELDS - .iter() - .map(|field| (field.to_string(), serde_json::Value::Null)) - .collect(); - let input = serde_json::json!({ - "name": null, - TORRENT_FIELD: torrent, - "entries": [], - }); - let keys: Vec<_> = input.as_object().unwrap().keys().cloned().collect(); - assert_eq!(keys.len(), MANIFEST_INPUT_FIELDS.len()); - assert!(keys - .iter() - .all(|key| MANIFEST_INPUT_FIELDS.contains(&key.as_str()))); - serde_json::from_value::(input).expect("every listed field is known"); + fn struct_fields_are_the_names_javascript_uses() { + assert_eq!( + struct_fields::(), + ["name", TORRENT_FIELD, "entries"] + ); + assert_eq!( + struct_fields::(), + ["infoHashV1", "infoHashV2"] + ); + } + + #[test] + fn an_embedded_entry_is_addressed_as_its_link_form_is() { + // Enough chunks that the link form shrinks the map. + let input = ManifestInput { + name: None, + torrent: None, + entries: vec![EntryInput::Embedded { + path: None, + size: None, + data_map: data_map_bytes(MAX_PUBLISHED_MAP_CHUNKS * 4, None), + }], + }; + let created = input.create().expect("manifest"); + let decoded = Manifest::decode(&created.bytes).expect("native decode"); + let address = hex::encode(decoded.entries[0].source.content_address().unwrap()); + let entry = &created.manifest.entries[0]; + assert_eq!(entry.address, address); + assert_eq!(entry.name, address, "a path-less entry is named by it"); } #[test] @@ -451,7 +513,7 @@ mod tests { } #[test] - fn only_a_small_root_data_map_is_embedded() { + fn only_a_small_root_or_published_data_map_is_embedded() { let error = embedded(None, data_map_bytes(OVERSIZED_CHUNKS, None)) .create() .unwrap_err(); @@ -460,12 +522,21 @@ mod tests { "{error}" ); - // A shrunk map carries no exact size, so any hint is kept as given. - let created = embedded(Some(1), data_map_bytes(OVERSIZED_CHUNKS, Some(1))) + // A published map carries no exact size, so any hint is kept as given. + let created = embedded(Some(1), data_map_bytes(MAX_PUBLISHED_MAP_CHUNKS, Some(1))) .create() - .expect("a shrunk map of any size is embedded"); + .expect("the published map an upload returns is embedded"); assert_eq!(created.manifest.entries[0].known_size, None); Manifest::decode(&created.bytes).expect("native decode"); + + // A larger shrunk map is an intermediate level no writer embeds. + let error = embedded(None, data_map_bytes(MAX_PUBLISHED_MAP_CHUNKS + 1, Some(1))) + .create() + .unwrap_err(); + assert!( + matches!(error, ManifestInputError::UnpublishedDataMap { .. }), + "{error}" + ); } #[test] diff --git a/ant-core/src/browser/wasm_transport/pointer.rs b/ant-core/src/browser/wasm_transport/pointer.rs index 871228a9..d76436fc 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -13,7 +13,7 @@ use super::upload_adapter::{paid_transactions, quote_transactions, wallet_quotes}; use super::*; -use crate::data::client::batch::{paid_proof_is_safely_fresh, ChunkPaymentPlan}; +use crate::data::client::batch::{paid_proof_has_expired, ChunkPaymentPlan}; use crate::data::{ ml_dsa_65, pointer_address, FinalState, FinalityStatus, MlDsaPublicKey, MlDsaSecretKey, Pointer, PointerFinality, PointerTarget, PointerTargetKind, @@ -45,10 +45,6 @@ const CALLBACK_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(feature = "test-utils")] const CALLBACK_TIMEOUT: Duration = Duration::from_secs(1); -/// Ends every error a write returns before its wallet is invoked: nothing -/// was paid, and retrying cannot pay twice. -const NOT_SUBMITTED: &str = "payment was not submitted"; - /// The pointer write option awaited with the checkpoint before paying. const ON_PREPARED_OPTION: &str = "onPrepared"; @@ -288,6 +284,9 @@ impl BrowserNetworkClient { /// Nothing is paid unless `pay_for_quotes` is called. A write that fails /// before calling it says "payment was not submitted"; any later failure /// may have paid, and is recovered from what the callbacks were handed. + /// Without `onPrepared` and `onSubmission` there is nothing to recover + /// from: a page lost between the broadcast and its receipt pays again on + /// retry. #[wasm_bindgen(js_name = createPointer)] pub async fn create_pointer( &self, @@ -298,20 +297,9 @@ impl BrowserNetworkClient { pay_for_quotes: js_sys::Function, options: Option, ) -> Result { - let payment_network = parse_payment_network(payment_network)?; - let written = self - .write_pointer( - PointerWrite::Create, - owner_seed, - target, - kind, - &payment_network, - &pay_for_quotes, - options, - ) + let write = PointerWrite::Create { target, kind }; + self.write_pointer(write, owner_seed, payment_network, &pay_for_quotes, options) .await - .map_err(|error| JsValue::from_str(&error))?; - to_js(&written) } /// Point the pointer `owner_seed` controls at `target`, one past the @@ -327,20 +315,9 @@ impl BrowserNetworkClient { pay_for_quotes: js_sys::Function, options: Option, ) -> Result { - let payment_network = parse_payment_network(payment_network)?; - let written = self - .write_pointer( - PointerWrite::Update, - owner_seed, - target, - kind, - &payment_network, - &pay_for_quotes, - options, - ) + let write = PointerWrite::Update { target, kind }; + self.write_pointer(write, owner_seed, payment_network, &pay_for_quotes, options) .await - .map_err(|error| JsValue::from_str(&error))?; - to_js(&written) } /// Hand the pointer `owner_seed` controls over to the pointer at @@ -361,20 +338,9 @@ impl BrowserNetworkClient { pay_for_quotes: js_sys::Function, options: 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, - options, - ) + let write = PointerWrite::Transfer { recipient }; + self.write_pointer(write, owner_seed, payment_network, &pay_for_quotes, options) .await - .map_err(|error| JsValue::from_str(&error))?; - to_js(&written) } /// Ask the whole close group whether the pointer at `address` is final, @@ -396,7 +362,7 @@ impl BrowserNetworkClient { /// [`recover_pointer_payment`] rebuilt. Pays nothing. /// /// Storers accept a proof only while its quotes are under a day old; a - /// refusal of an older proof says it expired. + /// failure with an older proof, by this device's clock, says so. #[wasm_bindgen(js_name = storePaidPointer)] pub async fn store_paid_pointer( &self, @@ -424,15 +390,27 @@ impl BrowserNetworkClient { } } -/// Which write a page asked for. +/// Which write a page asked for, with the target as the page gave it. #[derive(Clone, Copy)] -enum PointerWrite { +enum PointerWrite<'a> { /// A new pointer, refused if one exists. - Create, + Create { target: &'a str, kind: &'a str }, /// One past what the network serves, or a creation. - Update, - /// The final state, handing the pointer over to the target pointer. - Transfer, + Update { target: &'a str, kind: &'a str }, + /// The final state, handing the pointer over to the recipient pointer. + Transfer { recipient: &'a str }, +} + +/// Why a pointer write failed, and whether it can have paid. +#[derive(Debug, thiserror::Error)] +enum PointerWriteError { + /// It failed before the wallet was invoked, so nothing was paid and a + /// retry cannot pay twice. + #[error("{0}; payment was not submitted")] + NotSubmitted(String), + /// It failed once the wallet had been invoked, so it may have paid. + #[error("{0}")] + MayHavePaid(String), } /// The callbacks a pointer write takes in its trailing `options` argument. @@ -484,34 +462,93 @@ fn option_function(options: &JsValue, name: &str) -> Result, owner_seed: &[u8], - target: &str, - kind: &str, - payment_network: &BrowserPaymentNetwork, + payment_network: JsValue, pay_for_quotes: &js_sys::Function, options: Option, - ) -> Result { + ) -> Result { + let written = match self + .prepare_write(write, owner_seed, payment_network, options) + .await + { + Ok(prepared) => self.pay_and_store(prepared, pay_for_quotes).await, + Err(error) => Err(PointerWriteError::NotSubmitted(error)), + } + .map_err(|error| JsValue::from_str(&error.to_string()))?; + to_js(&written) + } + + /// Everything before the wallet: read the inputs, sign the state, quote + /// it, and hand the checkpoint to `onPrepared`. Nothing here pays. + async fn prepare_write( + &self, + write: PointerWrite<'_>, + owner_seed: &[u8], + payment_network: JsValue, + options: Option, + ) -> Result { let options = PointerWriteOptions::parse(options)?; + let payment_network = parse_payment_network(payment_network).map_err(js_error_message)?; let (owner, secret) = owner_keys(owner_seed)?; - let target = parse_target(target, kind)?; - let client = self.paying_client(payment_network); + let client = self.paying_client(&payment_network); let record = match write { - PointerWrite::Create => client.pointer_sign_create(&secret, &owner, target).await, - PointerWrite::Update => client.pointer_sign_update(&secret, &owner, target).await, - PointerWrite::Transfer => { + PointerWrite::Create { target, kind } => { + let target = parse_target(target, kind)?; + client.pointer_sign_create(&secret, &owner, target).await + } + PointerWrite::Update { target, kind } => { + let target = parse_target(target, kind)?; + client.pointer_sign_update(&secret, &owner, target).await + } + PointerWrite::Transfer { recipient } => { + let recipient = parse_target(recipient, "pointer")?; client - .pointer_sign_transfer(&secret, &owner, target.address) + .pointer_sign_transfer(&secret, &owner, recipient.address) .await } } .map_err(|error| error.to_string())?; - self.store_pointer(&client, &record, payment_network, pay_for_quotes, &options) + let plan = client + .prepare_pointer_payment(&record) + .await + .map_err(|error| error.to_string())?; + if let Some(on_prepared) = &options.on_prepared { + let checkpoint = PointerPaymentCheckpoint::encode(&record, &plan)?; + // Nothing is paid until the page has kept the checkpoint. + crate::runtime::timeout( + CALLBACK_TIMEOUT, + call_and_wait(on_prepared, &JsValue::from_str(&checkpoint)), + ) .await + .map_err(|_| { + format!( + "onPrepared did not settle within {}s", + CALLBACK_TIMEOUT.as_secs() + ) + })? + .map_err(|error| format!("onPrepared failed: {}", js_error_message(error)))?; + } + Ok(PreparedWrite { + client, + record, + plan, + payment_network, + options, + }) } /// A client whose quotes and paid writes go only to nodes on the page's @@ -526,76 +563,54 @@ impl BrowserNetworkClient { .with_shared_quote_state(&self.shared) } - /// Quote the record's state, have the wallet pay it, then store it. - async fn store_pointer( + /// Have the wallet pay a prepared state, then store it. + async fn pay_and_store( &self, - client: &crate::data::Client, - record: &Pointer, - payment_network: &BrowserPaymentNetwork, + prepared: PreparedWrite, pay_for_quotes: &js_sys::Function, - options: &PointerWriteOptions, - ) -> Result { - let plan = client - .prepare_pointer_payment(record) - .await - .map_err(|error| error.to_string())?; - if let Some(on_prepared) = &options.on_prepared { - let checkpoint = PointerPaymentCheckpoint::encode(record, &plan)?; - // Nothing is paid until the page has kept the checkpoint. - crate::runtime::timeout( - CALLBACK_TIMEOUT, - call_and_wait(on_prepared, &JsValue::from_str(&checkpoint)), - ) - .await - .map_err(|_| { - format!( - "onPrepared did not settle within {}s; {NOT_SUBMITTED}", - CALLBACK_TIMEOUT.as_secs() - ) - })? - .map_err(|error| { - format!( - "onPrepared failed: {}; {NOT_SUBMITTED}", - js_error_message(error) - ) - })?; - } + ) -> Result { + let PreparedWrite { + client, + record, + plan, + payment_network, + options, + } = prepared; let (transactions, submission) = self .pay_plan( - client, + &client, &plan, - payment_network, + &payment_network, pay_for_quotes, options.on_submission.as_ref(), ) .await?; - let proof = plan - .proof(&transactions) - .map_err(|error| error.to_string())?; + let paid = |error: crate::data::Error| PointerWriteError::MayHavePaid(error.to_string()); + let proof = plan.proof(&transactions).map_err(paid)?; if let Some(on_paid) = &options.on_paid { // The payment is spent whatever the page does with this, so a // callback that fails, or never settles, does not stop the state // being stored. let hand_over = async { - let paid = paid_state(&record.to_bytes(), &proof)?; - call_and_wait(on_paid, &paid).await + let state = paid_state(&record.to_bytes(), &proof)?; + call_and_wait(on_paid, &state).await }; let _ = crate::runtime::timeout(CALLBACK_TIMEOUT, hand_over).await; } - if let Err(error) = client.pointer_put_paid(record, proof).await { + 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()); + return Err(paid(error)); } client - .recover_final_write(record, error) + .recover_final_write(&record, error) .await - .map_err(|error| error.to_string())?; + .map_err(paid)?; } Ok(BrowserPointerWrite { - pointer: BrowserPointer::from(record), + pointer: BrowserPointer::from(&record), transaction_hash: submission.transaction_hash, storage_cost_atto: submission.total_amount, }) @@ -617,37 +632,52 @@ impl BrowserNetworkClient { payment_network: &BrowserPaymentNetwork, wallet: &js_sys::Function, on_submission: Option<&js_sys::Function>, - ) -> Result<(HashMap, BrowserPaymentSubmission), String> { - let verified = wallet_quotes(std::slice::from_ref(plan)).map_err(|e| e.to_string())?; - let network = serde_wasm_bindgen::to_value(payment_network).map_err(|e| e.to_string())?; - let quotes = serde_wasm_bindgen::to_value(&verified).map_err(|e| e.to_string())?; - let on_submission = on_submission.cloned(); - // The wallet waits on the promise this returns, so the page has kept - // the transaction before the wallet goes on. - let on_submission = - Closure:: Promise>::new( - move |sent: JsValue| match &on_submission { - Some(callback) => match callback.call1(&JsValue::NULL, &sent) { - Ok(returned) => Promise::resolve(&returned), - Err(error) => Promise::reject(&error), - }, - None => Promise::resolve(&JsValue::UNDEFINED), - }, - ) - .into_js_value(); + ) -> Result<(HashMap, BrowserPaymentSubmission), PointerWriteError> { + let verified = wallet_quotes(std::slice::from_ref(plan)) + .map_err(|e| PointerWriteError::NotSubmitted(e.to_string()))?; + let network = serde_wasm_bindgen::to_value(payment_network) + .map_err(|e| PointerWriteError::NotSubmitted(e.to_string()))?; + let quotes = serde_wasm_bindgen::to_value(&verified) + .map_err(|e| PointerWriteError::NotSubmitted(e.to_string()))?; + let kept = Rc::new(RefCell::new(Vec::::new())); + let on_submission = { + let kept = Rc::clone(&kept); + let callback = on_submission.cloned(); + Closure:: Promise>::new(move |sent: JsValue| { + let callback = callback.clone(); + // Bounded and never rejected: the transaction is already + // broadcast, so keeping it must neither hold up nor fail the + // payment, as for `onPaid`. + let keeping = wasm_bindgen_futures::future_to_promise(async move { + if let Some(callback) = callback { + let _ = crate::runtime::timeout( + CALLBACK_TIMEOUT, + call_and_wait(&callback, &sent), + ) + .await; + } + Ok(JsValue::UNDEFINED) + }); + kept.borrow_mut().push(keeping.clone()); + keeping + }) + } + // JS owns the callback while a wallet may still report a broadcast. + .into_js_value(); // Checked again here, not only when quoting: `onPrepared` may have // taken long enough for another write to learn that this build // cannot settle. if let Some(refusal) = client.corroborated_settlement_refusal() { - return Err(format!( - "{}; {NOT_SUBMITTED}", - crate::data::Error::ClientUpdateRequired(refusal) + return Err(PointerWriteError::NotSubmitted( + crate::data::Error::ClientUpdateRequired(refusal).to_string(), )); } if self.inner.pool.availability.closed.get() { - return Err(format!("browser client is closed; {NOT_SUBMITTED}")); + return Err(PointerWriteError::NotSubmitted( + "browser client is closed".into(), + )); } - let returned = wallet + let paid = wallet .call4( &JsValue::NULL, &network, @@ -655,13 +685,23 @@ impl BrowserNetworkClient { &on_submission, &JsValue::UNDEFINED, ) - .map_err(js_error_message)?; - let value = JsFuture::from(Promise::resolve(&returned)) - .await - .map_err(js_error_message)?; - let submission: BrowserPaymentSubmission = - serde_wasm_bindgen::from_value(value).map_err(|e| e.to_string())?; - let transactions = paid_transactions(&verified, &submission).map_err(|e| e.to_string())?; + .map(|returned| JsFuture::from(Promise::resolve(&returned))); + let paid = match paid { + Ok(receipt) => receipt.await, + Err(error) => Err(error), + }; + // Whether or not the wallet waited, the page has kept every + // broadcast it reported before the write goes on or fails. + let keeping = kept.borrow().clone(); + for promise in keeping { + let _ = JsFuture::from(promise).await; + } + let value = + paid.map_err(|error| PointerWriteError::MayHavePaid(js_error_message(error)))?; + let submission: BrowserPaymentSubmission = serde_wasm_bindgen::from_value(value) + .map_err(|e| PointerWriteError::MayHavePaid(e.to_string()))?; + let transactions = paid_transactions(&verified, &submission) + .map_err(|e| PointerWriteError::MayHavePaid(e.to_string()))?; Ok((transactions, submission)) } } @@ -687,13 +727,14 @@ fn paid_state(record: &[u8], proof: &[u8]) -> Result { Ok(paid.into()) } -/// A storer's refusal of a paid state, saying so when the proof has expired: -/// that refusal is final, unlike a network failure worth retrying. +/// A failure to store a paid state, noting when the proof holds a quote +/// past the age storers accept by this device's clock: unlike a network +/// failure, retrying that proof cannot succeed. fn explain_refusal(proof: &[u8], error: crate::data::Error) -> String { - match paid_proof_is_safely_fresh(proof, crate::runtime::system_time()) { - Some(false) => format!( - "paid proof expired: its quotes are older than storers accept, so this state \ - cannot be stored without paying again ({error})" + match paid_proof_has_expired(proof, crate::runtime::system_time()) { + Some(true) => format!( + "{error} (the paid proof holds a quote older than storers accept, by this \ + device's clock)" ), _ => error.to_string(), } @@ -723,7 +764,7 @@ impl PointerPaymentCheckpoint { if checkpoint.len() > MAX_POINTER_CHECKPOINT_BYTES { return Err(format!( "pointer checkpoint exceeds the {MAX_POINTER_CHECKPOINT_BYTES} bytes recovery \ - reads; {NOT_SUBMITTED}" + reads" )); } Ok(checkpoint) diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index d39d893c..bbebc0da 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -885,7 +885,9 @@ fn prune_locally_expired_proofs( proofs: HashMap<[u8; 32], Vec>, ) -> HashMap> { let now = std::time::SystemTime::now(); - let max_safe_age = max_safe_proof_age(); + let max_safe_age = Duration::from_secs( + CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS), + ); let mut kept: HashMap> = HashMap::with_capacity(proofs.len()); // Pair each expired address with the EXACT bytes we observed at // load time. The cache-side drop only removes the entry if those @@ -920,19 +922,16 @@ fn prune_locally_expired_proofs( kept } -/// The quote age past which a proof is too close to the storer's limit to -/// trust. -fn max_safe_proof_age() -> Duration { - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS.saturating_sub(CACHED_PROOF_SAFETY_MARGIN_SECS)) -} - -/// Whether serialized proof bytes are still young enough for storers to -/// accept, judged as a cached proof is. `None` if the bytes are not a -/// single-node proof. -#[cfg(any(all(target_arch = "wasm32", feature = "browser-wasm"), test))] -pub(crate) fn paid_proof_is_safely_fresh(proof: &[u8], now: std::time::SystemTime) -> Option { +/// Whether serialized proof bytes hold a quote older at `now` than storers +/// accept (`QUOTE_MAX_AGE_SECS`, with no safety margin), so storing them can +/// no longer succeed. `None` if the bytes are not a single-node proof. +pub fn paid_proof_has_expired(proof: &[u8], now: std::time::SystemTime) -> Option { let (proof, _) = deserialize_proof(proof).ok()?; - Some(proof_is_safely_fresh(&proof, now, max_safe_proof_age())) + Some(!proof_is_safely_fresh( + &proof, + now, + Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS), + )) } /// True iff every quote in the proof has a timestamp not older than @@ -1227,9 +1226,12 @@ mod tests { } #[test] - fn paid_proof_bytes_are_judged_by_their_oldest_quote() { + fn a_paid_proof_expires_with_its_oldest_quote() { let now = std::time::SystemTime::now(); - let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS); + // Inside the safety margin a cached proof is dropped, but storers + // still accept it, so it has not expired. + let close = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS); + let stale = now - Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS + 1); let bytes = |timestamps: &[std::time::SystemTime]| { serialize_single_node_proof(&PaymentProof { proof_of_payment: make_proof_with_timestamps(timestamps), @@ -1238,12 +1240,15 @@ mod tests { }) .unwrap() }; - assert_eq!(paid_proof_is_safely_fresh(&bytes(&[now]), now), Some(true)); assert_eq!( - paid_proof_is_safely_fresh(&bytes(&[now, stale]), now), + paid_proof_has_expired(&bytes(&[now, close]), now), Some(false) ); - assert_eq!(paid_proof_is_safely_fresh(b"not a proof", now), None); + assert_eq!( + paid_proof_has_expired(&bytes(&[now, stale]), now), + Some(true) + ); + assert_eq!(paid_proof_has_expired(b"not a proof", now), None); } #[test] diff --git a/ant-core/src/data/manifest/embed.rs b/ant-core/src/data/manifest/embed.rs index 42c716c1..43f67294 100644 --- a/ant-core/src/data/manifest/embed.rs +++ b/ant-core/src/data/manifest/embed.rs @@ -4,14 +4,15 @@ //! records; a reader must fetch those before it can fetch data. Embedding the //! root map instead removes that step, at the price of manifest bytes. The //! policy here embeds the root when its encoding, as a manifest writes it, -//! stays under [`MAX_EMBEDDED_ROOT_MAP_BYTES`] and the child map otherwise. +//! stays under [`MAX_EMBEDDED_ROOT_MAP_BYTES`](super::MAX_EMBEDDED_ROOT_MAP_BYTES) +//! and the child map otherwise; [`is_embeddable`] holds that policy. //! Either form has the same content address. use self_encryption::DataMap; use crate::data::Client; -use super::{embedded_len, ManifestError, MAX_EMBEDDED_ROOT_MAP_BYTES}; +use super::{is_embeddable, ManifestError}; /// The DataMap to embed for a file: its root map when that is small enough, /// otherwise the shrunk map given. A map that is already a root is returned @@ -24,7 +25,7 @@ pub async fn embeddable_data_map( return Ok(data_map.clone()); } let root = client.data_map_resolve_root(data_map).await?; - if embedded_len(&root)? <= MAX_EMBEDDED_ROOT_MAP_BYTES { + if is_embeddable(&root)? { Ok(root) } else { Ok(data_map.clone()) diff --git a/ant-core/src/data/manifest/mod.rs b/ant-core/src/data/manifest/mod.rs index cc7602f3..944134d8 100644 --- a/ant-core/src/data/manifest/mod.rs +++ b/ant-core/src/data/manifest/mod.rs @@ -96,6 +96,9 @@ pub const ADDRESS_LEN: usize = 32; /// files up to about 4 GB. Links carry the published (shrunk) form instead /// (see [`Manifest::link_form`]). pub const MAX_EMBEDDED_ROOT_MAP_BYTES: usize = 64 * 1024; +/// Most chunks a published (shrunk) DataMap lists: an upload shrinks a map +/// until it lists no more than this, as [`published_data_map`] does. +pub const MAX_PUBLISHED_MAP_CHUNKS: usize = 3; /// Deepest msgpack nesting the decoder tolerates. A manifest nests five /// levels; this leaves headroom for future optional fields. const MAX_DECODE_DEPTH: usize = 16; @@ -338,6 +341,19 @@ pub fn data_map_address(data_map: &DataMap) -> Result<[u8; ADDRESS_LEN], Manifes Ok(compute_address(&bytes)) } +/// Whether a writer embeds `data_map` as it is: a root map of at most +/// [`MAX_EMBEDDED_ROOT_MAP_BYTES`], or a shrunk map in the published form an +/// upload returns, at most [`MAX_PUBLISHED_MAP_CHUNKS`] chunks. A larger +/// root is embedded by its published map instead, and an intermediate +/// shrink level never is. +pub fn is_embeddable(data_map: &DataMap) -> Result { + if data_map.is_child() { + Ok(data_map.len() <= MAX_PUBLISHED_MAP_CHUNKS) + } else { + Ok(embedded_len(data_map)? <= MAX_EMBEDDED_ROOT_MAP_BYTES) + } +} + /// Bytes `data_map` occupies in an encoded manifest, as written. pub fn embedded_len(data_map: &DataMap) -> Result { let mut bytes = Vec::new(); diff --git a/ant-core/src/data/mod.rs b/ant-core/src/data/mod.rs index d60af180..ac460851 100644 --- a/ant-core/src/data/mod.rs +++ b/ant-core/src/data/mod.rs @@ -78,10 +78,10 @@ pub use manifest::{ }, }; pub use manifest::{ - data_map_address, embedded_len, file_link, is_link, manifest_link, manifest_link_bytes, - manifest_link_from_bytes, parse_link, published_data_map, ContentRef, Link, Manifest, - ManifestEntry, ManifestError, TorrentReference, MANIFEST_EXTENSION, - MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, + data_map_address, embedded_len, file_link, is_embeddable, is_link, manifest_link, + manifest_link_bytes, manifest_link_from_bytes, parse_link, published_data_map, ContentRef, + Link, Manifest, ManifestEntry, ManifestError, TorrentReference, MANIFEST_EXTENSION, + MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, MAX_PUBLISHED_MAP_CHUNKS, }; // Datamap file persistence helpers. Canonical path is diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index c8594175..9d234cbf 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -658,3 +658,73 @@ test("an onPrepared that never settles fails the write without paying", async () client.close(); } }); + +test("a broadcast the page cannot keep does not stop a paid state being stored", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + for (const [counter, onSubmission] of [ + ["0", () => new Promise(() => {})], + ["1", async () => { throw new Error("quota exceeded"); }], + ]) { + const pay = async (network, quotes, reportSubmission) => { + await reportSubmission({ transactionHash: transaction }); + return signer.pay(network, quotes); + }; + const write = counter === "0" ? "createPointer" : "updatePointer"; + const written = await client[write](seed, target(Number(counter) + 1), "chunk", paymentNetwork, pay, { onSubmission }); + assert.equal(written.pointer.counter, counter, "stored once paid"); + } + } finally { + client.close(); + } +}); + +test("a write waits for the page to keep a broadcast its wallet did not wait for", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + let submitted; + const pay = async (_network, _quotes, reportSubmission) => { + void reportSubmission({ transactionHash: transaction }); + throw new Error("wallet lost"); + }; + try { + await assert.rejects( + client.createPointer(seed, target(1), "chunk", paymentNetwork, pay, { + onSubmission: async value => { + await new Promise(resolve => setTimeout(resolve, 20)); + submitted = value; + }, + }), + error => { + assert.deepEqual(submitted, { transactionHash: transaction }, "kept before the write failed"); + assert.match(String(error), /wallet lost/); + assert.doesNotMatch(String(error), /payment was not submitted/, "the wallet was invoked"); + return true; + }, + ); + } finally { + client.close(); + } +}); + +test("every failure before the wallet says nothing was paid", async () => { + const rtc = mockWebRtc(Array.from({ length: 7 }, () => ({}))); + const client = new BrowserNetworkClient(rtc.endpoints); + const signer = wallet(); + try { + await client.createPointer(seed, target(1), "chunk", paymentNetwork, signer.pay); + for (const write of [ + () => client.createPointer(seed, target(2), "chunk", paymentNetwork, signer.pay), + () => client.createPointer(seed.subarray(1), target(2), "chunk", paymentNetwork, signer.pay), + () => client.updatePointer(seed, target(2), "file", paymentNetwork, signer.pay), + () => client.updatePointer(seed, target(2), "chunk", {}, signer.pay), + ]) { + await assert.rejects(write(), /payment was not submitted$/); + } + assert.equal(signer.calls.length, 1); + } finally { + client.close(); + } +}); diff --git a/docs/adr/ADR-0007-browser-api-completion.md b/docs/adr/ADR-0007-browser-api-completion.md index ffc3961b..4b0af822 100644 --- a/docs/adr/ADR-0007-browser-api-completion.md +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -26,17 +26,17 @@ Browser applications need shared-core manifest exports and pointer operations wi ## Decision -Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to native writer policy: a root DataMap is embedded only up to the native size limit, and a size hint must match a root DataMap's exact size. The entry count is checked before any entry is parsed. Refusals are a typed `ManifestInputError`. A link carries each embedded map's shrunk form, so an embedded root map must be one an upload shrank and stored the wrapper records for, as every upload does. +Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to the native writers' embedding policy, shared as `is_embeddable`: a root DataMap up to the native size limit, or the published map an upload returns (at most three chunks), never an intermediate shrink level. A size hint must match a root DataMap's exact size. The entry count is checked before any entry is parsed. Refusals are a typed `ManifestInputError`. `serde_wasm_bindgen` reads only a struct's declared fields, so unknown keys are refused against the field names read from each input struct's serde derive. A link carries each embedded map's shrunk form, so an embedded root map must be one an upload shrank and stored the wrapper records for, as every upload does. Pointer writes take an optional trailing options object, `{ onPrepared, onSubmission, onPaid }`; unknown options are refused before anything is paid. The bare `onPaid` function earlier releases took is no longer accepted. - `onPrepared` is awaited, for at most 30 seconds, with a versioned checkpoint string containing the signed record and verified plan (never the owner seed) before the wallet is invoked. Its byte fields are base64 and the plan MessagePack, so it is a fraction of the size of JSON number arrays. A failed or unsettled `onPrepared` prevents payment. -- `onSubmission` is handed each transaction the wallet reports broadcasting, through the `onSubmission` callback the wallet is given, and the wallet waits on it. The page therefore keeps the transaction before the wallet goes on, without every wallet having to. +- `onSubmission` is handed each transaction the wallet reports broadcasting, through the `onSubmission` callback the wallet is given. The write waits for every such call to settle, bounded by the same timeout, before it goes on or fails, whether or not the wallet waited. The page therefore keeps the transaction without every wallet having to. Its failure, like `onPaid`'s, never stops a paid state being stored. - `onPaid` is handed `{ record, proof }`; its failure must not prevent storing an already paid state. -The settlement-refusal check is repeated immediately before the wallet is invoked. Nothing is paid unless the wallet is invoked, and every failure before that ends "payment was not submitted"; any later failure may have paid and is recovered from what the callbacks were handed. +The settlement-refusal check is repeated immediately before the wallet is invoked. Nothing is paid unless the wallet is invoked. Write failures are typed by that boundary: every failure before it, from invalid input to an existing pointer, ends "payment was not submitted"; any later failure may have paid and is recovered from what the callbacks were handed. A write given neither `onPrepared` nor `onSubmission` keeps nothing to recover from, so a page lost between broadcast and receipt pays again on retry. -Export a recovery function that builds the paid proof from the original plan and the wallet's receipt or reported submission: `transactionHashes` keyed by quote hash, which must name the checkpoint's quote, or a single `transactionHash` paying its one quote, exactly as the write accepts. It does not sign, quote or submit another transaction, and the transaction must be confirmed on chain first. Storing a paid state whose quotes are older than storers accept reports that the proof expired, rather than a generic store failure. +Export a recovery function that builds the paid proof from the original plan and the wallet's receipt or reported submission: `transactionHashes` keyed by quote hash, which must name the checkpoint's quote, or a single `transactionHash` paying its one quote, exactly as the write accepts. It does not sign, quote or submit another transaction, and the transaction must be confirmed on chain first. A failure to store a paid state whose proof holds a quote older than storers accept, by the device's clock, says so alongside the storer's error. ## Consequences diff --git a/docs/adr/README.md b/docs/adr/README.md index 90aa8dfd..772b7464 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,3 +26,4 @@ See [`TOOLING.md`](./TOOLING.md) for `adrs`, `adr-kit`, and AI harness setup. - [ADR-0004: Direct browser read client](./ADR-0004-direct-browser-read-client.md) - [ADR-0005: Bootstrap multiaddresses and portable network defaults](./ADR-0005-bootstrap-multiaddresses.md) - [ADR-0006: File manifests and manifest links](./ADR-0006-file-manifests-and-links.md) +- [ADR-0007: Browser manifest creation and pointer payment recovery](./ADR-0007-browser-api-completion.md) (Proposed) From cb380050504129b5ae4e5006d79971e7cbc72ab3 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:16:33 +0200 Subject: [PATCH 6/9] test(browser): recover a lost pointer payment on real nodes A real-Chromium test against the pinned devnet nodes and Anvil. It pays once for a pointer, loses the page after the payment confirms and before the state is stored, and rebuilds the paid state with recoverPointerPayment from the onPrepared checkpoint and the transaction onSubmission kept. It then stores that state with storePaidPointer and reads it back, with no second payment. A write refused before the wallet is checked to say "payment was not submitted". The test also builds a manifest with createManifest and writes the .ant file and link to its output directory for the native CLI. The two pointer tests now share one local-chain wallet. Co-Authored-By: Claude Opus 5.5 --- ant-core/browser-tests/README.md | 8 +++ ant-core/browser-tests/harness.js | 83 ++++++++++++++++++++++----- ant-core/browser-tests/webrtc.spec.js | 25 ++++++++ 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/ant-core/browser-tests/README.md b/ant-core/browser-tests/README.md index 76acdae2..6917f981 100644 --- a/ant-core/browser-tests/README.md +++ b/ant-core/browser-tests/README.md @@ -73,6 +73,14 @@ injects a post-payment byte-loader failure, resumes on a new client without another payment, and downloads using only the public DataMap address. No RTC or node RPC mocks are installed. It also checks that a closed network client rejects reconnection. Temporary node data is removed on shutdown. + +The pointer recovery test pays once on the local chain, then loses the page +after the payment confirms and before the write stores the state. It rebuilds +the paid state from the `onPrepared` checkpoint and the transaction +`onSubmission` kept, stores it without paying again, and checks that a write +refused before the wallet says "payment was not submitted". It also builds a +manifest in the page and writes the `.ant` file and link to its test output +directory, for decoding with `ant manifest show`. The suite uses local funds only and verifies that the RPC belongs to the local Anvil devnet before invoking the wallet. diff --git a/ant-core/browser-tests/harness.js b/ant-core/browser-tests/harness.js index c8b8f677..debd16f5 100644 --- a/ant-core/browser-tests/harness.js +++ b/ant-core/browser-tests/harness.js @@ -1,4 +1,7 @@ -import init, { BrowserNetworkClient, encryptPublicFile, pointerAddress } from "../wasm-tests/pkg/ant_core.js"; +import init, { + BrowserNetworkClient, createFileLink, createManifest, decodeManifest, encryptPublicFile, parseManifestLink, + pointerAddress, recoverPointerPayment, +} from "../wasm-tests/pkg/ant_core.js"; import { Contract, JsonRpcProvider } from "ethers"; await init(); globalThis.runIntegration = async ({ endpoint, payment, rpcUrl }) => { @@ -50,17 +53,17 @@ globalThis.runIntegration = async ({ endpoint, payment, rpcUrl }) => { matches: downloaded.content.length === content.length && downloaded.content.every((value, index) => value === content[index]), hello: hello.type, records: result.records, replicas: result.file.replicas }; }; -// Pointers (ADR-0016) through real nodes: each state paid on the local chain, -// stored on the close group, and read back by a client that wrote nothing. -globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => { +// A wallet paying on the local chain, reporting each broadcast and counting +// the payments it is asked for. +const chainWallet = async (payment, rpcUrl) => { const provider = new JsonRpcProvider(rpcUrl); const signer = await provider.getSigner(0); const token = new Contract(payment.payment_token_address, ["function approve(address,uint256) returns (bool)"], signer); const vault = new Contract(payment.payment_vault_address, ["function payForQuotes((address rewardsAddress,uint256 amount,bytes32 quoteHash)[] payments)"], signer); - let payments = 0; - const pay = async (_, quotes, submitted) => { - payments++; + const wallet = { payments: 0, destroy: () => provider.destroy() }; + wallet.pay = async (_, quotes, submitted) => { + wallet.payments++; const totalAmount = quotes.reduce((sum, quote) => sum + BigInt(quote.amount), 0n); await (await token.approve(payment.payment_vault_address, totalAmount)).wait(); const transaction = await vault.payForQuotes(quotes.map(quote => ({ @@ -71,14 +74,21 @@ globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => { await transaction.wait(); return receipt; }; + return wallet; +}; +const chunk = n => n.toString(16).padStart(2, "0").repeat(32); +// A node admits 16 requests a second from one browser session (ADR-0015) +// and closes the session past that. A paid pointer write asks each node about +// eight things, and this wallet pays instantly on a local chain, so writes are +// spaced as a real wallet's confirmation would space them. +const paced = async write => { await new Promise(resolve => setTimeout(resolve, 1_500)); return write(); }; +// Pointers (ADR-0016) through real nodes: each state paid on the local chain, +// stored on the close group, and read back by a client that wrote nothing. +globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => { + const wallet = await chainWallet(payment, rpcUrl); + const pay = wallet.pay; const seed = crypto.getRandomValues(new Uint8Array(32)); const inner = crypto.getRandomValues(new Uint8Array(32)); - const chunk = n => n.toString(16).padStart(2, "0").repeat(32); - // A node admits 16 requests a second from one browser session (ADR-0015) - // and closes the session past that. A paid pointer write asks each node about - // eight things, and this wallet pays instantly on a local chain, so writes are - // spaced as a real wallet's confirmation would space them. - const paced = async write => { await new Promise(resolve => setTimeout(resolve, 1_500)); return write(); }; const writer = new BrowserNetworkClient([endpoint]); const created = await writer.createPointer(seed, chunk(1), "chunk", payment, pay); const updated = await paced(() => writer.updatePointer(seed, chunk(2), "chunk", payment, pay)); @@ -98,10 +108,53 @@ globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => { 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), + wallet.destroy(); + return { payments: wallet.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, transferredCounter: transferred.pointer.counter, finalityStatus: finality.status, finalityTransferredTo: finality.majority?.transferredTo }; }; +// A page lost once its wallet's payment confirmed, before the write stored the +// state: rebuilt from the checkpoint and the broadcast the page kept, stored +// without paying again. Then a manifest built in the page. +globalThis.runPointerRecoveryIntegration = async ({ endpoint, payment, rpcUrl }) => { + const wallet = await chainWallet(payment, rpcUrl); + const seed = crypto.getRandomValues(new Uint8Array(32)); + const address = pointerAddress(seed); + let checkpoint, submission, lostError; + const lost = async (...args) => { + await wallet.pay(...args); + throw new Error("page lost after the payment confirmed"); + }; + const writer = new BrowserNetworkClient([endpoint]); + try { + await writer.createPointer(seed, chunk(5), "chunk", payment, lost, { + onPrepared: value => { checkpoint = value; }, + onSubmission: value => { submission = value; }, + }); + } catch (error) { lostError = String(error); } + writer.close(); writer.free(); + const recovered = recoverPointerPayment(checkpoint, submission); + const storer = new BrowserNetworkClient([endpoint]); + const beforeRecovery = await storer.getPointer(address); + const stored = await paced(() => storer.storePaidPointer(recovered.record, recovered.proof, payment)); + let existsError; + try { await paced(() => storer.createPointer(seed, chunk(6), "chunk", payment, wallet.pay)); } + catch (error) { existsError = String(error); } + storer.close(); storer.free(); + const reader = new BrowserNetworkClient([endpoint]); + const read = await reader.getPointer(address); + reader.close(); reader.free(); + wallet.destroy(); + const file = encryptPublicFile(new TextEncoder().encode("a manifest entry")); + const created = createManifest({ + name: "recovered", torrent: { infoHashV1: "ab".repeat(20) }, + entries: [{ kind: "public", path: "docs/readme.txt", size: 16, address: createFileLink(file.address) }], + }); + const decoded = decodeManifest(created.bytes); + return { payments: wallet.payments, lostError, submission, beforeRecovery, stored, read, existsError, + expectedAddress: address, fileAddress: file.address, manifestEntry: decoded.entries[0], + manifestBytes: Array.from(created.bytes), link: created.link, + linkMatches: JSON.stringify(parseManifestLink(created.link).manifest) === JSON.stringify(decoded) }; +}; diff --git a/ant-core/browser-tests/webrtc.spec.js b/ant-core/browser-tests/webrtc.spec.js index fb3be058..26dd279f 100644 --- a/ant-core/browser-tests/webrtc.spec.js +++ b/ant-core/browser-tests/webrtc.spec.js @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; test("real Chromium authenticates, resumes one paid upload, and downloads by address", 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(); @@ -35,3 +36,27 @@ test("real Chromium creates, updates, reads, resolves and hands over pointers on expect(result.finalityTransferredTo).toBe(result.endAddress); expect(errors).toEqual([]); }); +test("real Chromium recovers a pointer payment its page lost, paying once, and builds a manifest", async ({ page, request }, testInfo) => { + const manifest = await (await request.get("http://127.0.0.1:35000/api/browser-manifest.json")).json(); + const info = await (await request.get("http://127.0.0.1:35000/api/info")).json(); + const errors = []; page.on("pageerror", error => errors.push(error.message)); + await page.goto("/"); + await page.waitForFunction(() => typeof globalThis.runPointerRecoveryIntegration === "function"); + const result = await page.evaluate(options => globalThis.runPointerRecoveryIntegration(options), { + endpoint: manifest.endpoints[0], payment: manifest.payment, rpcUrl: info.evm.rpc_url, + }); + expect(result.payments).toBe(1); + expect(result.lostError).toContain("page lost after the payment confirmed"); + expect(result.lostError).not.toContain("payment was not submitted"); + expect(result.submission.transactionHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(result.beforeRecovery).toBeNull(); + expect(result.stored.address).toBe(result.expectedAddress); expect(result.stored.counter).toBe("0"); + expect(result.read.counter).toBe("0"); expect(result.read.target).toBe("05".repeat(32)); + expect(result.existsError).toMatch(/already exists[\s\S]*payment was not submitted$/); + expect(result.manifestEntry).toMatchObject({ name: "docs/readme.txt", kind: "public", address: result.fileAddress, size: 16 }); + expect(result.linkMatches).toBe(true); + // Kept for decoding with the native CLI: `ant manifest show `. + await writeFile(testInfo.outputPath("browser-created.ant"), Buffer.from(result.manifestBytes)); + await writeFile(testInfo.outputPath("browser-created-link.txt"), result.link); + expect(errors).toEqual([]); +}); From 404fa3440c7529d2ab0e10ef200d960d619c0fd4 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:25:43 +0200 Subject: [PATCH 7/9] feat(browser): record a SHA-256 on created manifest entries main now records the creator's SHA-256 on manifest entries (#229, V2-1564), and decodeManifest already shows it. createManifest entries take an optional hex `sha256` as well, so a page that hashes a file can record it just as a native writer does. It is the creator's claim and must be 32 bytes of hex; the browser computes none itself. Co-Authored-By: Claude Opus 5.5 --- ant-core/src/browser/file_manifest.rs | 56 +++++++++++++++++++-- ant-core/wasm-tests/manifest-link.test.mjs | 5 +- docs/adr/ADR-0007-browser-api-completion.md | 2 +- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/ant-core/src/browser/file_manifest.rs b/ant-core/src/browser/file_manifest.rs index d393b595..83724358 100644 --- a/ant-core/src/browser/file_manifest.rs +++ b/ant-core/src/browser/file_manifest.rs @@ -8,7 +8,7 @@ use super::BrowserManifestView; use crate::data::manifest::{ self, embedded_len, is_embeddable, ContentRef, Link, Manifest, ManifestEntry, ManifestError, TorrentReference, MANIFEST_LINK_RECOMMENDED_MAX_BYTES, MAX_EMBEDDED_ROOT_MAP_BYTES, - MAX_MANIFEST_BYTES, MAX_MANIFEST_ENTRIES, MAX_PUBLISHED_MAP_CHUNKS, + MAX_MANIFEST_BYTES, MAX_MANIFEST_ENTRIES, MAX_PUBLISHED_MAP_CHUNKS, SHA256_LEN, }; use self_encryption::DataMap; use serde::de::{self, Visitor}; @@ -71,6 +71,9 @@ pub enum ManifestInputError { /// A size hint JavaScript cannot represent exactly. #[error("size {0} exceeds JavaScript's safe integer range")] UnsafeSize(u64), + /// A SHA-256 that is not hex of the right length. + #[error("sha256 must be {} bytes of hex", SHA256_LEN)] + InvalidSha256, /// A size hint that contradicts the size a root DataMap fixes. #[error("size {hint} does not match the embedded DataMap's {known} bytes")] SizeMismatch { @@ -158,6 +161,8 @@ pub enum EntryInput { path: Option, /// Plaintext size hint. size: Option, + /// Hex SHA-256 of the plaintext, recorded as the creator's claim. + sha256: Option, /// The file's public address. address: String, }, @@ -173,6 +178,8 @@ pub enum EntryInput { path: Option, /// Plaintext size; must match a root DataMap's exact size. size: Option, + /// Hex SHA-256 of the plaintext, recorded as the creator's claim. + sha256: Option, /// The serialized DataMap. #[serde(rename = "dataMap", with = "serde_bytes")] data_map: Vec, @@ -252,6 +259,7 @@ impl EntryInput { Self::Public { path, size, + sha256, address, } => { let Link::File(address) = manifest::parse_link(&address)? else { @@ -260,13 +268,14 @@ impl EntryInput { ManifestEntry { path, size, - sha256: None, + sha256: sha256.as_deref().map(parse_sha256).transpose()?, source: ContentRef::Public { address }, } } Self::Embedded { path, size, + sha256, data_map, } => { *embedded_bytes = embedded_bytes @@ -278,7 +287,7 @@ impl EntryInput { ManifestEntry { path, size, - sha256: None, + sha256: sha256.as_deref().map(parse_sha256).transpose()?, source: ContentRef::Embedded { data_map: embeddable_data_map(&data_map)?, }, @@ -312,6 +321,14 @@ fn torrent_hash( .transpose() } +/// Parse a hex SHA-256. +fn parse_sha256(hex: &str) -> Result<[u8; SHA256_LEN], ManifestInputError> { + hex::decode(hex) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or(ManifestInputError::InvalidSha256) +} + /// Decode a canonical DataMap and hold it to what a native writer embeds: a /// root map only while it is small, otherwise the published map an upload /// returns. @@ -372,6 +389,7 @@ mod tests { entries: vec![EntryInput::Embedded { path: Some("file".into()), size, + sha256: None, data_map, }], } @@ -386,11 +404,13 @@ mod tests { EntryInput::Public { path: Some("z.txt".into()), size: Some(20), + sha256: Some("ef".repeat(SHA256_LEN)), address: "ab".repeat(32), }, EntryInput::Public { path: Some("a.txt".into()), size: None, + sha256: None, address: "cd".repeat(32), }, ], @@ -399,6 +419,11 @@ mod tests { .expect("manifest"); let decoded = Manifest::decode(&created.bytes).expect("native decode"); assert_eq!(decoded.entries[0].path.as_deref(), Some("a.txt")); + assert_eq!(decoded.entries[1].sha256, Some([0xef; SHA256_LEN])); + assert_eq!( + created.manifest.entries[1].sha256.as_deref(), + Some("ef".repeat(SHA256_LEN).as_str()) + ); assert_eq!( manifest::parse_link(&created.link).expect("link"), manifest::Link::Manifest(decoded) @@ -415,6 +440,7 @@ mod tests { entries: vec![EntryInput::Public { path: Some(path.into()), size: Some(size), + sha256: None, address: "ab".repeat(32), }] } @@ -423,6 +449,28 @@ mod tests { } } + #[test] + fn a_sha256_must_be_hex_of_its_length() { + for sha256 in ["zz".repeat(SHA256_LEN), "ab".repeat(SHA256_LEN - 1)] { + let error = ManifestInput { + name: None, + torrent: None, + entries: vec![EntryInput::Public { + path: None, + size: None, + sha256: Some(sha256), + address: "ab".repeat(32), + }], + } + .create() + .unwrap_err(); + assert!( + matches!(error, ManifestInputError::InvalidSha256), + "{error}" + ); + } + } + #[test] fn struct_fields_are_the_names_javascript_uses() { assert_eq!( @@ -444,6 +492,7 @@ mod tests { entries: vec![EntryInput::Embedded { path: None, size: None, + sha256: None, data_map: data_map_bytes(MAX_PUBLISHED_MAP_CHUNKS * 4, None), }], }; @@ -462,6 +511,7 @@ mod tests { .map(|_| EntryInput::Public { path: None, size: None, + sha256: None, address: String::new(), }) .collect(); diff --git a/ant-core/wasm-tests/manifest-link.test.mjs b/ant-core/wasm-tests/manifest-link.test.mjs index 46e07d08..0a871055 100644 --- a/ant-core/wasm-tests/manifest-link.test.mjs +++ b/ant-core/wasm-tests/manifest-link.test.mjs @@ -60,7 +60,7 @@ test("createManifest takes JavaScript input and writes what the decoders read", name: "files", torrent: { infoHashV1: "ab".repeat(20) }, entries: [ - { kind: "public", path: "z.txt", size: 20, address: `ant://${"cd".repeat(32)}` }, + { kind: "public", path: "z.txt", size: 20, sha256: "ef".repeat(32), address: `ant://${"cd".repeat(32)}` }, { kind: "embedded", path: "a.bin", dataMap: embedded.dataMap }, ], }); @@ -74,6 +74,8 @@ test("createManifest takes JavaScript input and writes what the decoders read", ); assert.equal(created.manifest.entries[0].address, ADDRESS); assert.equal(created.manifest.entries[1].size, 20); + assert.equal(created.manifest.entries[1].sha256, "ef".repeat(32)); + assert.equal(created.manifest.entries[0].sha256, undefined); assert.equal(created.manifest.torrent.infoHashV1, "ab".repeat(20)); assert.deepEqual(decodeManifest(created.bytes), created.manifest); @@ -88,6 +90,7 @@ test("createManifest refuses input it does not know", () => { [{ entries: [{ kind: "Public", address }] }, /unknown variant `Public`/], [{ entries: [{ kind: "embedded", dataMap: [1, 2, 3] }] }, /invalid DataMap/], [{ entries: [{ kind: "public", address: LINK }] }, /public entry requires a file address/], + [{ entries: [{ kind: "public", address, sha256: "ab" }] }, /sha256 must be 32 bytes of hex/], [{ torrent: { infoHashV1: "ab".repeat(20), infoHashV3: "" }, entries: [] }, /unknown field `infoHashV3`/], [{ entries: [{ kind: "public", address, size: 2 ** 53 }] }, /expected u64/], [{ entries: [{ kind: "public", address, size: 2n ** 53n }] }, /safe integer/], diff --git a/docs/adr/ADR-0007-browser-api-completion.md b/docs/adr/ADR-0007-browser-api-completion.md index 4b0af822..0704f74c 100644 --- a/docs/adr/ADR-0007-browser-api-completion.md +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -26,7 +26,7 @@ Browser applications need shared-core manifest exports and pointer operations wi ## Decision -Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to the native writers' embedding policy, shared as `is_embeddable`: a root DataMap up to the native size limit, or the published map an upload returns (at most three chunks), never an intermediate shrink level. A size hint must match a root DataMap's exact size. The entry count is checked before any entry is parsed. Refusals are a typed `ManifestInputError`. `serde_wasm_bindgen` reads only a struct's declared fields, so unknown keys are refused against the field names read from each input struct's serde derive. A link carries each embedded map's shrunk form, so an embedded root map must be one an upload shrank and stored the wrapper records for, as every upload does. +Export manifest construction through the existing shared format encoder, which refuses anything its decoder would refuse. Browser input is held to the native writers' embedding policy, shared as `is_embeddable`: a root DataMap up to the native size limit, or the published map an upload returns (at most three chunks), never an intermediate shrink level. A size hint must match a root DataMap's exact size. An entry may carry a hex `sha256`, recorded as the creator's claim as a native writer records it (ADR-0006); the browser computes none itself. The entry count is checked before any entry is parsed. Refusals are a typed `ManifestInputError`. `serde_wasm_bindgen` reads only a struct's declared fields, so unknown keys are refused against the field names read from each input struct's serde derive. A link carries each embedded map's shrunk form, so an embedded root map must be one an upload shrank and stored the wrapper records for, as every upload does. Pointer writes take an optional trailing options object, `{ onPrepared, onSubmission, onPaid }`; unknown options are refused before anything is paid. The bare `onPaid` function earlier releases took is no longer accepted. From 93f3bd22e1440f74063c71dccf10fced64bafe15 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:20:28 +0200 Subject: [PATCH 8/9] fix(browser): check unknown keys on any JavaScript object reject_unknown_keys took its object with dyn_ref, which tests `instanceof Object`. An object without Object.prototype, such as one from Object.create(null), or an object from another frame fails that test, so its keys went unchecked. For pointer write options that meant a misspelt `onPrepared` was ignored and the write paid with no checkpoint kept; createManifest likewise accepted unknown top-level and torrent keys. Any value JavaScript reports as an object is now checked. Tests cover both kinds of object for createManifest and the pointer options. Co-Authored-By: Claude Opus 5.5 --- ant-core/src/browser/wasm_transport.rs | 10 +++++++--- ant-core/wasm-tests/manifest-link.test.mjs | 9 +++++++++ ant-core/wasm-tests/pointer.test.mjs | 4 ++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index d2b77c23..67f6eb91 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -3457,11 +3457,15 @@ fn pass_ice_gathering_turn(connection: RtcPeerConnection, turn: Option Result<(), String> { - let Some(object) = value.dyn_ref::() else { + if !value.is_object() { return Ok(()); - }; - for key in js_sys::Object::keys(object).iter() { + } + for key in js_sys::Object::keys(value.unchecked_ref::()).iter() { let key = key.as_string().unwrap_or_default(); if !known.contains(&key.as_str()) { return Err(format!("unknown field `{key}`, expected one of {known:?}")); diff --git a/ant-core/wasm-tests/manifest-link.test.mjs b/ant-core/wasm-tests/manifest-link.test.mjs index 0a871055..7b4b540a 100644 --- a/ant-core/wasm-tests/manifest-link.test.mjs +++ b/ant-core/wasm-tests/manifest-link.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import vm from "node:vm"; import { createFileLink, createManifest, @@ -97,6 +98,14 @@ test("createManifest refuses input it does not know", () => { ]) { assert.throws(() => createManifest(input), error); } + // Whatever the object's prototype or realm. + for (const input of [ + Object.assign(Object.create(null), { entries: [], extra: true }), + { entries: [], torrent: Object.assign(Object.create(null), { infoHashV1: "ab".repeat(20), extra: true }) }, + vm.runInNewContext("({ entries: [], extra: true })"), + ]) { + assert.throws(() => createManifest(input), /unknown field `extra`/); + } }); test("createFileLink formats a public address in the shared link grammar", () => { diff --git a/ant-core/wasm-tests/pointer.test.mjs b/ant-core/wasm-tests/pointer.test.mjs index 9d234cbf..1ffc28a6 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -4,6 +4,7 @@ import { BrowserNetworkClient } from "./client-fixture.mjs"; import assert from "node:assert/strict"; import test from "node:test"; +import vm from "node:vm"; import { pointerAddress, recoverPointerPayment } from "./pkg/ant_core.js"; import { mockWebRtc, paymentNetwork } from "./mock-webrtc.mjs"; @@ -523,6 +524,9 @@ test("pointer write options are checked before anything is paid", async () => { try { for (const [options, error] of [ [{ onPrepare: () => {} }, /unknown field `onPrepare`/], + // Checked whatever the object's prototype or realm. + [Object.assign(Object.create(null), { onPrepare: () => {} }), /unknown field `onPrepare`/], + [vm.runInNewContext("({ onPrepare: () => {} })"), /unknown field `onPrepare`/], [{ onPaid: 5 }, /onPaid must be a function/], [{ onSubmission: "x" }, /onSubmission must be a function/], [7, /must be an object/], From f96c2fb5bb7574e126685e460639c98b92fdcc05 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:20:28 +0200 Subject: [PATCH 9/9] docs(browser): keep the paid-suite paragraph whole and name checkpoint contents The pointer recovery paragraph in the browser-tests README had split the paid upload paragraph from its last sentence. ADR-0007 now says what a checkpoint holds, a signed record and a quoted payment plan, and that the transaction evidence is in the kept submissions and receipts. Co-Authored-By: Claude Opus 5.5 --- ant-core/browser-tests/README.md | 4 ++-- docs/adr/ADR-0007-browser-api-completion.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ant-core/browser-tests/README.md b/ant-core/browser-tests/README.md index 6917f981..518c2338 100644 --- a/ant-core/browser-tests/README.md +++ b/ant-core/browser-tests/README.md @@ -73,6 +73,8 @@ injects a post-payment byte-loader failure, resumes on a new client without another payment, and downloads using only the public DataMap address. No RTC or node RPC mocks are installed. It also checks that a closed network client rejects reconnection. Temporary node data is removed on shutdown. +The suite uses local funds only and verifies that the RPC belongs to the local +Anvil devnet before invoking the wallet. The pointer recovery test pays once on the local chain, then loses the page after the payment confirms and before the write stores the state. It rebuilds @@ -81,8 +83,6 @@ the paid state from the `onPrepared` checkpoint and the transaction refused before the wallet says "payment was not submitted". It also builds a manifest in the page and writes the `.ant` file and link to its test output directory, for decoding with `ant manifest show`. -The suite uses local funds only and verifies that the RPC belongs to the local -Anvil devnet before invoking the wallet. ## Large-file disk streaming diff --git a/docs/adr/ADR-0007-browser-api-completion.md b/docs/adr/ADR-0007-browser-api-completion.md index 0704f74c..a460fd39 100644 --- a/docs/adr/ADR-0007-browser-api-completion.md +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -48,7 +48,7 @@ Export a recovery function that builds the paid proof from the original plan and ### Negative / Trade-offs - Applications must supply durable persistence and an observation-only recovery provider for unresolved payments. A crash before submission may require explicit verification that nothing was broadcast. -- Checkpoints contain signed records and transaction evidence and must be treated as sensitive application data. +- Checkpoints contain signed records and quoted payment plans, and kept submissions and receipts contain transaction evidence; all must be treated as sensitive application data. ### Neutral / Operational