diff --git a/ant-core/browser-tests/README.md b/ant-core/browser-tests/README.md index 76acdae2..518c2338 100644 --- a/ant-core/browser-tests/README.md +++ b/ant-core/browser-tests/README.md @@ -76,6 +76,14 @@ 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 +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`. + ## Large-file disk streaming `large-file.mjs` is an opt-in check, not run in CI, that a file larger than 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([]); +}); diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 2013625d..66478b3d 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))] @@ -271,12 +272,14 @@ fn browser_chunk_info(info: &ChunkInfo) -> BrowserChunkInfo { #[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] mod wasm { + 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::{ 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, @@ -666,6 +669,37 @@ 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 { + 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, struct_fields::()) + .map_err(|e| JsValue::from_str(&e))?; + } + let input: 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.to_string()))?; + 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(|error| JsValue::from_str(&error.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 { @@ -1080,7 +1114,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. @@ -1093,7 +1127,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. @@ -1105,7 +1139,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. @@ -1120,20 +1154,46 @@ 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>, } 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) @@ -1141,14 +1201,17 @@ impl BrowserManifestView { ), crate::data::manifest::ContentRef::Public { .. } => None, }; + // An embedded entry's address comes from self-encrypting its + // DataMap; derive it once for both the name and the address. + let address = addressed.source.content_address()?; entries.push(BrowserManifestEntryView { - name: entry.effective_name()?, + 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(), - address: hex::encode(entry.source.content_address()?), + kind: entry.source.kind().to_owned(), + address: hex::encode(address), data_map, }); } diff --git a/ant-core/src/browser/file_manifest.rs b/ant-core/src/browser/file_manifest.rs new file mode 100644 index 00000000..83724358 --- /dev/null +++ b/ant-core/src/browser/file_manifest.rs @@ -0,0 +1,621 @@ +// 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::manifest::MAX_SAFE_JS_INTEGER; +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, SHA256_LEN, +}; +use self_encryption::DataMap; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, 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"; + +/// 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 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), + /// 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 { + /// 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, + }, +} + +/// 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)] +#[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, + /// Hex SHA-256 of the plaintext, recorded as the creator's claim. + sha256: 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. + /// + /// 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, + /// 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, + }, +} + +/// 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 { + // 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 + .entries + .into_iter() + .map(|entry| entry.into_entry(&mut embedded_bytes)) + .collect::>()?; + let mut manifest = Manifest { + name: self.name, + torrent, + entries, + }; + // 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 = manifest.link_form()?; + let link_bytes = link.encode_canonical()?; + Ok(CreatedManifest { + 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, + }) + } +} + +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, + sha256, + address, + } => { + let Link::File(address) = manifest::parse_link(&address)? else { + return Err(ManifestInputError::NotAFileAddress); + }; + ManifestEntry { + path, + size, + sha256: sha256.as_deref().map(parse_sha256).transpose()?, + source: ContentRef::Public { address }, + } + } + Self::Embedded { + path, + size, + sha256, + data_map, + } => { + *embedded_bytes = embedded_bytes + .checked_add(data_map.len()) + .filter(|total| *total <= MAX_MANIFEST_BYTES) + .ok_or(ManifestInputError::EmbeddedTooLarge { + max: MAX_MANIFEST_BYTES, + })?; + ManifestEntry { + path, + size, + sha256: sha256.as_deref().map(parse_sha256).transpose()?, + source: ContentRef::Embedded { + data_map: embeddable_data_map(&data_map)?, + }, + } + } + }; + 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 let Some((known, hint)) = entry.known_size().zip(entry.size) { + if known != hint { + return Err(ManifestInputError::SizeMismatch { hint, known }); + } + } + 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: &'static str, + pick: impl FnOnce(TorrentReference) -> Option, +) -> Result, ManifestInputError> { + hex.map(|hex| { + let parsed = TorrentReference::parse_hex(&hex)?; + pick(parsed).ok_or(ManifestInputError::TorrentHashVersion { field }) + }) + .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. +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); + } + if is_embeddable(&data_map)? { + return 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)] +#[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, + sha256: None, + data_map, + }], + } + } + + #[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), + 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), + }, + ], + } + .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!(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) + ); + 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), + sha256: None, + address: "ab".repeat(32), + }] + } + .create() + .is_err()); + } + } + + #[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!( + 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, + sha256: 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] + 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, + sha256: 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() + .to_string(); + 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!( + 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)) + .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_or_published_data_map_is_embedded() { + let error = embedded(None, data_map_bytes(OVERSIZED_CHUNKS, None)) + .create() + .unwrap_err(); + assert!( + matches!(error, ManifestInputError::RootDataMapTooLarge { .. }), + "{error}" + ); + + // 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("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] + 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.rs b/ant-core/src/browser/wasm_transport.rs index cb859c82..67f6eb91 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -3454,6 +3454,26 @@ fn pass_ice_gathering_turn(connection: RtcPeerConnection, turn: Option Result<(), String> { + if !value.is_object() { + return Ok(()); + } + 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:?}")); + } + } + 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 3a0180f4..d76436fc 100644 --- a/ant-core/src/browser/wasm_transport/pointer.rs +++ b/ant-core/src/browser/wasm_transport/pointer.rs @@ -11,27 +11,48 @@ //! 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::client::batch::{paid_proof_has_expired, ChunkPaymentPlan}; use crate::data::{ ml_dsa_65, pointer_address, FinalState, FinalityStatus, MlDsaPublicKey, MlDsaSecretKey, Pointer, PointerFinality, PointerTarget, PointerTargetKind, }; use ant_protocol::evm::{QuoteHash, TxHash}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; /// Length of an owner key seed. const OWNER_SEED_LEN: usize = 32; -/// 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. +/// 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; + +/// 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 ON_PAID_TIMEOUT: Duration = Duration::from_secs(30); +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 ON_PAID_TIMEOUT: Duration = Duration::from_secs(1); +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(1); + +/// 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"; /// A pointer as JavaScript sees it. #[derive(Debug, Serialize)] @@ -242,10 +263,30 @@ 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. + /// `options` is `{ onPrepared, onSubmission, onPaid }`, each optional. + /// An unknown option is refused before anything is paid. + /// + /// - `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. + /// + /// 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, @@ -254,27 +295,16 @@ impl BrowserNetworkClient { kind: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - on_paid: Option, + 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, - on_paid.as_ref(), - ) + 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 /// counter the network serves (or create it), and pay for the new state. - /// `on_paid` is as for [`Self::create_pointer`]. + /// `options` is as for [`Self::create_pointer`]. #[wasm_bindgen(js_name = updatePointer)] pub async fn update_pointer( &self, @@ -283,27 +313,16 @@ impl BrowserNetworkClient { kind: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - on_paid: Option, + 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, - on_paid.as_ref(), - ) + 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 - /// `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. @@ -317,22 +336,11 @@ impl BrowserNetworkClient { recipient: &str, payment_network: JsValue, pay_for_quotes: js_sys::Function, - on_paid: Option, + 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, - on_paid.as_ref(), - ) + 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, @@ -350,7 +358,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 + /// failure with an older proof, by this device's clock, says so. #[wasm_bindgen(js_name = storePaidPointer)] pub async fn store_paid_pointer( &self, @@ -362,59 +374,181 @@ 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)) } } -/// 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. +#[derive(Default)] +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, 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()); + }; + // 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()); + } + reject_unknown_keys( + &value, + &[ON_PREPARED_OPTION, ON_SUBMISSION_OPTION, ON_PAID_OPTION], + )?; + Ok(Self { + 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: &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() { + return Ok(None); + } + value + .dyn_into() + .map(Some) + .map_err(|_| format!("{name} must be a function")) +} + +/// A signed, quoted and checkpointed pointer state, ready for the wallet. +struct PreparedWrite { + client: crate::data::Client, + record: Pointer, + plan: ChunkPaymentPlan, + payment_network: BrowserPaymentNetwork, + options: PointerWriteOptions, } impl BrowserNetworkClient { - #[allow(clippy::too_many_arguments)] // Mirrors the JS arguments. + /// Prepare `write`, have the wallet pay it, then store it. async fn write_pointer( &self, - write: PointerWrite, + write: PointerWrite<'_>, owner_seed: &[u8], - target: &str, - kind: &str, - payment_network: &BrowserPaymentNetwork, + payment_network: JsValue, pay_for_quotes: &js_sys::Function, - on_paid: Option<&js_sys::Function>, - ) -> Result { + options: Option, + ) -> 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, on_paid) + 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 @@ -429,47 +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, - on_paid: Option<&js_sys::Function>, - ) -> Result { - let plan = client - .prepare_pointer_payment(record) - .await - .map_err(|error| error.to_string())?; + ) -> Result { + let PreparedWrite { + client, + record, + plan, + payment_network, + options, + } = prepared; let (transactions, submission) = self - .pay_plan(&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) - .map_err(|error| error.to_string())?; - if let Some(on_paid) = on_paid { + 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 _ = - crate::runtime::timeout(ON_PAID_TIMEOUT, hand_over_paid(on_paid, record, &proof)) - .await; + let hand_over = async { + 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, }) @@ -478,28 +619,65 @@ 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 lost between the wallet's broadcast and its receipt pays again on - /// retry. + /// is recovered rather than repeated. A pointer write keeps no journal of + /// 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, - ) -> 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) - }) + on_submission: Option<&js_sys::Function>, + ) -> 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(PointerWriteError::NotSubmitted( + 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()); + return Err(PointerWriteError::NotSubmitted( + "browser client is closed".into(), + )); } - let returned = wallet + let paid = wallet .call4( &JsValue::NULL, &network, @@ -507,32 +685,172 @@ 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)) } } -/// 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> { +/// 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(()) +} + +/// 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()) +} + +/// 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_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(), + } +} + +/// What `onPrepared` is handed, as JSON: the signed record and the plan its +/// 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 { + version: u64, + 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" + )); + } + 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 +/// newer release is reported as unsupported rather than as malformed. +#[derive(Deserialize)] +struct CheckpointVersion { + version: u64, +} + +/// 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(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, or +/// the submission `onSubmission` was handed: `{ transactionHash }` or +/// `{ transactionHashes: { : } }`. +/// +/// 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) + .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> { + 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(&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/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/client/batch.rs b/ant-core/src/data/client/batch.rs index 3928eb1d..bbebc0da 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}; @@ -923,6 +922,18 @@ fn prune_locally_expired_proofs( kept } +/// 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, + Duration::from_secs(CACHED_PROOF_MAX_AGE_SECS), + )) +} + /// True iff every quote in the proof has a timestamp not older than /// `now - max_safe_age`. /// @@ -1214,6 +1225,32 @@ mod tests { ProofOfPayment { peer_quotes } } + #[test] + fn a_paid_proof_expires_with_its_oldest_quote() { + let now = std::time::SystemTime::now(); + // 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), + tx_hashes: Vec::new(), + commitment_sidecars: Vec::new(), + }) + .unwrap() + }; + assert_eq!( + paid_proof_has_expired(&bytes(&[now, close]), now), + Some(false) + ); + 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] #[cfg(any(feature = "native", test))] fn proof_is_safely_fresh_accepts_recent_quote() { 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 f7d5777f..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(); @@ -377,10 +393,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 +412,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 { @@ -415,6 +443,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 +489,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 @@ -479,8 +513,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); @@ -963,6 +1003,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/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/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/manifest-link.test.mjs b/ant-core/wasm-tests/manifest-link.test.mjs index 6b804ff6..7b4b540a 100644 --- a/ant-core/wasm-tests/manifest-link.test.mjs +++ b/ant-core/wasm-tests/manifest-link.test.mjs @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { decodeManifest, parseManifestLink } from "./pkg/ant_core.js"; +import vm from "node:vm"; +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 +54,63 @@ 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, sha256: "ef".repeat(32), 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.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); + 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/], + [{ 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/], + ]) { + 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", () => { + 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 0a0de407..1ffc28a6 100644 --- a/ant-core/wasm-tests/pointer.test.mjs +++ b/ant-core/wasm-tests/pointer.test.mjs @@ -4,7 +4,8 @@ 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 vm from "node:vm"; +import { pointerAddress, recoverPointerPayment } from "./pkg/ant_core.js"; import { mockWebRtc, paymentNetwork } from "./mock-webrtc.mjs"; const seed = new Uint8Array(32).fill(7); @@ -197,7 +198,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 +209,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 +252,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 +273,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 +307,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 +371,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 +399,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; @@ -478,3 +479,256 @@ test("an open pointer reports its counter, and a transfer nowhere is refused bef client.close(); } }); + +const transaction = `0x${"ab".repeat(32)}`; +/** 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 () => { + 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 => { + 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"); + }, + }); + 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"); }, + }), + /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"); + } 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 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/], + // 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), + 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.*; payment was not submitted/, + ); + 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"); + + 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( + 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(); + } +}); + +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(); + } +}); + +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 new file mode 100644 index 00000000..a460fd39 --- /dev/null +++ b/docs/adr/ADR-0007-browser-api-completion.md @@ -0,0 +1,64 @@ +# 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, 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. + +- `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. 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. 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. 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 + +### 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 quoted payment plans, and kept submissions and receipts contain transaction evidence; all must be treated as sensitive application data. + +### Neutral / Operational + +- 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 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 + +This ADR remains Proposed until human engineering review. 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)