From 8af33c80c9a4a783f026cb983e2f97f5c2f9bfa4 Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Mon, 3 Aug 2026 17:42:03 +0200
Subject: [PATCH 01/31] feat: add direct browser public file client
---
.gitignore | 2 +
README.md | 26 +-
.../ADR-0003-direct-browser-read-client.md | 115 +++
web/README.md | 94 ++
web/index.html | 113 +++
web/package-lock.json | 815 ++++++++++++++++++
web/package.json | 19 +
web/src/file.js | 174 ++++
web/src/file.test.js | 106 +++
web/src/main.js | 247 ++++++
web/src/manifest.js | 117 +++
web/src/manifest.test.js | 65 ++
web/src/protocol.js | 426 +++++++++
web/src/protocol.test.js | 223 +++++
web/src/style.css | 219 +++++
15 files changed, 2760 insertions(+), 1 deletion(-)
create mode 100644 docs/adr/ADR-0003-direct-browser-read-client.md
create mode 100644 web/README.md
create mode 100644 web/index.html
create mode 100644 web/package-lock.json
create mode 100644 web/package.json
create mode 100644 web/src/file.js
create mode 100644 web/src/file.test.js
create mode 100644 web/src/main.js
create mode 100644 web/src/manifest.js
create mode 100644 web/src/manifest.test.js
create mode 100644 web/src/protocol.js
create mode 100644 web/src/protocol.test.js
create mode 100644 web/src/style.css
diff --git a/.gitignore b/.gitignore
index fc94dea5..b71f648b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
/target
+/web/node_modules/
+/web/dist/
.cargo/config.toml
.claude/plans/
.claude/scheduled_tasks.lock
diff --git a/README.md b/README.md
index 0f02eeb9..4725a7fe 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,37 @@ A unified CLI and Rust library for storing data on the Autonomi decentralized ne
## Overview
-This project provides two crates:
+This project provides two Rust crates and a browser client:
- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Designed to be consumed by any frontend (CLI, GUI, AI agents, REST clients).
- **ant-cli** — A thin CLI binary (`ant`) built on `ant-core`.
+- **web** — A direct WebTransport client and test site. It performs browser-side
+ closest-node lookup, reconstructs complete public self-encrypted files, and
+ saves them without a data gateway.
Data on Autonomi is **content-addressed**. Files are split into encrypted chunks (via [self-encryption](https://en.wikipedia.org/wiki/Convergent_encryption)), each stored at an XOR address derived from its content. A `DataMap` tracks which chunks belong to a file. Payments for storage are made on an EVM-compatible blockchain (Arbitrum).
+### Direct browser testnet client
+
+The browser client works with an ADR-0009-enabled `ant-node` checkout. Start
+the browser-enabled node devnet, then run the site:
+
+```bash
+# In ant-node-web-support
+cargo run --features webtransport-poc --bin ant-devnet -- \
+ --preset minimal --base-port 23000 \
+ --webtransport --webtransport-base-port 24000 \
+ --serve-port 25000 --enable-logging
+
+# In ant-client-web-support/web
+npm ci
+npm run dev
+```
+
+Open `http://127.0.0.1:5173`; the default public test file and direct node
+endpoints are loaded from the local browser manifest. See [web/README.md](web/README.md)
+for the protocol flow and LAN configuration.
+
## Installation
### Linux / macOS
diff --git a/docs/adr/ADR-0003-direct-browser-read-client.md b/docs/adr/ADR-0003-direct-browser-read-client.md
new file mode 100644
index 00000000..b44c7a4d
--- /dev/null
+++ b/docs/adr/ADR-0003-direct-browser-read-client.md
@@ -0,0 +1,115 @@
+# ADR-0003: Direct browser read client over WebTransport
+
+- **Status:** Proposed
+- **Date:** 2026-08-03
+- **Decision owners:**
+- **Reviewers:**
+- **Supersedes:** none
+- **Superseded by:** none
+- **Related:** ant-node ADR-0009; W3C WebTransport
+
+## Context
+
+The Autonomi web client must perform closest-node lookup and immutable-data
+download itself. Sending those operations through an HTTP application gateway
+would make the gateway an availability, privacy, and bandwidth chokepoint.
+Browsers cannot use the native Saorsa QUIC protocol, but they can establish
+WebTransport sessions with browser-compatible node listeners.
+
+This repository owns the client and UI side of that split. Nodes own transport
+termination, local DHT answers, storage reads, endpoint records, and testnet
+bootstrap-manifest production under ant-node ADR-0009.
+
+## Decision Drivers
+
+- File bytes must travel directly from a storage node to the browser.
+- The browser must own iterative XOR lookup rather than ask a gateway to do it.
+- Self-signed node certificates must be authenticated through explicit hashes.
+- Downloaded immutable content must be verified before it is exposed to users.
+- Local testnets need a reproducible bootstrap and default-file workflow.
+
+## Considered Options
+
+1. **Use the daemon REST API as a data gateway.** Rejected for lookup and file
+ bytes because it would not exercise a full browser client.
+2. **Compile the complete native Rust client to WebAssembly.** Deferred because
+ the native transport, EVM, and filesystem dependency graph is not currently
+ browser-compatible.
+3. **Implement a narrow JavaScript WebTransport read client (chosen).** It maps
+ directly to the versioned browser node protocol and keeps the application
+ boundary small enough to audit.
+
+## Decision
+
+The `web/` package will implement the direct browser read client:
+
+- load a versioned browser bootstrap manifest containing node URLs,
+ certificate hashes, peer IDs, and published immutable-file metadata;
+- authenticate every seed and discovered endpoint by certificate hash and
+ verify its `HELLO` peer ID;
+- perform iterative `FIND_NODE` queries using 256-bit XOR ordering, `K = 20`,
+ and `ALPHA = 3`;
+- query closest direct endpoints with `GET_CHUNK`, retrying `not_found` and
+ unavailable nodes without routing bytes through the manifest service;
+- fetch the public MessagePack DataMap and every resolved encrypted data chunk;
+- reconstruct the file with the native `self_encryption 0.36` BLAKE3 KDF,
+ ChaCha20-Poly1305 authentication, and Brotli decompression;
+- verify encrypted-record addresses, per-chunk plaintext hashes and sizes, and
+ the final whole-file BLAKE3 hash before allowing a save;
+- expose a small test site that loads the local testnet manifest, displays the
+ startup-published file, and downloads it through the browser save flow.
+
+The local browser manifest is bootstrap metadata, not a gateway. Production
+clients will replace its unsigned endpoint list with the ML-DSA-signed records
+defined by ant-node ADR-0009.
+
+For the local vertical slice, the bootstrap manifest carries a resolved JSON
+view of the public root DataMap alongside its ordinary on-network DataMap
+address. The browser still fetches and verifies that public DataMap record and
+all file bytes directly from nodes. Production discovery must replace this
+unsigned resolved view with parsing and validation of the signed/on-network
+metadata chain.
+
+## Consequences
+
+### Positive
+
+- Lookup and data transfer remain decentralized at the application layer.
+- A local five-node testnet can validate multiple direct node connections,
+ certificate pins, lookup convergence, fallback, and content verification.
+- The browser protocol is independent of native Rust serialization details.
+
+### Negative / Trade-offs
+
+- The current client reconstructs files in memory and the local launcher caps
+ public files at 64 MiB; streaming-to-disk is not yet implemented.
+- JavaScript lookup behavior must remain aligned with native Kademlia rules.
+- Certificate and endpoint verification adds bootstrap-record lifecycle work.
+
+### Neutral / Operational
+
+- The manifest HTTP service carries only small bootstrap metadata.
+- WebTransport still requires a secure browser context; localhost qualifies
+ for development.
+- Node and client repositories must run compatible browser protocol versions.
+
+## Validation
+
+- Unit tests cover fixed-width identifiers, XOR ordering, response framing,
+ manifest validation, and BLAKE3 mismatch rejection.
+- The browser production bundle builds without Node-specific runtime APIs.
+- A fixed vector generated by native `self_encryption 0.36` verifies browser
+ KDF, authenticated decryption, Brotli reconstruction, and tamper rejection.
+- A live node integration test starts five WebTransport-enabled nodes,
+ publishes a public DataMap and encrypted chunks, connects with the advertised
+ certificate hash, retrieves every record, and reconstructs the exact file.
+- Before acceptance, run interactive tests on current Chrome, Firefox, and
+ Safari and add shared lookup convergence vectors with the native client.
+- Revisit this decision when streaming file reconstruction or production signed
+ endpoint discovery is implemented.
+
+## Notes for AI-assisted work
+
+AI tools may help draft this ADR, but **must not mark it Accepted without human
+review**. Accepted ADRs are immutable: create a new superseding ADR rather than
+editing an Accepted ADR.
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 00000000..dbbdaf20
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,94 @@
+# Autonomi direct browser client
+
+This web application is the browser-facing client for ADR-0009. It loads a
+local testnet bootstrap manifest, connects directly to storage nodes over
+WebTransport, performs the XOR closest-node lookup in JavaScript, retrieves a
+public DataMap and every encrypted file chunk, reconstructs the complete file,
+and verifies its whole-file BLAKE3 hash before saving it.
+
+The node-side WebTransport listener and testnet manifest API live in the
+`ant-node-web-support` sibling repository. No HTTP gateway performs lookup or
+proxies file bytes.
+
+## Requirements
+
+- Rust 1.88 or newer for the node's optional `wtransport` dependency.
+- Node.js 20.19+ or 22.12+.
+- A current browser implementing WebTransport certificate hashes.
+
+## Run the browser-enabled testnet
+
+From `ant-node-web-support`:
+
+```bash
+cargo run --features webtransport-poc --bin ant-devnet -- \
+ --preset minimal \
+ --base-port 23000 \
+ --webtransport \
+ --webtransport-base-port 24000 \
+ --serve-port 25000 \
+ --enable-logging
+```
+
+This starts five native nodes on UDP 23000-23004 and five direct browser
+listeners on UDP 24000-24004. It also:
+
+- self-encrypts the built-in `autonomi-browser-testnet.txt` and publishes its
+ encrypted chunks and public DataMap through the ordinary node PUT handler
+ using devnet-prepaid cache entries;
+- exposes all direct node URLs and certificate pins at
+ `http://127.0.0.1:25000/api/browser-manifest.json`;
+- includes the public DataMap address, plaintext BLAKE3 hash, resolved chunk
+ metadata, filename, size, and replica count in that manifest.
+
+Pass `--public-file /path/to/file` to publish another file instead. The built-in
+file is generated as 5 MiB so the demo exercises whole-file reconstruction. A
+custom file may be up to 64 MiB in this local launcher.
+
+## Run the site
+
+From this directory:
+
+```bash
+npm ci
+npm run dev
+```
+
+Open `http://127.0.0.1:5173`. The page automatically loads the testnet
+manifest from port 25000 and fills in the default file:
+
+1. **Load testnet** refreshes the manifest and direct endpoint catalog.
+2. **Connect** performs a pinned WebTransport `HELLO` with the first node.
+3. **Find closest** runs the iterative lookup in the browser.
+4. **Download and save file** opens the browser save flow, fetches the public
+ DataMap and encrypted chunks from direct closest storage nodes, reconstructs
+ the whole file, verifies BLAKE3, and retains a **Save again** link.
+
+The browser receives the DataMap and all encrypted file bytes from UDP
+24000-24004 over HTTP/3, not from the manifest server on TCP 25000. The
+manifest server is bootstrap metadata only.
+
+For a LAN test, start the node devnet with `--host `, serve Vite with
+`npm run dev -- --host 0.0.0.0`, and add the exact site Origin with
+`--webtransport-origin http://:5173`. Change the manifest URL in the
+page to `http://:25000/api/browser-manifest.json`.
+
+## Verify the client
+
+```bash
+npm test
+npm run build
+```
+
+The tests cover fixed-width IDs, XOR ordering, response framing, browser
+manifest validation, and a native `self_encryption 0.36` compatibility vector.
+Cross-repository live verification additionally starts the node testnet,
+downloads all public-file records through WebTransport, and reconstructs the
+original bytes.
+
+## Current boundary
+
+The local testnet manifest is intentionally unsigned bootstrap material. A
+production deployment still needs ML-DSA-signed endpoint records, certificate
+overlap/rotation, network dissemination, relayed WebTransport, and production
+traffic quotas as specified by ADR-0009.
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 00000000..9578a3c2
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ Autonomi WebTransport PoC
+
+
+
+
+
+
ADR-0009 · interoperability proof
+
Direct browser client
+
+ Connect directly to an ant-node, query its local closest-node view,
+ reconstruct a complete public file, and save it from the browser.
+
+
+
+
+
Local testnet
+
+
+
+
+
+
+
Published file
+
Address
+
File chunks
+
Stored on
+
+
+
+
+
Direct node endpoint
+
+ Populated from the testnet manifest. It can also be entered manually.
+
+
+
+
+
+
+
+
+
+
+
Browser-side closest lookup
+
+
+
+
+
+
+
+
+
Direct public file download
+
+ Fetches the public DataMap and every encrypted file chunk directly,
+ reconstructs and verifies the complete file, then opens the browser save flow.
+
- Populated from the testnet manifest. It can also be entered manually.
+ The certificate pins and peer identity are embedded in this address.
+ It is populated from the testnet manifest and can also be pasted manually.
-
diff --git a/web/src/main.js b/web/src/main.js
index 70536d5a..176d303d 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -17,8 +17,7 @@ const elements = {
publicFileAddress: document.querySelector("#public-file-address"),
publicFileChunks: document.querySelector("#public-file-chunks"),
publicFileReplicas: document.querySelector("#public-file-replicas"),
- endpointUrl: document.querySelector("#endpoint-url"),
- certificateHash: document.querySelector("#certificate-hash"),
+ endpointMultiaddr: document.querySelector("#endpoint-multiaddr"),
connect: document.querySelector("#connect"),
connectionState: document.querySelector("#connection-state"),
lookupTarget: document.querySelector("#lookup-target"),
@@ -46,10 +45,7 @@ function log(message, value) {
}
function endpointFromForm() {
- return {
- url: elements.endpointUrl.value.trim(),
- certificateSha256: elements.certificateHash.value.trim(),
- };
+ return elements.endpointMultiaddr.value.trim();
}
function seedEndpoints() {
@@ -65,8 +61,7 @@ async function loadManifest() {
browserManifest = manifest;
const first = manifest.endpoints[0];
- elements.endpointUrl.value = first.url;
- elements.certificateHash.value = first.certificate_sha256;
+ elements.endpointMultiaddr.value = first.multiaddr;
client?.close();
client = undefined;
elements.connectionState.classList.remove("connected");
diff --git a/web/src/manifest.js b/web/src/manifest.js
index 9bd8a5d6..214e4b89 100644
--- a/web/src/manifest.js
+++ b/web/src/manifest.js
@@ -1,6 +1,6 @@
-import { hexToBytes } from "./protocol.js";
+import { hexToBytes, parseWebTransportMultiaddr } from "./protocol.js";
-export const BROWSER_MANIFEST_VERSION = 2;
+export const BROWSER_MANIFEST_VERSION = 3;
const MAX_PUBLIC_FILE_BYTES = 64 * 1024 * 1024;
const MAX_DATA_MAP_BYTES = 4 * 1024 * 1024;
const MAX_FILE_CHUNKS = 1024;
@@ -16,19 +16,8 @@ export function parseBrowserManifest(value) {
throw new Error("Browser manifest contains no WebTransport endpoints");
}
const endpoints = value.endpoints.map((endpoint) => {
- if (!endpoint || typeof endpoint.url !== "string") {
- throw new Error("Browser manifest endpoint has no URL");
- }
- if (!endpoint.url.startsWith("https://")) {
- throw new Error(`WebTransport endpoint must use HTTPS: ${endpoint.url}`);
- }
- hexToBytes(endpoint.peer_id ?? "", 32);
- hexToBytes(endpoint.certificate_sha256 ?? "", 32);
- return {
- peer_id: endpoint.peer_id.toLowerCase(),
- url: endpoint.url,
- certificate_sha256: endpoint.certificate_sha256.toLowerCase(),
- };
+ const parsed = parseWebTransportMultiaddr(endpoint);
+ return { multiaddr: parsed.multiaddr };
});
const files = (value.files ?? []).map((file) => {
diff --git a/web/src/manifest.test.js b/web/src/manifest.test.js
index f9949eb3..4ff4d469 100644
--- a/web/src/manifest.test.js
+++ b/web/src/manifest.test.js
@@ -4,14 +4,12 @@ import { parseBrowserManifest } from "./manifest.js";
test("browser manifest validates and normalizes endpoints and files", () => {
const manifest = parseBrowserManifest({
- version: 2,
+ version: 3,
network_id: "local-test",
created_at: "2026-08-03T00:00:00Z",
endpoints: [
{
- peer_id: "AA".repeat(32),
- url: "https://127.0.0.1:22000/autonomi/webtransport/v1",
- certificate_sha256: "BB".repeat(32),
+ multiaddr: webtransportMultiaddr("AA".repeat(32), 0xbb),
},
],
files: [
@@ -32,7 +30,10 @@ test("browser manifest validates and normalizes endpoints and files", () => {
],
});
- assert.equal(manifest.endpoints[0].peer_id, "aa".repeat(32));
+ assert.equal(
+ manifest.endpoints[0].multiaddr,
+ webtransportMultiaddr("AA".repeat(32), 0xbb),
+ );
assert.equal(manifest.files[0].address, "cc".repeat(32));
assert.equal(manifest.files[0].blake3, "dd".repeat(32));
assert.deepEqual(
@@ -42,24 +43,33 @@ test("browser manifest validates and normalizes endpoints and files", () => {
assert.equal(manifest.files[0].replicas, 5);
});
-test("browser manifest rejects missing endpoints and malformed hashes", () => {
+test("browser manifest rejects missing endpoints and malformed multiaddresses", () => {
assert.throws(
- () => parseBrowserManifest({ version: 2, network_id: "test", endpoints: [] }),
+ () => parseBrowserManifest({ version: 3, network_id: "test", endpoints: [] }),
/no WebTransport endpoints/,
);
assert.throws(
() =>
parseBrowserManifest({
- version: 2,
+ version: 3,
network_id: "test",
endpoints: [
{
- peer_id: "wrong",
- url: "https://127.0.0.1:22000/path",
- certificate_sha256: "bb".repeat(32),
+ multiaddr:
+ "/ip4/127.0.0.1/udp/22000/quic-v1/webtransport/certhash/uAA/p2p/wrong",
},
],
}),
- /hexadecimal|Expected 32 bytes/,
+ /multihash|hexadecimal|Expected 32 bytes/,
);
});
+
+function webtransportMultiaddr(peerId, certificateByte) {
+ const multihash = Uint8Array.from([
+ 0x12,
+ 0x20,
+ ...Array(32).fill(certificateByte),
+ ]);
+ const certhash = `u${Buffer.from(multihash).toString("base64url")}`;
+ return `/ip4/127.0.0.1/udp/22000/quic-v1/webtransport/certhash/${certhash}/p2p/${peerId}`;
+}
diff --git a/web/src/protocol.js b/web/src/protocol.js
index 7779fc5e..d6ee7988 100644
--- a/web/src/protocol.js
+++ b/web/src/protocol.js
@@ -1,11 +1,16 @@
import { blake3 } from "@noble/hashes/blake3.js";
import { bytesToHex as nobleBytesToHex } from "@noble/hashes/utils.js";
-export const PROTOCOL_VERSION = 1;
-export const PROTOCOL_NAME = "autonomi.web.poc.v1";
+export const PROTOCOL_VERSION = 2;
+export const PROTOCOL_NAME = "autonomi.web.poc.v2";
+export const WEBTRANSPORT_PATH = "/autonomi/webtransport/v1";
export const MAX_CHUNK_SIZE = 4 * 1024 * 1024;
export const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 4 + MAX_RESPONSE_HEADER_BYTES + MAX_CHUNK_SIZE;
+const MAX_WEBTRANSPORT_MULTIADDR_LENGTH = 2048;
+const MAX_CERTIFICATE_HASHES = 2;
+const SHA2_256_MULTIHASH_CODE = 0x12;
+const SHA2_256_MULTIHASH_LENGTH = 32;
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -118,20 +123,145 @@ async function readAll(readable, limit = MAX_RESPONSE_BYTES) {
return result;
}
-function normalizeEndpoint(endpoint) {
- if (!endpoint || typeof endpoint.url !== "string") {
- throw new Error("Endpoint URL is required");
+function decodeBase64Url(value) {
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
+ throw new Error("Certificate multihash is not valid unpadded base64url");
+ }
+ const padding = "=".repeat((4 - (value.length % 4)) % 4);
+ let binary;
+ try {
+ binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
+ } catch (error) {
+ throw new Error("Certificate multihash is not valid unpadded base64url", {
+ cause: error,
+ });
+ }
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+function decodeCertificateMultihash(value) {
+ if (typeof value !== "string" || !value.startsWith("u")) {
+ throw new Error("Certificate multihash must use base64url multibase (`u`)");
+ }
+ const decoded = decodeBase64Url(value.slice(1));
+ if (
+ decoded.length !== 34 ||
+ decoded[0] !== SHA2_256_MULTIHASH_CODE ||
+ decoded[1] !== SHA2_256_MULTIHASH_LENGTH
+ ) {
+ throw new Error("Certificate multihash must contain a 32-byte SHA-256 digest");
+ }
+ return decoded.slice(2);
+}
+
+function validateIpv4(value) {
+ const octets = value.split(".");
+ if (
+ octets.length !== 4 ||
+ octets.some(
+ (octet) =>
+ !/^(0|[1-9][0-9]{0,2})$/.test(octet) || Number.parseInt(octet, 10) > 255,
+ )
+ ) {
+ throw new Error(`Invalid IPv4 address ${value}`);
+ }
+ return value;
+}
+
+function endpointMultiaddr(endpoint) {
+ if (typeof endpoint === "string") return endpoint;
+ if (endpoint && typeof endpoint.multiaddr === "string") return endpoint.multiaddr;
+ throw new Error("A WebTransport multiaddress is required");
+}
+
+export function parseWebTransportMultiaddr(endpoint) {
+ const multiaddr = endpointMultiaddr(endpoint).trim();
+ if (
+ multiaddr.length === 0 ||
+ multiaddr.length > MAX_WEBTRANSPORT_MULTIADDR_LENGTH ||
+ !multiaddr.startsWith("/")
+ ) {
+ throw new Error("Invalid WebTransport multiaddress length or prefix");
+ }
+ const segments = multiaddr.split("/");
+ if (segments.length < 9) {
+ throw new Error("WebTransport multiaddress is incomplete");
+ }
+
+ const hostProtocol = segments[1];
+ const hostValue = segments[2];
+ if (!hostValue) throw new Error("WebTransport multiaddress host is empty");
+ let urlHost;
+ if (hostProtocol === "ip4") {
+ urlHost = validateIpv4(hostValue);
+ } else if (hostProtocol === "ip6") {
+ urlHost = `[${hostValue}]`;
+ } else if (["dns", "dns4", "dns6"].includes(hostProtocol)) {
+ urlHost = hostValue.toLowerCase();
+ } else {
+ throw new Error(`Unsupported WebTransport host protocol ${hostProtocol}`);
+ }
+ if (segments[3] !== "udp") {
+ throw new Error("WebTransport multiaddress must use UDP");
+ }
+ if (!/^[0-9]{1,5}$/.test(segments[4])) {
+ throw new Error("WebTransport multiaddress has an invalid UDP port");
+ }
+ const port = Number.parseInt(segments[4], 10);
+ if (port < 1 || port > 65535) {
+ throw new Error("WebTransport multiaddress has an invalid UDP port");
+ }
+ if (segments[5] !== "quic-v1" || segments[6] !== "webtransport") {
+ throw new Error(
+ "WebTransport multiaddress must contain /quic-v1/webtransport",
+ );
+ }
+
+ let index = 7;
+ const certificateHashes = [];
+ const certificateHashMultihashes = [];
+ while (segments[index] === "certhash") {
+ const encoded = segments[index + 1];
+ if (!encoded) throw new Error("WebTransport multiaddress has an empty certhash");
+ certificateHashes.push(decodeCertificateMultihash(encoded));
+ certificateHashMultihashes.push(encoded);
+ index += 2;
+ }
+ if (
+ certificateHashes.length < 1 ||
+ certificateHashes.length > MAX_CERTIFICATE_HASHES
+ ) {
+ throw new Error(
+ `WebTransport multiaddress must contain between 1 and ${MAX_CERTIFICATE_HASHES} certificate hashes`,
+ );
+ }
+ if (new Set(certificateHashMultihashes).size !== certificateHashes.length) {
+ throw new Error("WebTransport multiaddress contains duplicate certificate hashes");
+ }
+ if (segments[index] !== "p2p" || index + 2 !== segments.length) {
+ throw new Error("WebTransport multiaddress must end with /p2p/");
+ }
+ const peerId = segments[index + 1]?.toLowerCase() ?? "";
+ hexToBytes(peerId, 32);
+
+ let url;
+ try {
+ url = new URL(`https://${urlHost}:${port}${WEBTRANSPORT_PATH}`).toString();
+ } catch (error) {
+ throw new Error("WebTransport multiaddress contains an invalid host", {
+ cause: error,
+ });
}
- const certificateSha256 =
- endpoint.certificate_sha256 ?? endpoint.certificateSha256;
return {
- url: endpoint.url,
- peerId: endpoint.peer_id ?? endpoint.peerId,
- certificateSha256,
- certificateBytes: hexToBytes(certificateSha256 ?? "", 32),
+ multiaddr,
+ url,
+ peerId,
+ certificateHashes,
};
}
+const normalizeEndpoint = parseWebTransportMultiaddr;
+
export class BrowserNodeClient {
constructor(endpoint) {
this.endpoint = normalizeEndpoint(endpoint);
@@ -145,9 +275,10 @@ export class BrowserNodeClient {
throw new Error("This browser does not expose the WebTransport API");
}
const transport = new WebTransport(this.endpoint.url, {
- serverCertificateHashes: [
- { algorithm: "sha-256", value: this.endpoint.certificateBytes },
- ],
+ serverCertificateHashes: this.endpoint.certificateHashes.map((value) => ({
+ algorithm: "sha-256",
+ value,
+ })),
});
try {
await transport.ready;
@@ -200,15 +331,14 @@ export class BrowserNodeClient {
if (header.protocol !== PROTOCOL_NAME) {
throw new Error(`Unsupported browser protocol ${header.protocol}`);
}
+ hexToBytes(header.peer_id, 32);
const advertisedEndpoint = normalizeEndpoint(header.endpoint);
if (
- advertisedEndpoint.url !== this.endpoint.url ||
- advertisedEndpoint.certificateSha256.toLowerCase() !==
- this.endpoint.certificateSha256.toLowerCase()
+ advertisedEndpoint.multiaddr !== this.endpoint.multiaddr ||
+ advertisedEndpoint.peerId !== header.peer_id.toLowerCase()
) {
throw new Error("Node advertised a different WebTransport endpoint");
}
- hexToBytes(header.peer_id, 32);
if (
this.endpoint.peerId &&
header.peer_id.toLowerCase() !== this.endpoint.peerId.toLowerCase()
@@ -231,7 +361,15 @@ export class BrowserNodeClient {
if (!Array.isArray(header.nodes)) {
throw new Error("Node returned an invalid node list");
}
- for (const node of header.nodes) hexToBytes(node.peer_id, 32);
+ for (const node of header.nodes) {
+ hexToBytes(node.peer_id, 32);
+ if (node.webtransport) {
+ const parsed = normalizeEndpoint(node.webtransport);
+ if (parsed.peerId !== node.peer_id.toLowerCase()) {
+ throw new Error(`Node ${node.peer_id} advertised another peer's endpoint`);
+ }
+ }
+ }
return header.nodes;
}
@@ -264,7 +402,7 @@ export class BrowserNodeClient {
function endpointKey(endpoint) {
const normalized = normalizeEndpoint(endpoint);
- return `${normalized.url}|${normalized.certificateSha256}`;
+ return normalized.multiaddr;
}
export async function iterativeFindClosest(
@@ -294,7 +432,8 @@ export async function iterativeFindClosest(
await Promise.all(
seedEndpoints.map(async (endpoint) => {
- const seedName = endpoint?.peer_id ?? endpoint?.peerId ?? endpoint?.url ?? "seed";
+ const seedName =
+ typeof endpoint === "string" ? endpoint : endpoint?.multiaddr ?? "seed";
try {
const client = clientFor(endpoint);
const hello = await client.hello();
@@ -333,10 +472,7 @@ export async function iterativeFindClosest(
candidates.map(async (candidate) => {
queried.add(candidate.peer_id);
try {
- const candidateClient = clientFor({
- ...candidate.webtransport,
- peer_id: candidate.peer_id,
- });
+ const candidateClient = clientFor(candidate.webtransport);
if (!candidateClient.peerId) await candidateClient.hello();
const nodes = await candidateClient.findNode(target, k);
onProgress(
@@ -395,7 +531,7 @@ export async function getChunkFromClosest(
try {
for (const node of lookup.nodes) {
if (!node.webtransport) continue;
- const endpoint = { ...node.webtransport, peer_id: node.peer_id };
+ const endpoint = node.webtransport;
const key = endpointKey(endpoint);
let client = lookup.clients.get(key);
if (!client) {
diff --git a/web/src/protocol.test.js b/web/src/protocol.test.js
index a8a48bfc..7dbfc4e2 100644
--- a/web/src/protocol.test.js
+++ b/web/src/protocol.test.js
@@ -6,6 +6,7 @@ import {
getChunkFromClosest,
hexToBytes,
parseResponseFrame,
+ parseWebTransportMultiaddr,
verifyChunk,
xorDistance,
} from "./protocol.js";
@@ -26,7 +27,7 @@ test("XOR distance is an unsigned 256-bit ordering value", () => {
test("response framing preserves a raw binary body", () => {
const header = new TextEncoder().encode(
JSON.stringify({
- version: 1,
+ version: 2,
request_id: 9,
status: "ok",
content_length: 3,
@@ -45,6 +46,29 @@ test("response framing preserves a raw binary body", () => {
assert.deepEqual([...parsed.content], [1, 2, 3]);
});
+test("WebTransport multiaddresses carry current and next certificate hashes", () => {
+ const peerId = "ab".repeat(32);
+ const multiaddr = webtransportMultiaddr("ip4", "127.0.0.1", 24000, peerId, [
+ 0x11,
+ 0x22,
+ ]);
+ const parsed = parseWebTransportMultiaddr(multiaddr);
+
+ assert.equal(parsed.url, "https://127.0.0.1:24000/autonomi/webtransport/v1");
+ assert.equal(parsed.peerId, peerId);
+ assert.deepEqual(
+ parsed.certificateHashes.map((hash) => [...hash]),
+ [Array(32).fill(0x11), Array(32).fill(0x22)],
+ );
+ assert.throws(
+ () =>
+ parseWebTransportMultiaddr(
+ `/ip4/127.0.0.1/udp/24000/quic-v1/webtransport/p2p/${peerId}`,
+ ),
+ /certificate hashes/,
+ );
+});
+
test("BLAKE3 verification accepts the canonical empty hash", () => {
const emptyHash =
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
@@ -57,16 +81,8 @@ test("browser lookup discovers a direct node and downloads a verified chunk", as
const address = bytesToHex(blake3(content));
const seedPeer = "ff".repeat(32);
const storagePeer = address;
- const seed = {
- peer_id: seedPeer,
- url: "https://seed.test/autonomi/webtransport/v1",
- certificate_sha256: "11".repeat(32),
- };
- const storage = {
- peer_id: storagePeer,
- url: "https://storage.test/autonomi/webtransport/v1",
- certificate_sha256: "22".repeat(32),
- };
+ const seed = endpoint("seed.test", seedPeer, 0x11);
+ const storage = endpoint("storage.test", storagePeer, 0x22);
const calls = [];
const routes = new Map([
[
@@ -114,7 +130,7 @@ test("browser lookup discovers a direct node and downloads a verified chunk", as
globalThis.WebTransport = previousWebTransport;
});
- const downloaded = await getChunkFromClosest([seed], address);
+ const downloaded = await getChunkFromClosest([seed.multiaddr], address);
assert.deepEqual(downloaded.content, content);
assert.equal(downloaded.hash, address);
assert.equal(downloaded.node.peer_id, storagePeer);
@@ -133,8 +149,7 @@ function browserNode(endpoint) {
native_addresses: [],
reliability: 1,
webtransport: {
- url: endpoint.url,
- certificate_sha256: endpoint.certificate_sha256,
+ multiaddr: endpoint.multiaddr,
},
};
}
@@ -142,12 +157,11 @@ function browserNode(endpoint) {
function helloResponse(request, endpoint) {
return response(request, {
type: "hello",
- protocol: "autonomi.web.poc.v1",
+ protocol: "autonomi.web.poc.v2",
peer_id: endpoint.peer_id,
max_chunk_size: 4 * 1024 * 1024,
endpoint: {
- url: endpoint.url,
- certificate_sha256: endpoint.certificate_sha256,
+ multiaddr: endpoint.multiaddr,
},
capabilities: ["find_node", "get_chunk"],
});
@@ -156,7 +170,7 @@ function helloResponse(request, endpoint) {
function response(request, fields, content = new Uint8Array()) {
return {
header: {
- version: 1,
+ version: 2,
request_id: request.request_id,
status: "ok",
content_length: content.length,
@@ -166,6 +180,26 @@ function response(request, fields, content = new Uint8Array()) {
};
}
+function certificateMultihash(byte) {
+ const multihash = Uint8Array.from([0x12, 0x20, ...Array(32).fill(byte)]);
+ return `u${Buffer.from(multihash).toString("base64url")}`;
+}
+
+function webtransportMultiaddr(hostProtocol, host, port, peerId, hashBytes) {
+ const hashes = hashBytes
+ .map((byte) => `/certhash/${certificateMultihash(byte)}`)
+ .join("");
+ return `/${hostProtocol}/${host}/udp/${port}/quic-v1/webtransport${hashes}/p2p/${peerId}`;
+}
+
+function endpoint(host, peerId, certificateByte) {
+ return {
+ peer_id: peerId,
+ url: `https://${host}/autonomi/webtransport/v1`,
+ multiaddr: webtransportMultiaddr("dns", host, 443, peerId, [certificateByte]),
+ };
+}
+
function encodeResponse({ header, content }) {
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
const frame = new Uint8Array(4 + headerBytes.length + content.length);
From a6c4c5a12a0d00019ddfe2ddb564c1e4e25a88d3 Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Wed, 5 Aug 2026 14:17:36 +0200
Subject: [PATCH 03/31] feat(web): upload paid public files
---
.../ADR-0003-direct-browser-read-client.md | 52 ++-
web/README.md | 31 +-
web/index.html | 40 ++-
web/package-lock.json | 183 +++++++++-
web/package.json | 6 +-
web/src/main.js | 64 ++++
web/src/manifest.js | 29 +-
web/src/manifest.test.js | 41 ++-
web/src/payment.js | 306 ++++++++++++++++
web/src/payment.test.js | 235 +++++++++++++
web/src/protocol.js | 67 +++-
web/src/protocol.test.js | 60 +++-
web/src/style.css | 13 +
web/src/upload.js | 326 ++++++++++++++++++
web/src/upload.test.js | 81 +++++
15 files changed, 1488 insertions(+), 46 deletions(-)
create mode 100644 web/src/payment.js
create mode 100644 web/src/payment.test.js
create mode 100644 web/src/upload.js
create mode 100644 web/src/upload.test.js
diff --git a/docs/adr/ADR-0003-direct-browser-read-client.md b/docs/adr/ADR-0003-direct-browser-read-client.md
index 8e94e1e4..e6121b67 100644
--- a/docs/adr/ADR-0003-direct-browser-read-client.md
+++ b/docs/adr/ADR-0003-direct-browser-read-client.md
@@ -1,8 +1,8 @@
-# ADR-0003: Direct browser read client over WebTransport
+# ADR-0003: Direct browser immutable-data client over WebTransport
- **Status:** Proposed
- **Date:** 2026-08-03
-- **Last amended:** 2026-08-04
+- **Last amended:** 2026-08-05
- **Decision owners:**
- **Reviewers:**
- **Supersedes:** none
@@ -11,15 +11,16 @@
## Context
-The Autonomi web client must perform closest-node lookup and immutable-data
-download itself. Sending those operations through an HTTP application gateway
-would make the gateway an availability, privacy, and bandwidth chokepoint.
+The Autonomi web client must perform closest-node lookup, immutable-data
+download, quote verification, payment, and upload itself. Sending those
+operations through an HTTP application gateway would make the gateway an
+availability, privacy, and bandwidth chokepoint.
Browsers cannot use the native Saorsa QUIC protocol, but they can establish
WebTransport sessions with browser-compatible node listeners.
This repository owns the client and UI side of that split. Nodes own transport
-termination, local DHT answers, storage reads, endpoint records, and testnet
-bootstrap-manifest production under ant-node ADR-0009.
+termination, local DHT answers, storage reads and paid writes, endpoint
+records, and testnet bootstrap-manifest production under ant-node ADR-0009.
## Decision Drivers
@@ -28,6 +29,8 @@ bootstrap-manifest production under ant-node ADR-0009.
- Self-signed node certificates must be authenticated through hashes embedded
in self-contained node multiaddresses, without a separate client argument.
- Downloaded immutable content must be verified before it is exposed to users.
+- The wallet secret must be provided at runtime, used only by the local EVM
+ signer, and never sent to a node or persisted in bootstrap metadata.
- Local testnets need a reproducible bootstrap and default-file workflow.
## Considered Options
@@ -37,13 +40,13 @@ bootstrap-manifest production under ant-node ADR-0009.
2. **Compile the complete native Rust client to WebAssembly.** Deferred because
the native transport, EVM, and filesystem dependency graph is not currently
browser-compatible.
-3. **Implement a narrow JavaScript WebTransport read client (chosen).** It maps
- directly to the versioned browser node protocol and keeps the application
- boundary small enough to audit.
+3. **Implement a narrow JavaScript WebTransport immutable-data client
+ (chosen).** It maps directly to the versioned browser node protocol and
+ keeps the application boundary small enough to audit.
## Decision
-The `web/` package will implement the direct browser read client:
+The `web/` package will implement the direct browser client:
- load a versioned browser bootstrap manifest containing WebTransport
multiaddresses and published immutable-file metadata;
@@ -55,13 +58,23 @@ The `web/` package will implement the direct browser read client:
and `ALPHA = 3`;
- query closest direct endpoints with `GET_CHUNK`, retrying `not_found` and
unavailable nodes without routing bytes through the manifest service;
+- self-encrypt selected public files with the native `self_encryption 0.36`
+ format and generate the public MessagePack DataMap;
+- request ordinary node storage quotes, verify their ML-DSA peer/content
+ binding, forced price, and signed storage commitment before payment;
+- construct an EVM wallet only from the runtime secret field, approve the
+ public vault when required, and make one batched `payForQuotes` transaction;
+- upload each content-addressed encrypted record with the signed quote and
+ transaction hash through paid `PUT_CHUNK`; the wallet key never crosses the
+ WebTransport session;
- fetch the public MessagePack DataMap and every resolved encrypted data chunk;
- reconstruct the file with the native `self_encryption 0.36` BLAKE3 KDF,
ChaCha20-Poly1305 authentication, and Brotli decompression;
- verify encrypted-record addresses, per-chunk plaintext hashes and sizes, and
the final whole-file BLAKE3 hash before allowing a save;
- expose a small test site that loads the local testnet manifest, displays the
- startup-published file, and downloads it through the browser save flow.
+ startup-published file, uploads paid files, and downloads either through the
+ browser save flow.
The local browser manifest is bootstrap metadata, not a gateway. Production
clients will replace its unsigned endpoint list with the ML-DSA-signed records
@@ -92,7 +105,8 @@ metadata chain.
### Positive
-- Lookup and data transfer remain decentralized at the application layer.
+- Lookup, payment, and data transfer remain decentralized at the application
+ layer.
- A local five-node testnet can validate multiple direct node connections,
multiaddress-embedded certificate pins, lookup convergence, fallback, and
content verification.
@@ -101,7 +115,8 @@ metadata chain.
### Negative / Trade-offs
- The current client reconstructs files in memory and the local launcher caps
- public files at 64 MiB; streaming-to-disk is not yet implemented.
+ public files at 64 MiB; upload encryption and reconstruction are not yet
+ streaming.
- JavaScript lookup behavior must remain aligned with native Kademlia rules.
- Certificate and endpoint verification adds bootstrap-record lifecycle work.
@@ -114,15 +129,18 @@ metadata chain.
## Validation
-- Unit tests cover fixed-width identifiers, XOR ordering, response framing,
- manifest validation, and BLAKE3 mismatch rejection.
+- Unit tests cover fixed-width identifiers, XOR ordering, bidirectional binary
+ framing, manifest/payment validation, quote signatures and the native
+ Keccak-256 EVM quote-hash vector, native-format
+ encryption/DataMap generation, and BLAKE3 mismatch rejection.
- The browser production bundle builds without Node-specific runtime APIs.
- A fixed vector generated by native `self_encryption 0.36` verifies browser
KDF, authenticated decryption, Brotli reconstruction, and tamper rejection.
- A live node integration test starts five WebTransport-enabled nodes,
publishes a public DataMap and encrypted chunks, connects with the advertised
self-contained multiaddress, retrieves every record, and reconstructs the
- exact file.
+ exact file, pays a real signed quote, accepts a paid binary PUT through the
+ ordinary node verifier, and reads the stored record back.
- Before acceptance, run interactive tests on current Chrome, Firefox, and
Safari and add shared lookup convergence vectors with the native client.
- Revisit this decision when streaming file reconstruction or production signed
diff --git a/web/README.md b/web/README.md
index ad07d7c0..e60c42fe 100644
--- a/web/README.md
+++ b/web/README.md
@@ -4,7 +4,9 @@ This web application is the browser-facing client for ADR-0009. It loads a
local testnet bootstrap manifest, connects directly to storage nodes over
WebTransport, performs the XOR closest-node lookup in JavaScript, retrieves a
public DataMap and every encrypted file chunk, reconstructs the complete file,
-and verifies its whole-file BLAKE3 hash before saving it.
+and verifies its whole-file BLAKE3 hash before saving it. It can also
+self-encrypt a file, pay signed node quotes with a wallet held only in the
+page, and upload the encrypted records directly to closest storage nodes.
The node-side WebTransport listener and testnet manifest API live in the
`ant-node-web-support` sibling repository. No HTTP gateway performs lookup or
@@ -31,6 +33,7 @@ cargo run --features webtransport-poc --bin ant-devnet -- \
--webtransport \
--webtransport-base-port 24000 \
--serve-port 25000 \
+ --enable-evm \
--enable-logging
```
@@ -45,6 +48,13 @@ listeners on UDP 24000-24004. It also:
`http://127.0.0.1:25000/api/browser-manifest.json`;
- includes the public DataMap address, plaintext BLAKE3 hash, resolved chunk
metadata, filename, size, and replica count in that manifest.
+- starts local Anvil payment contracts and prints a disposable funded wallet
+ private key. The browser manifest contains only public RPC and contract
+ addresses, never the private key.
+
+Confirm that `HELLO.payment.rpc_url` is a loopback Anvil URL. An
+`https://arb1.arbitrum.io/rpc` value means an older devnet was started without
+`--enable-evm`; its disposable local key cannot fund uploads there.
Pass `--public-file /path/to/file` to publish another file instead. The built-in
file is generated as 5 MiB so the demo exercises whole-file reconstruction. A
@@ -66,7 +76,12 @@ manifest from port 25000 and fills in the default file:
2. **Connect** parses the first node multiaddress and performs a pinned
WebTransport `HELLO`.
3. **Find closest** runs the iterative lookup in the browser.
-4. **Download and save file** opens the browser save flow, fetches the public
+4. Under **Paid public file upload**, choose a file, paste the funded private
+ key printed by ant-devnet, then select **Pay and upload file**. Encryption,
+ quote/commitment verification, approval, and payment happen locally. The
+ key field is cleared immediately and the resulting public DataMap address
+ is placed in the download field.
+5. **Download and save file** opens the browser save flow, fetches the public
DataMap and encrypted chunks from direct closest storage nodes, reconstructs
the whole file, verifies BLAKE3, and retains a **Save again** link.
@@ -86,11 +101,15 @@ npm test
npm run build
```
-The tests cover fixed-width IDs, XOR ordering, response framing, browser
-manifest validation, and a native `self_encryption 0.36` compatibility vector.
+The tests cover fixed-width IDs, XOR ordering, bidirectional binary framing,
+browser manifest/payment validation, signed quote verification including the
+native Keccak-256 EVM quote hash, native
+encryption/DataMap generation, and a `self_encryption 0.36` compatibility
+vector.
Cross-repository live verification additionally starts the node testnet,
-downloads all public-file records through WebTransport, and reconstructs the
-original bytes.
+downloads all public-file records through WebTransport, reconstructs the
+original bytes, pays a real quote on local Anvil, uploads a fresh record
+through the ordinary node payment validator, and reads it back.
## Current boundary
diff --git a/web/index.html b/web/index.html
index e624fb5c..8da7ece3 100644
--- a/web/index.html
+++ b/web/index.html
@@ -17,7 +17,7 @@
Direct browser client
Connect directly to an ant-node, query its local closest-node view,
- reconstruct a complete public file, and save it from the browser.
+ upload a paid public file, or reconstruct and save one from the browser.
@@ -79,6 +79,44 @@
Browser-side closest lookup
+
+
Paid public file upload
+
+ Self-encrypts the file in this page, verifies signed storage quotes, pays
+ the testnet vault, and sends encrypted records directly to closest nodes.
+
+
+ The wallet key is used only by this page to sign the EVM transactions. It
+ is cleared from the form immediately and is never sent to a node or stored
+ in the manifest. Use the disposable funded key printed by ant-devnet.
+
+
+
+
+
+
+
+
+
Uploaded file
+
Address
+
Storage
+
Payment
+
+
+
Direct public file download
diff --git a/web/package-lock.json b/web/package-lock.json
index 8f5fd2f9..a3490305 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,14 +8,33 @@
"name": "ant-client-web",
"version": "0.1.0",
"dependencies": {
+ "@msgpack/msgpack": "^3.1.3",
"@noble/ciphers": "2.2.0",
"@noble/hashes": "2.2.0",
- "brotli-dec-wasm": "2.3.2"
+ "@noble/post-quantum": "^0.6.1",
+ "brotli-dec-wasm": "2.3.2",
+ "brotli-wasm": "^3.0.1",
+ "ethers": "^6.17.0"
},
"devDependencies": {
"vite": "8.2.0"
}
},
+ "node_modules/@adraffy/ens-normalize": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
+ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@msgpack/msgpack": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
+ "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/@noble/ciphers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
@@ -28,6 +47,21 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@noble/curves": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
+ "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.2.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
@@ -40,6 +74,23 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@noble/post-quantum": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz",
+ "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/ciphers": "~2.2.0",
+ "@noble/curves": "~2.2.0",
+ "@noble/hashes": "~2.2.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@oxc-project/types": {
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
@@ -295,12 +346,39 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/aes-js": {
+ "version": "4.0.0-beta.5",
+ "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
+ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
+ "license": "MIT"
+ },
"node_modules/brotli-dec-wasm": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/brotli-dec-wasm/-/brotli-dec-wasm-2.3.2.tgz",
"integrity": "sha512-5H+k8eVLIJY6B4olN2HP9QzJAxcplf0jV7mWnkpxvOSeUE9Npg3dQ2pgLn30a9MUFHNro1iSmtdu6VNdlb+TIw==",
"license": "MIT OR Apache-2.0"
},
+ "node_modules/brotli-wasm": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/brotli-wasm/-/brotli-wasm-3.0.1.tgz",
+ "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=v18.0.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -311,6 +389,73 @@
"node": ">=8"
}
},
+ "node_modules/ethers": {
+ "version": "6.17.0",
+ "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz",
+ "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/ethers-io/"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.buymeacoffee.com/ricmoo"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@adraffy/ens-normalize": "1.11.1",
+ "@noble/curves": "1.2.0",
+ "@noble/hashes": "1.3.2",
+ "@types/node": "22.7.5",
+ "aes-js": "4.0.0-beta.5",
+ "tslib": "2.7.0",
+ "ws": "8.21.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/ethers/node_modules/@noble/curves": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
+ "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.3.2"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/ethers/node_modules/@noble/hashes": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
+ "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/ethers/node_modules/@types/node": {
+ "version": "22.7.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
+ "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.19.2"
+ }
+ },
+ "node_modules/ethers/node_modules/undici-types": {
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -733,6 +878,21 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tslib": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
+ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
+ "license": "0BSD"
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/vite": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
@@ -810,6 +970,27 @@
"optional": true
}
}
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/web/package.json b/web/package.json
index 19cad25c..6e9f616c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,9 +9,13 @@
"test": "node --test"
},
"dependencies": {
+ "@msgpack/msgpack": "^3.1.3",
"@noble/ciphers": "2.2.0",
"@noble/hashes": "2.2.0",
- "brotli-dec-wasm": "2.3.2"
+ "@noble/post-quantum": "^0.6.1",
+ "brotli-dec-wasm": "2.3.2",
+ "brotli-wasm": "^3.0.1",
+ "ethers": "^6.17.0"
},
"devDependencies": {
"vite": "8.2.0"
diff --git a/web/src/main.js b/web/src/main.js
index 176d303d..192de1a9 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -7,6 +7,7 @@ import {
} from "./protocol.js";
import { downloadPublicFile } from "./file.js";
import { fetchBrowserManifest } from "./manifest.js";
+import { uploadPublicFile } from "./upload.js";
const elements = {
manifestUrl: document.querySelector("#manifest-url"),
@@ -23,6 +24,15 @@ const elements = {
lookupTarget: document.querySelector("#lookup-target"),
randomTarget: document.querySelector("#random-target"),
findClosest: document.querySelector("#find-closest"),
+ uploadInput: document.querySelector("#upload-file-input"),
+ walletSecret: document.querySelector("#wallet-secret"),
+ uploadFile: document.querySelector("#upload-file"),
+ uploadState: document.querySelector("#upload-state"),
+ uploadResult: document.querySelector("#upload-result"),
+ uploadResultName: document.querySelector("#upload-result-name"),
+ uploadResultAddress: document.querySelector("#upload-result-address"),
+ uploadResultRecords: document.querySelector("#upload-result-records"),
+ uploadResultPayment: document.querySelector("#upload-result-payment"),
fileAddress: document.querySelector("#file-address"),
downloadFile: document.querySelector("#download-file"),
downloadState: document.querySelector("#download-state"),
@@ -151,6 +161,60 @@ elements.findClosest.addEventListener("click", async () => {
}
});
+elements.uploadFile.addEventListener("click", async () => {
+ elements.uploadState.classList.remove("connected");
+ elements.uploadState.textContent = "Preparing upload…";
+ elements.uploadResult.hidden = true;
+ elements.uploadFile.disabled = true;
+ let walletSecret = elements.walletSecret.value.trim();
+ elements.walletSecret.value = "";
+ try {
+ if (!browserManifest) throw new Error("Load the browser testnet manifest first");
+ const file = elements.uploadInput.files?.[0];
+ if (!file) throw new Error("Choose a file to upload");
+ if (!walletSecret) throw new Error("Enter the paying wallet secret key");
+
+ log(`Starting paid public upload for ${file.name}`);
+ const result = await uploadPublicFile(
+ seedEndpoints(),
+ browserManifest.payment,
+ file,
+ walletSecret,
+ {
+ onProgress: (message) => {
+ elements.uploadState.textContent = message;
+ log(message);
+ },
+ },
+ );
+
+ browserManifest.files = [
+ ...browserManifest.files.filter(
+ (published) => published.address !== result.file.address,
+ ),
+ result.file,
+ ];
+ elements.fileAddress.value = result.file.address;
+ elements.uploadResultName.textContent = `${result.file.name} · ${result.file.size.toLocaleString()} bytes`;
+ elements.uploadResultAddress.textContent = result.file.address;
+ elements.uploadResultRecords.textContent = `${result.records} records · at least ${result.file.replicas} replica${result.file.replicas === 1 ? "" : "s"}`;
+ elements.uploadResultPayment.textContent = result.transactionHash
+ ? `${result.transactionHash} · ${result.storageCostAtto} atto tokens`
+ : "No new payment was required";
+ elements.uploadResult.hidden = false;
+ elements.uploadState.textContent = "Uploaded · ready to download";
+ elements.uploadState.classList.add("connected");
+ log(`Uploaded and registered ${result.file.name} for immediate download`, result);
+ } catch (error) {
+ elements.uploadState.textContent = "Upload failed";
+ log(`File upload failed: ${error.message}`);
+ console.error(error);
+ } finally {
+ walletSecret = "";
+ elements.uploadFile.disabled = false;
+ }
+});
+
elements.downloadFile.addEventListener("click", async () => {
elements.downloadState.classList.remove("connected");
elements.downloadState.textContent = "Preparing save…";
diff --git a/web/src/manifest.js b/web/src/manifest.js
index 214e4b89..b42edd43 100644
--- a/web/src/manifest.js
+++ b/web/src/manifest.js
@@ -1,6 +1,6 @@
import { hexToBytes, parseWebTransportMultiaddr } from "./protocol.js";
-export const BROWSER_MANIFEST_VERSION = 3;
+export const BROWSER_MANIFEST_VERSION = 4;
const MAX_PUBLIC_FILE_BYTES = 64 * 1024 * 1024;
const MAX_DATA_MAP_BYTES = 4 * 1024 * 1024;
const MAX_FILE_CHUNKS = 1024;
@@ -19,6 +19,7 @@ export function parseBrowserManifest(value) {
const parsed = parseWebTransportMultiaddr(endpoint);
return { multiaddr: parsed.multiaddr };
});
+ const payment = parsePaymentNetwork(value.payment);
const files = (value.files ?? []).map((file) => {
if (!file || typeof file.name !== "string" || file.name.length === 0) {
@@ -93,10 +94,36 @@ export function parseBrowserManifest(value) {
network_id: value.network_id,
created_at: value.created_at,
endpoints,
+ payment,
files,
};
}
+export function parsePaymentNetwork(value) {
+ if (!value || typeof value !== "object") {
+ throw new Error("Browser manifest contains no payment network");
+ }
+ let rpcUrl;
+ try {
+ rpcUrl = new URL(value.rpc_url);
+ } catch (error) {
+ throw new Error("Payment RPC URL is invalid", { cause: error });
+ }
+ if (!["http:", "https:"].includes(rpcUrl.protocol)) {
+ throw new Error("Payment RPC URL must use HTTP or HTTPS");
+ }
+ if (rpcUrl.username || rpcUrl.password) {
+ throw new Error("Payment RPC URL must not contain credentials");
+ }
+ hexToBytes(value.payment_token_address ?? "", 20);
+ hexToBytes(value.payment_vault_address ?? "", 20);
+ return {
+ rpc_url: rpcUrl.toString(),
+ payment_token_address: `0x${value.payment_token_address.replace(/^0x/i, "").toLowerCase()}`,
+ payment_vault_address: `0x${value.payment_vault_address.replace(/^0x/i, "").toLowerCase()}`,
+ };
+}
+
export async function fetchBrowserManifest(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
diff --git a/web/src/manifest.test.js b/web/src/manifest.test.js
index 4ff4d469..4322ca1e 100644
--- a/web/src/manifest.test.js
+++ b/web/src/manifest.test.js
@@ -4,9 +4,10 @@ import { parseBrowserManifest } from "./manifest.js";
test("browser manifest validates and normalizes endpoints and files", () => {
const manifest = parseBrowserManifest({
- version: 3,
+ version: 4,
network_id: "local-test",
created_at: "2026-08-03T00:00:00Z",
+ payment: paymentNetwork(),
endpoints: [
{
multiaddr: webtransportMultiaddr("AA".repeat(32), 0xbb),
@@ -41,18 +42,26 @@ test("browser manifest validates and normalizes endpoints and files", () => {
[0, 1, 2],
);
assert.equal(manifest.files[0].replicas, 5);
+ assert.deepEqual(manifest.payment, paymentNetwork());
});
test("browser manifest rejects missing endpoints and malformed multiaddresses", () => {
assert.throws(
- () => parseBrowserManifest({ version: 3, network_id: "test", endpoints: [] }),
+ () =>
+ parseBrowserManifest({
+ version: 4,
+ network_id: "test",
+ payment: paymentNetwork(),
+ endpoints: [],
+ }),
/no WebTransport endpoints/,
);
assert.throws(
() =>
parseBrowserManifest({
- version: 3,
+ version: 4,
network_id: "test",
+ payment: paymentNetwork(),
endpoints: [
{
multiaddr:
@@ -64,6 +73,32 @@ test("browser manifest rejects missing endpoints and malformed multiaddresses",
);
});
+test("browser manifest requires public payment contract configuration", () => {
+ const endpoint = { multiaddr: webtransportMultiaddr("aa".repeat(32), 0xbb) };
+ assert.throws(
+ () => parseBrowserManifest({ version: 4, network_id: "test", endpoints: [endpoint] }),
+ /no payment network/,
+ );
+ assert.throws(
+ () =>
+ parseBrowserManifest({
+ version: 4,
+ network_id: "test",
+ endpoints: [endpoint],
+ payment: { ...paymentNetwork(), rpc_url: "file:///tmp/anvil" },
+ }),
+ /HTTP or HTTPS/,
+ );
+});
+
+function paymentNetwork() {
+ return {
+ rpc_url: "http://127.0.0.1:8545/",
+ payment_token_address: `0x${"11".repeat(20)}`,
+ payment_vault_address: `0x${"22".repeat(20)}`,
+ };
+}
+
function webtransportMultiaddr(peerId, certificateByte) {
const multihash = Uint8Array.from([
0x12,
diff --git a/web/src/payment.js b/web/src/payment.js
new file mode 100644
index 00000000..7f61c032
--- /dev/null
+++ b/web/src/payment.js
@@ -0,0 +1,306 @@
+import { blake3 } from "@noble/hashes/blake3.js";
+import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
+import { decode } from "@msgpack/msgpack";
+import {
+ Contract,
+ JsonRpcProvider,
+ keccak256,
+ MaxUint256,
+ NonceManager,
+ Wallet,
+} from "ethers";
+import { bytesToHex, hexToBytes } from "./protocol.js";
+
+const U256_MAX = (1n << 256n) - 1n;
+const PAYMENT_MULTIPLIER = 3n;
+const PRICE_BASELINE_WEI = 3_906_250_000_000_000n;
+const PRICE_COEFFICIENT_WEI = 35_156_250_000_000_000n;
+const DIVISOR_SQUARED = 6_000n * 6_000n;
+const MAX_COMMITMENT_KEY_COUNT = 1_000_000;
+const MAX_COMMITMENT_SIDECAR_BYTES = 8 * 1024;
+const DOMAIN_COMMITMENT = new TextEncoder().encode(
+ "autonomi.ant.replication.storage_commitment.v1",
+);
+const DOMAIN_COMMITMENT_HASH = new TextEncoder().encode(
+ "autonomi.ant.replication.commitment_hash.v1",
+);
+
+const TOKEN_ABI = [
+ "function allowance(address owner, address spender) view returns (uint256)",
+ "function approve(address spender, uint256 amount) returns (bool)",
+];
+const VAULT_ABI = [
+ "function payForQuotes((address rewardsAddress,uint256 amount,bytes32 quoteHash)[] payments)",
+];
+
+function concatBytes(...parts) {
+ const size = parts.reduce((total, part) => total + part.length, 0);
+ const output = new Uint8Array(size);
+ let offset = 0;
+ for (const part of parts) {
+ output.set(part, offset);
+ offset += part.length;
+ }
+ return output;
+}
+
+function unsignedLittleEndian(value, length) {
+ let remaining = BigInt(value);
+ if (remaining < 0n || remaining >= 1n << BigInt(length * 8)) {
+ throw new Error(`Unsigned integer does not fit ${length} bytes`);
+ }
+ const result = new Uint8Array(length);
+ for (let index = 0; index < length; index += 1) {
+ result[index] = Number(remaining & 0xffn);
+ remaining >>= 8n;
+ }
+ return result;
+}
+
+function postcardVarint(value) {
+ let remaining = BigInt(value);
+ const result = [];
+ do {
+ let byte = Number(remaining & 0x7fn);
+ remaining >>= 7n;
+ if (remaining > 0n) byte |= 0x80;
+ result.push(byte);
+ } while (remaining > 0n);
+ return Uint8Array.from(result);
+}
+
+function canonicalQuoteBytes(quote) {
+ const content = hexToBytes(quote.content, 32);
+ const rewardsAddress = hexToBytes(quote.rewards_address, 20);
+ const price = parseAmount(quote.price, "quote price");
+ const timestamp = parseAmount(String(quote.timestamp_secs), "quote timestamp");
+ const count = quote.committed_key_count;
+ if (!Number.isSafeInteger(count) || count < 0 || count > MAX_COMMITMENT_KEY_COUNT) {
+ throw new Error(`Invalid committed key count ${count}`);
+ }
+ let pin = Uint8Array.of(0);
+ if (quote.commitment_pin !== null && quote.commitment_pin !== undefined) {
+ pin = concatBytes(Uint8Array.of(1), hexToBytes(quote.commitment_pin, 32));
+ }
+ return concatBytes(
+ content,
+ unsignedLittleEndian(timestamp, 8),
+ unsignedLittleEndian(price, 32),
+ rewardsAddress,
+ unsignedLittleEndian(BigInt(count), 4),
+ pin,
+ );
+}
+
+export function paymentQuoteHash(signedBytes, publicKey, signature) {
+ // This is the EVM-facing PaymentQuote hash settled by the payment vault.
+ // Native evmlib deliberately uses Keccak-256 here, while ANT identities,
+ // chunk addresses, and commitment pins use BLAKE3.
+ return keccak256(concatBytes(signedBytes, publicKey, signature)).slice(2);
+}
+
+function parseAmount(value, label) {
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) {
+ throw new Error(`Invalid ${label}`);
+ }
+ const amount = BigInt(value);
+ if (amount > U256_MAX) throw new Error(`${label} exceeds uint256`);
+ return amount;
+}
+
+function calculatePrice(keyCount) {
+ const count = BigInt(keyCount);
+ return PRICE_BASELINE_WEI + (count * count * PRICE_COEFFICIENT_WEI) / DIVISOR_SQUARED;
+}
+
+function equalBytes(left, right) {
+ if (left.length !== right.length) return false;
+ return left.every((byte, index) => byte === right[index]);
+}
+
+function verifyEncodedCommitment(commitment, normalized) {
+ const encoded = hexToBytes(commitment.encoded);
+ if (encoded.length > MAX_COMMITMENT_SIDECAR_BYTES) {
+ throw new Error("Storage commitment sidecar exceeds the protocol limit");
+ }
+ let fields;
+ try {
+ fields = decode(encoded);
+ } catch (error) {
+ throw new Error(`Storage commitment sidecar is not valid MessagePack: ${error.message}`, {
+ cause: error,
+ });
+ }
+ if (!Array.isArray(fields) || fields.length !== 5) {
+ throw new Error("Storage commitment sidecar has an invalid native shape");
+ }
+ const [root, keyCount, peerId, publicKey, signature] = fields;
+ if (
+ keyCount !== normalized.keyCount ||
+ !equalBytes(fixedCommitmentBytes(root, 32), normalized.root) ||
+ !equalBytes(fixedCommitmentBytes(peerId, 32), normalized.peerId) ||
+ !equalBytes(fixedCommitmentBytes(publicKey), normalized.publicKey) ||
+ !equalBytes(fixedCommitmentBytes(signature), normalized.signature)
+ ) {
+ throw new Error("Storage commitment sidecar differs from the verified commitment");
+ }
+}
+
+function fixedCommitmentBytes(value, length) {
+ const bytes = value instanceof Uint8Array ? value : Uint8Array.from(value ?? []);
+ if (length !== undefined && bytes.length !== length) {
+ throw new Error(`Storage commitment field must contain ${length} bytes`);
+ }
+ return bytes;
+}
+
+function verifyCommitment(commitment, quote) {
+ if (!commitment || typeof commitment !== "object") {
+ throw new Error("Bound quote omitted its storage commitment");
+ }
+ const root = hexToBytes(commitment.root, 32);
+ const peerId = hexToBytes(commitment.sender_peer_id, 32);
+ const publicKey = hexToBytes(commitment.sender_public_key);
+ const signature = hexToBytes(commitment.signature);
+ const keyCount = commitment.key_count;
+ if (
+ publicKey.length !== ml_dsa65.lengths.publicKey ||
+ signature.length !== ml_dsa65.lengths.signature
+ ) {
+ throw new Error("Storage commitment has invalid ML-DSA-65 field lengths");
+ }
+ if (!Number.isSafeInteger(keyCount) || keyCount !== quote.committed_key_count) {
+ throw new Error("Storage commitment key count does not match quote");
+ }
+ if (bytesToHex(blake3(publicKey)) !== quote.peer_id.toLowerCase()) {
+ throw new Error("Storage commitment public key is not bound to quote peer");
+ }
+ if (bytesToHex(peerId) !== quote.peer_id.toLowerCase()) {
+ throw new Error("Storage commitment belongs to a different peer");
+ }
+ verifyEncodedCommitment(commitment, {
+ root,
+ keyCount,
+ peerId,
+ publicKey,
+ signature,
+ });
+ const signedPayload = concatBytes(
+ root,
+ unsignedLittleEndian(BigInt(keyCount), 4),
+ peerId,
+ unsignedLittleEndian(BigInt(publicKey.length), 4),
+ publicKey,
+ );
+ if (
+ !ml_dsa65.verify(signature, signedPayload, publicKey, {
+ context: DOMAIN_COMMITMENT,
+ })
+ ) {
+ throw new Error("Storage commitment has an invalid ML-DSA-65 signature");
+ }
+
+ const postcard = concatBytes(
+ root,
+ postcardVarint(keyCount),
+ peerId,
+ postcardVarint(publicKey.length),
+ publicKey,
+ postcardVarint(signature.length),
+ signature,
+ );
+ const pin = bytesToHex(blake3(concatBytes(DOMAIN_COMMITMENT_HASH, postcard)));
+ if (pin !== quote.commitment_pin.toLowerCase()) {
+ throw new Error("Storage commitment does not resolve the quote pin");
+ }
+ return true;
+}
+
+export function verifyStorageQuote(quote, expectedAddress, expectedPeerId) {
+ if (!quote || typeof quote !== "object") throw new Error("Node returned no quote");
+ hexToBytes(expectedAddress, 32);
+ hexToBytes(expectedPeerId, 32);
+ if (quote.content?.toLowerCase() !== expectedAddress.toLowerCase()) {
+ throw new Error("Storage quote is for a different chunk");
+ }
+ if (quote.peer_id?.toLowerCase() !== expectedPeerId.toLowerCase()) {
+ throw new Error("Storage quote belongs to a different WebTransport peer");
+ }
+ const publicKey = hexToBytes(quote.public_key);
+ const signature = hexToBytes(quote.signature);
+ if (publicKey.length !== ml_dsa65.lengths.publicKey) {
+ throw new Error(`Storage quote has a ${publicKey.length}-byte public key`);
+ }
+ if (signature.length !== ml_dsa65.lengths.signature) {
+ throw new Error(`Storage quote has a ${signature.length}-byte signature`);
+ }
+ if (bytesToHex(blake3(publicKey)) !== quote.peer_id.toLowerCase()) {
+ throw new Error("Storage quote public key is not bound to its peer ID");
+ }
+ const signedBytes = canonicalQuoteBytes(quote);
+ if (!ml_dsa65.verify(signature, signedBytes, publicKey)) {
+ throw new Error("Storage quote has an invalid ML-DSA-65 signature");
+ }
+ const quoteHash = paymentQuoteHash(signedBytes, publicKey, signature);
+ if (quoteHash !== quote.quote_hash?.toLowerCase()) {
+ throw new Error("Storage quote hash does not match its signed fields");
+ }
+ const price = parseAmount(quote.price, "quote price");
+ if (price !== calculatePrice(quote.committed_key_count)) {
+ throw new Error("Storage quote price is not bound to its committed key count");
+ }
+ if (quote.committed_key_count === 0) {
+ if (quote.commitment_pin !== null || quote.commitment !== null) {
+ throw new Error("Baseline storage quote has an incoherent commitment");
+ }
+ } else {
+ if (!quote.commitment_pin) throw new Error("Bound storage quote omitted its pin");
+ verifyCommitment(quote.commitment, quote);
+ }
+ hexToBytes(quote.rewards_address, 20);
+ return {
+ quote,
+ quoteHash,
+ rewardsAddress: `0x${quote.rewards_address.replace(/^0x/i, "")}`,
+ amount: price * PAYMENT_MULTIPLIER,
+ };
+}
+
+export async function payForStorageQuotes(
+ paymentNetwork,
+ verifiedQuotes,
+ walletSecret,
+ { onProgress = () => {} } = {},
+) {
+ if (!Array.isArray(verifiedQuotes) || verifiedQuotes.length === 0) {
+ return { transactionHash: undefined, walletAddress: undefined, totalAmount: 0n };
+ }
+ const provider = new JsonRpcProvider(paymentNetwork.rpc_url);
+ let wallet;
+ try {
+ wallet = new Wallet(walletSecret, provider);
+ } catch (error) {
+ throw new Error("Wallet secret key is invalid", { cause: error });
+ }
+ const signer = new NonceManager(wallet);
+ const totalAmount = verifiedQuotes.reduce((total, quote) => total + quote.amount, 0n);
+ const token = new Contract(paymentNetwork.payment_token_address, TOKEN_ABI, signer);
+ const vault = new Contract(paymentNetwork.payment_vault_address, VAULT_ABI, signer);
+ const allowance = await token.allowance(wallet.address, paymentNetwork.payment_vault_address);
+ if (allowance < totalAmount) {
+ onProgress(`Approving the payment vault from wallet ${wallet.address}`);
+ const approval = await token.approve(paymentNetwork.payment_vault_address, MaxUint256);
+ await approval.wait();
+ }
+ const payments = verifiedQuotes.map((quote) => ({
+ rewardsAddress: quote.rewardsAddress,
+ amount: quote.amount,
+ quoteHash: `0x${quote.quoteHash}`,
+ }));
+ onProgress(`Submitting one payment for ${payments.length} storage quote(s)`);
+ const transaction = await vault.payForQuotes(payments);
+ const receipt = await transaction.wait();
+ if (!receipt || receipt.status !== 1) throw new Error("Storage payment transaction reverted");
+ onProgress(`Payment confirmed in ${transaction.hash}`);
+ return { transactionHash: transaction.hash, walletAddress: wallet.address, totalAmount };
+}
diff --git a/web/src/payment.test.js b/web/src/payment.test.js
new file mode 100644
index 00000000..bdb7d1d7
--- /dev/null
+++ b/web/src/payment.test.js
@@ -0,0 +1,235 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { blake3 } from "@noble/hashes/blake3.js";
+import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
+import { encode } from "@msgpack/msgpack";
+import { keccak256 } from "ethers";
+import { bytesToHex } from "./protocol.js";
+import { paymentQuoteHash, verifyStorageQuote } from "./payment.js";
+
+const BASELINE_PRICE = 3_906_250_000_000_000n;
+const PRICE_COEFFICIENT = 35_156_250_000_000_000n;
+const COMMITMENT_CONTEXT = new TextEncoder().encode(
+ "autonomi.ant.replication.storage_commitment.v1",
+);
+const COMMITMENT_HASH_DOMAIN = new TextEncoder().encode(
+ "autonomi.ant.replication.commitment_hash.v1",
+);
+
+function concatBytes(...parts) {
+ const output = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
+ let offset = 0;
+ for (const part of parts) {
+ output.set(part, offset);
+ offset += part.length;
+ }
+ return output;
+}
+
+function littleEndian(value, length) {
+ let remaining = BigInt(value);
+ const result = new Uint8Array(length);
+ for (let index = 0; index < length; index += 1) {
+ result[index] = Number(remaining & 0xffn);
+ remaining >>= 8n;
+ }
+ return result;
+}
+
+function postcardVarint(value) {
+ let remaining = BigInt(value);
+ const bytes = [];
+ do {
+ let byte = Number(remaining & 0x7fn);
+ remaining >>= 7n;
+ if (remaining > 0n) byte |= 0x80;
+ bytes.push(byte);
+ } while (remaining > 0n);
+ return Uint8Array.from(bytes);
+}
+
+function signedBaselineQuote() {
+ const content = Uint8Array.from({ length: 32 }, (_, index) => index);
+ const rewards = new Uint8Array(20).fill(0x44);
+ const timestamp = 1_775_000_000n;
+ const { publicKey, secretKey } = ml_dsa65.keygen(new Uint8Array(32).fill(0x17));
+ const payload = concatBytes(
+ content,
+ littleEndian(timestamp, 8),
+ littleEndian(BASELINE_PRICE, 32),
+ rewards,
+ littleEndian(0n, 4),
+ Uint8Array.of(0),
+ );
+ const signature = ml_dsa65.sign(payload, secretKey);
+ const quoteHash = keccak256(concatBytes(payload, publicKey, signature)).slice(2);
+ const peerId = bytesToHex(blake3(publicKey));
+ return {
+ peerId,
+ address: bytesToHex(content),
+ quote: {
+ peer_id: peerId,
+ content: bytesToHex(content),
+ timestamp_secs: Number(timestamp),
+ price: BASELINE_PRICE.toString(),
+ rewards_address: bytesToHex(rewards),
+ public_key: bytesToHex(publicKey),
+ signature: bytesToHex(signature),
+ committed_key_count: 0,
+ commitment_pin: null,
+ quote_hash: quoteHash,
+ commitment: null,
+ },
+ };
+}
+
+function signedBoundQuote() {
+ const content = new Uint8Array(32).fill(0x31);
+ const rewards = new Uint8Array(20).fill(0x42);
+ const root = new Uint8Array(32).fill(0x53);
+ const keyCount = 23;
+ const timestamp = 1_775_000_001n;
+ const { publicKey, secretKey } = ml_dsa65.keygen(new Uint8Array(32).fill(0x29));
+ const peerId = blake3(publicKey);
+ const commitmentPayload = concatBytes(
+ root,
+ littleEndian(keyCount, 4),
+ peerId,
+ littleEndian(publicKey.length, 4),
+ publicKey,
+ );
+ const commitmentSignature = ml_dsa65.sign(commitmentPayload, secretKey, {
+ context: COMMITMENT_CONTEXT,
+ });
+ const postcard = concatBytes(
+ root,
+ postcardVarint(keyCount),
+ peerId,
+ postcardVarint(publicKey.length),
+ publicKey,
+ postcardVarint(commitmentSignature.length),
+ commitmentSignature,
+ );
+ const pin = blake3(concatBytes(COMMITMENT_HASH_DOMAIN, postcard));
+ const price =
+ BASELINE_PRICE +
+ (BigInt(keyCount) * BigInt(keyCount) * PRICE_COEFFICIENT) / (6_000n * 6_000n);
+ const quotePayload = concatBytes(
+ content,
+ littleEndian(timestamp, 8),
+ littleEndian(price, 32),
+ rewards,
+ littleEndian(keyCount, 4),
+ Uint8Array.of(1),
+ pin,
+ );
+ const quoteSignature = ml_dsa65.sign(quotePayload, secretKey);
+ const quoteHash = keccak256(
+ concatBytes(quotePayload, publicKey, quoteSignature),
+ ).slice(2);
+ const encodedCommitment = encode([
+ Array.from(root),
+ keyCount,
+ Array.from(peerId),
+ Array.from(publicKey),
+ Array.from(commitmentSignature),
+ ]);
+ const commitment = {
+ encoded: bytesToHex(encodedCommitment),
+ root: bytesToHex(root),
+ key_count: keyCount,
+ sender_peer_id: bytesToHex(peerId),
+ sender_public_key: bytesToHex(publicKey),
+ signature: bytesToHex(commitmentSignature),
+ };
+ return {
+ address: bytesToHex(content),
+ peerId: bytesToHex(peerId),
+ quote: {
+ peer_id: bytesToHex(peerId),
+ content: bytesToHex(content),
+ timestamp_secs: Number(timestamp),
+ price: price.toString(),
+ rewards_address: bytesToHex(rewards),
+ public_key: bytesToHex(publicKey),
+ signature: bytesToHex(quoteSignature),
+ committed_key_count: keyCount,
+ commitment_pin: bytesToHex(pin),
+ quote_hash: quoteHash,
+ commitment,
+ },
+ };
+}
+
+test("accepts a correctly bound and signed native-shaped storage quote", () => {
+ const { quote, address, peerId } = signedBaselineQuote();
+ const verified = verifyStorageQuote(quote, address, peerId);
+ assert.equal(verified.quoteHash, quote.quote_hash);
+ assert.equal(verified.amount, BASELINE_PRICE * 3n);
+ assert.equal(verified.rewardsAddress, `0x${quote.rewards_address}`);
+});
+
+test("uses evmlib's Keccak-256 PaymentQuote hash", () => {
+ // Fixed native hash-vector input split as bytes-for-signing, public key,
+ // and signature. PaymentQuote::hash() concatenates these exact byte slices.
+ assert.equal(
+ paymentQuoteHash(Uint8Array.of(0, 1), Uint8Array.of(2), Uint8Array.of(3)),
+ "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f",
+ );
+});
+
+test("rejects quote field tampering before any payment", () => {
+ const fixture = signedBaselineQuote();
+ assert.throws(
+ () =>
+ verifyStorageQuote(
+ { ...fixture.quote, price: (BASELINE_PRICE + 1n).toString() },
+ fixture.address,
+ fixture.peerId,
+ ),
+ /invalid ML-DSA-65 signature|price/,
+ );
+ assert.throws(
+ () => verifyStorageQuote(fixture.quote, fixture.address, "ff".repeat(32)),
+ /different WebTransport peer/,
+ );
+ const signature = Uint8Array.from(Buffer.from(fixture.quote.signature, "hex"));
+ signature[100] ^= 1;
+ assert.throws(
+ () =>
+ verifyStorageQuote(
+ { ...fixture.quote, signature: bytesToHex(signature) },
+ fixture.address,
+ fixture.peerId,
+ ),
+ /invalid ML-DSA-65 signature/,
+ );
+});
+
+test("verifies a bound commitment and the exact native sidecar before payment", () => {
+ const fixture = signedBoundQuote();
+ assert.doesNotThrow(() =>
+ verifyStorageQuote(fixture.quote, fixture.address, fixture.peerId),
+ );
+
+ const decodedShape = [
+ Array(32).fill(0x99),
+ fixture.quote.committed_key_count,
+ Array.from(Buffer.from(fixture.quote.commitment.sender_peer_id, "hex")),
+ Array.from(Buffer.from(fixture.quote.commitment.sender_public_key, "hex")),
+ Array.from(Buffer.from(fixture.quote.commitment.signature, "hex")),
+ ];
+ const mismatchedSidecar = {
+ ...fixture.quote.commitment,
+ encoded: bytesToHex(encode(decodedShape)),
+ };
+ assert.throws(
+ () =>
+ verifyStorageQuote(
+ { ...fixture.quote, commitment: mismatchedSidecar },
+ fixture.address,
+ fixture.peerId,
+ ),
+ /sidecar differs/,
+ );
+});
diff --git a/web/src/protocol.js b/web/src/protocol.js
index d6ee7988..b439c394 100644
--- a/web/src/protocol.js
+++ b/web/src/protocol.js
@@ -1,8 +1,8 @@
import { blake3 } from "@noble/hashes/blake3.js";
import { bytesToHex as nobleBytesToHex } from "@noble/hashes/utils.js";
-export const PROTOCOL_VERSION = 2;
-export const PROTOCOL_NAME = "autonomi.web.poc.v2";
+export const PROTOCOL_VERSION = 3;
+export const PROTOCOL_NAME = "autonomi.web.poc.v3";
export const WEBTRANSPORT_PATH = "/autonomi/webtransport/v1";
export const MAX_CHUNK_SIZE = 4 * 1024 * 1024;
export const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
@@ -289,23 +289,33 @@ export class BrowserNodeClient {
this.transport = transport;
}
- async request(type, fields = {}) {
+ async request(type, fields = {}, content = new Uint8Array()) {
await this.connect();
+ if (!(content instanceof Uint8Array) || content.length > MAX_CHUNK_SIZE) {
+ throw new Error(`Request content must be at most ${MAX_CHUNK_SIZE} bytes`);
+ }
const requestId = nextRequestId;
nextRequestId += 1;
const stream = await this.transport.createBidirectionalStream();
const writer = stream.writable.getWriter();
try {
- await writer.write(
- encoder.encode(
- JSON.stringify({
- version: PROTOCOL_VERSION,
- request_id: requestId,
- type,
- ...fields,
- }),
- ),
+ const header = encoder.encode(
+ JSON.stringify({
+ version: PROTOCOL_VERSION,
+ request_id: requestId,
+ content_length: content.length,
+ type,
+ ...fields,
+ }),
);
+ if (header.length === 0 || header.length > MAX_RESPONSE_HEADER_BYTES) {
+ throw new Error(`Request header is ${header.length} bytes`);
+ }
+ const prefix = new Uint8Array(4);
+ new DataView(prefix.buffer).setUint32(0, header.length, false);
+ await writer.write(prefix);
+ await writer.write(header);
+ if (content.length > 0) await writer.write(content);
await writer.close();
} finally {
writer.releaseLock();
@@ -394,6 +404,39 @@ export class BrowserNodeClient {
return { content: response.content, hash };
}
+ async quoteChunk(address, size) {
+ hexToBytes(address, 32);
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_CHUNK_SIZE) {
+ throw new Error(`Invalid chunk size ${size}`);
+ }
+ const { header } = await this.request("quote_chunk", { address, size });
+ if (header.type !== "storage_quote") {
+ throw new Error("Expected a STORAGE_QUOTE response");
+ }
+ if (header.address.toLowerCase() !== address.toLowerCase()) {
+ throw new Error("Node returned a quote for a different chunk address");
+ }
+ return { quote: header.quote, alreadyStored: Boolean(header.already_stored) };
+ }
+
+ async putChunk(address, content, quote, transactionHash) {
+ hexToBytes(address, 32);
+ hexToBytes(transactionHash, 32);
+ verifyChunk(address, content);
+ const { header } = await this.request(
+ "put_chunk",
+ { address, quote, transaction_hash: transactionHash },
+ content,
+ );
+ if (header.type !== "chunk_stored") {
+ throw new Error("Expected a CHUNK_STORED response");
+ }
+ if (header.address.toLowerCase() !== address.toLowerCase()) {
+ throw new Error("Node stored a different chunk address");
+ }
+ return { address: header.address, alreadyStored: Boolean(header.already_stored) };
+ }
+
close() {
this.transport?.close({ closeCode: 0, reason: "client closed" });
this.transport = undefined;
diff --git a/web/src/protocol.test.js b/web/src/protocol.test.js
index 7dbfc4e2..833ca2a8 100644
--- a/web/src/protocol.test.js
+++ b/web/src/protocol.test.js
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { blake3 } from "@noble/hashes/blake3.js";
import {
+ BrowserNodeClient,
bytesToHex,
getChunkFromClosest,
hexToBytes,
@@ -27,7 +28,7 @@ test("XOR distance is an unsigned 256-bit ordering value", () => {
test("response framing preserves a raw binary body", () => {
const header = new TextEncoder().encode(
JSON.stringify({
- version: 2,
+ version: 3,
request_id: 9,
status: "ok",
content_length: 3,
@@ -143,6 +144,44 @@ test("browser lookup discovers a direct node and downloads a verified chunk", as
]);
});
+test("paid PUT frames the encrypted chunk as a binary request body", async (t) => {
+ const content = new TextEncoder().encode("encrypted record");
+ const address = bytesToHex(blake3(content));
+ const node = endpoint("storage.test", "33".repeat(32), 0x44);
+ const transactionHash = "55".repeat(32);
+ const quote = { quote_hash: "66".repeat(32) };
+ let observed;
+ const routes = new Map([
+ [
+ node.url,
+ (request) => {
+ if (request.type === "put_chunk") {
+ observed = request;
+ return response(request, {
+ type: "chunk_stored",
+ address,
+ already_stored: false,
+ });
+ }
+ throw new Error(`Unexpected request ${request.type}`);
+ },
+ ],
+ ]);
+ const previousWebTransport = globalThis.WebTransport;
+ globalThis.WebTransport = mockWebTransport(routes);
+ t.after(() => {
+ globalThis.WebTransport = previousWebTransport;
+ });
+
+ const client = new BrowserNodeClient(node.multiaddr);
+ const stored = await client.putChunk(address, content, quote, transactionHash);
+ assert.deepEqual(stored, { address, alreadyStored: false });
+ assert.equal(observed.content_length, content.length);
+ assert.deepEqual(observed.content, content);
+ assert.deepEqual(observed.quote, quote);
+ assert.equal(observed.transaction_hash, transactionHash);
+});
+
function browserNode(endpoint) {
return {
peer_id: endpoint.peer_id,
@@ -157,7 +196,7 @@ function browserNode(endpoint) {
function helloResponse(request, endpoint) {
return response(request, {
type: "hello",
- protocol: "autonomi.web.poc.v2",
+ protocol: "autonomi.web.poc.v3",
peer_id: endpoint.peer_id,
max_chunk_size: 4 * 1024 * 1024,
endpoint: {
@@ -170,7 +209,7 @@ function helloResponse(request, endpoint) {
function response(request, fields, content = new Uint8Array()) {
return {
header: {
- version: 2,
+ version: 3,
request_id: request.request_id,
status: "ok",
content_length: content.length,
@@ -240,7 +279,20 @@ function mockWebTransport(routes) {
encoded.set(chunk, offset);
offset += chunk.length;
}
- const request = JSON.parse(new TextDecoder().decode(encoded));
+ if (encoded.length < 4) throw new Error("Request omitted its frame prefix");
+ const headerLength = new DataView(
+ encoded.buffer,
+ encoded.byteOffset,
+ encoded.byteLength,
+ ).getUint32(0, false);
+ const contentOffset = 4 + headerLength;
+ const request = JSON.parse(
+ new TextDecoder().decode(encoded.subarray(4, contentOffset)),
+ );
+ request.content = encoded.slice(contentOffset);
+ if (request.content.length !== request.content_length) {
+ throw new Error("Request content length mismatch");
+ }
const handler = routes.get(this.url);
responseController.enqueue(encodeResponse(handler(request)));
responseController.close();
diff --git a/web/src/style.css b/web/src/style.css
index d65ff0ae..301225fb 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -48,6 +48,14 @@ h2 {
font-size: 0.88rem;
}
+.secret-note {
+ padding: 0.75rem 0.85rem;
+ border-left: 3px solid #a76b16;
+ color: #71501e;
+ background: #fff8e9;
+ font-size: 0.84rem;
+}
+
.eyebrow {
color: #25764a;
font-size: 0.78rem;
@@ -209,6 +217,11 @@ a {
color: #a6b5aa;
}
+ .secret-note {
+ color: #efd19e;
+ background: #2c2519;
+ }
+
.file-card {
background: #111713;
}
diff --git a/web/src/upload.js b/web/src/upload.js
new file mode 100644
index 00000000..5cc234b6
--- /dev/null
+++ b/web/src/upload.js
@@ -0,0 +1,326 @@
+import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
+import { blake3 } from "@noble/hashes/blake3.js";
+import { decode, encode } from "@msgpack/msgpack";
+import { deriveChunkMaterial } from "./file.js";
+import {
+ BrowserNodeClient,
+ bytesToHex,
+ hexToBytes,
+ iterativeFindClosest,
+} from "./protocol.js";
+import { payForStorageQuotes, verifyStorageQuote } from "./payment.js";
+
+export const MAX_BROWSER_UPLOAD_BYTES = 64 * 1024 * 1024;
+const SELF_ENCRYPTION_MAX_CHUNK_SIZE = 4_190_208;
+const MAX_STORE_TARGETS = 7;
+
+function numberOfChunks(fileSize) {
+ if (fileSize < 3) return 0;
+ if (fileSize < 3 * SELF_ENCRYPTION_MAX_CHUNK_SIZE) return 3;
+ return Math.ceil(fileSize / SELF_ENCRYPTION_MAX_CHUNK_SIZE);
+}
+
+function chunkSize(fileSize, index) {
+ if (fileSize < 3 * SELF_ENCRYPTION_MAX_CHUNK_SIZE) {
+ return index < 2 ? Math.floor(fileSize / 3) : fileSize - 2 * Math.floor(fileSize / 3);
+ }
+ const count = numberOfChunks(fileSize);
+ const remainder = fileSize % SELF_ENCRYPTION_MAX_CHUNK_SIZE;
+ if (index < count - 2 || remainder === 0) return SELF_ENCRYPTION_MAX_CHUNK_SIZE;
+ return index === count - 2 ? SELF_ENCRYPTION_MAX_CHUNK_SIZE : remainder;
+}
+
+function chunkStart(fileSize, index) {
+ const count = numberOfChunks(fileSize);
+ if (index === count - 1) {
+ return chunkSize(fileSize, 0) * (index - 1) + chunkSize(fileSize, index - 1);
+ }
+ return chunkSize(fileSize, 0) * index;
+}
+
+async function defaultCompress(input) {
+ const { default: brotliPromise } = await import("brotli-wasm");
+ const brotli = await brotliPromise;
+ return brotli.compress(input, { quality: 6 });
+}
+
+function xorPad(content, pad) {
+ const output = new Uint8Array(content.length);
+ for (let index = 0; index < content.length; index += 1) {
+ output[index] = content[index] ^ pad[index % pad.length];
+ }
+ return output;
+}
+
+export function encodePublicDataMap(chunks) {
+ const compact = [
+ 1,
+ chunks.map((chunk) => [
+ chunk.index,
+ Array.from(hexToBytes(chunk.dst_hash, 32)),
+ Array.from(hexToBytes(chunk.src_hash, 32)),
+ chunk.src_size,
+ ]),
+ null,
+ ];
+ return encode(compact, { sortKeys: false });
+}
+
+function fixedBytes(value, length, label) {
+ const bytes = value instanceof Uint8Array ? value : Uint8Array.from(value ?? []);
+ if (bytes.length !== length) throw new Error(`${label} must contain ${length} bytes`);
+ return bytes;
+}
+
+export function decodePublicDataMap(content) {
+ let dataMap;
+ try {
+ dataMap = decode(content);
+ } catch (error) {
+ throw new Error(`Public DataMap is not valid MessagePack: ${error.message}`, {
+ cause: error,
+ });
+ }
+ if (!Array.isArray(dataMap) || dataMap.length !== 3 || dataMap[0] !== 1) {
+ throw new Error("Public DataMap does not use self_encryption version 1");
+ }
+ if (dataMap[2] !== null) {
+ throw new Error("Nested DataMaps are not yet supported by the browser uploader");
+ }
+ if (!Array.isArray(dataMap[1]) || dataMap[1].length < 3) {
+ throw new Error("Public DataMap has fewer than three chunks");
+ }
+ const chunks = dataMap[1]
+ .map((chunk) => {
+ if (!Array.isArray(chunk) || chunk.length !== 4) {
+ throw new Error("Public DataMap contains an invalid chunk descriptor");
+ }
+ const [index, dstHash, srcHash, srcSize] = chunk;
+ if (!Number.isSafeInteger(index) || index < 0) {
+ throw new Error(`Invalid DataMap chunk index ${index}`);
+ }
+ if (!Number.isSafeInteger(srcSize) || srcSize < 1) {
+ throw new Error(`Invalid DataMap plaintext chunk size ${srcSize}`);
+ }
+ return {
+ index,
+ dst_hash: bytesToHex(fixedBytes(dstHash, 32, "DataMap destination hash")),
+ src_hash: bytesToHex(fixedBytes(srcHash, 32, "DataMap source hash")),
+ src_size: srcSize,
+ };
+ })
+ .sort((left, right) => left.index - right.index);
+ chunks.forEach((chunk, index) => {
+ if (chunk.index !== index) throw new Error("DataMap chunk indices are not contiguous");
+ });
+ return chunks;
+}
+
+export async function encryptPublicFile(
+ content,
+ { name = "upload.bin", contentType = "application/octet-stream", compress = defaultCompress } = {},
+) {
+ if (!(content instanceof Uint8Array)) throw new Error("Upload content must be bytes");
+ if (content.length < 3) throw new Error("Self-encryption requires a file of at least 3 bytes");
+ if (content.length > MAX_BROWSER_UPLOAD_BYTES) {
+ throw new Error(`Browser uploads are limited to ${MAX_BROWSER_UPLOAD_BYTES} bytes`);
+ }
+ const count = numberOfChunks(content.length);
+ const plaintextChunks = Array.from({ length: count }, (_, index) => {
+ const start = chunkStart(content.length, index);
+ return content.slice(start, start + chunkSize(content.length, index));
+ });
+ const sourceHashes = plaintextChunks.map((chunk) => blake3(chunk));
+ const encrypted = await Promise.all(
+ plaintextChunks.map(async (plaintext, index) => {
+ const descriptor = { index };
+ const { pad, key, nonce } = deriveChunkMaterial(descriptor, sourceHashes, 0);
+ const compressed = await compress(plaintext);
+ const ciphertext = chacha20poly1305(key, nonce).encrypt(compressed);
+ const bytes = xorPad(ciphertext, pad);
+ return {
+ content: bytes,
+ info: {
+ index,
+ dst_hash: bytesToHex(blake3(bytes)),
+ src_hash: bytesToHex(sourceHashes[index]),
+ src_size: plaintext.length,
+ },
+ };
+ }),
+ );
+ const chunks = encrypted.map(({ info }) => info);
+ const dataMap = encodePublicDataMap(chunks);
+ const address = bytesToHex(blake3(dataMap));
+ const records = encrypted.map(({ content: bytes, info }) => ({
+ address: info.dst_hash,
+ content: bytes,
+ }));
+ records.push({ address, content: dataMap });
+ return {
+ descriptor: {
+ name,
+ address,
+ size: content.length,
+ content_type: contentType || "application/octet-stream",
+ blake3: bytesToHex(blake3(content)),
+ data_map_size: dataMap.length,
+ chunks,
+ replicas: 0,
+ },
+ records,
+ };
+}
+
+function closeClients(clients) {
+ for (const client of clients.values()) client.close();
+}
+
+function assertUploadNode(hello, paymentNetwork) {
+ if (
+ !Array.isArray(hello.capabilities) ||
+ !hello.capabilities.includes("quote_chunk") ||
+ !hello.capabilities.includes("put_chunk")
+ ) {
+ throw new Error("Node does not advertise paid browser uploads");
+ }
+ const advertised = hello.payment;
+ if (
+ !advertised ||
+ new URL(advertised.rpc_url).toString() !== new URL(paymentNetwork.rpc_url).toString() ||
+ advertised.payment_token_address?.toLowerCase() !==
+ paymentNetwork.payment_token_address.toLowerCase() ||
+ advertised.payment_vault_address?.toLowerCase() !==
+ paymentNetwork.payment_vault_address.toLowerCase()
+ ) {
+ throw new Error("Node advertises a different payment network than the manifest");
+ }
+}
+
+async function prepareRecord(seedEndpoints, paymentNetwork, record, onProgress) {
+ onProgress(`Finding closest nodes for ${record.address}`);
+ const lookup = await iterativeFindClosest(seedEndpoints, record.address, { onProgress });
+ const endpoints = lookup.nodes
+ .filter((node) => node.webtransport)
+ .slice(0, MAX_STORE_TARGETS)
+ .map((node) => ({ peerId: node.peer_id, endpoint: node.webtransport }));
+ try {
+ const failures = [];
+ for (const target of endpoints) {
+ const client = new BrowserNodeClient(target.endpoint);
+ try {
+ assertUploadNode(await client.hello(), paymentNetwork);
+ const response = await client.quoteChunk(record.address, record.content.length);
+ const verified = verifyStorageQuote(
+ response.quote,
+ record.address,
+ target.peerId,
+ );
+ if (response.alreadyStored) {
+ onProgress(`Chunk ${record.address} is already stored; skipping payment`);
+ return { record, alreadyStored: true, targets: endpoints };
+ }
+ onProgress(`Verified storage quote ${verified.quoteHash} from ${target.peerId}`);
+ return {
+ record,
+ alreadyStored: false,
+ targets: [target, ...endpoints.filter((candidate) => candidate !== target)],
+ verified,
+ };
+ } catch (error) {
+ failures.push(`${target.peerId}: ${error.message}`);
+ } finally {
+ client.close();
+ }
+ }
+ throw new Error(`No closest node supplied a valid quote (${failures.join("; ")})`);
+ } finally {
+ closeClients(lookup.clients);
+ }
+}
+
+async function storePrepared(prepared, paymentNetwork, transactionHash, onProgress) {
+ if (prepared.alreadyStored) return 1;
+ const attempts = await Promise.allSettled(
+ prepared.targets.map(async (target) => {
+ const client = new BrowserNodeClient(target.endpoint);
+ try {
+ assertUploadNode(await client.hello(), paymentNetwork);
+ const result = await client.putChunk(
+ prepared.record.address,
+ prepared.record.content,
+ prepared.verified.quote,
+ transactionHash,
+ );
+ onProgress(
+ `${result.alreadyStored ? "Confirmed" : "Stored"} ${prepared.record.address} on ${target.peerId}`,
+ );
+ return result;
+ } finally {
+ client.close();
+ }
+ }),
+ );
+ const stored = attempts.filter((attempt) => attempt.status === "fulfilled").length;
+ if (stored === 0) {
+ const failures = attempts
+ .filter((attempt) => attempt.status === "rejected")
+ .map((attempt) => attempt.reason?.message ?? String(attempt.reason));
+ throw new Error(`Paid chunk was rejected by every closest node: ${failures.join("; ")}`);
+ }
+ return stored;
+}
+
+export async function uploadPublicFile(
+ seedEndpoints,
+ paymentNetwork,
+ file,
+ walletSecret,
+ { onProgress = () => {}, compress = defaultCompress } = {},
+) {
+ const content = new Uint8Array(await file.arrayBuffer());
+ onProgress(`Self-encrypting ${file.name} (${content.length.toLocaleString()} bytes)`);
+ const encrypted = await encryptPublicFile(content, {
+ name: file.name,
+ contentType: file.type,
+ compress,
+ });
+ const prepared = [];
+ for (let index = 0; index < encrypted.records.length; index += 1) {
+ onProgress(`Preparing record ${index + 1}/${encrypted.records.length}`);
+ prepared.push(
+ await prepareRecord(seedEndpoints, paymentNetwork, encrypted.records[index], onProgress),
+ );
+ }
+ const payable = prepared.filter((record) => !record.alreadyStored);
+ let transactionHash;
+ let totalAmount = 0n;
+ if (payable.length > 0) {
+ const payment = await payForStorageQuotes(
+ paymentNetwork,
+ payable.map((record) => record.verified),
+ walletSecret,
+ { onProgress },
+ );
+ transactionHash = payment.transactionHash;
+ totalAmount = payment.totalAmount;
+ }
+ let replicas = Number.POSITIVE_INFINITY;
+ for (let index = 0; index < prepared.length; index += 1) {
+ onProgress(`Storing record ${index + 1}/${prepared.length}`);
+ const stored = await storePrepared(
+ prepared[index],
+ paymentNetwork,
+ transactionHash,
+ onProgress,
+ );
+ replicas = Math.min(replicas, stored);
+ }
+ encrypted.descriptor.replicas = Number.isFinite(replicas) ? replicas : 0;
+ return {
+ file: encrypted.descriptor,
+ transactionHash,
+ storageCostAtto: totalAmount.toString(),
+ records: encrypted.records.length,
+ };
+}
diff --git a/web/src/upload.test.js b/web/src/upload.test.js
new file mode 100644
index 00000000..37b12425
--- /dev/null
+++ b/web/src/upload.test.js
@@ -0,0 +1,81 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { brotliCompressSync, brotliDecompressSync, constants } from "node:zlib";
+import { blake3 } from "@noble/hashes/blake3.js";
+import { decryptSelfEncryptedChunk } from "./file.js";
+import { bytesToHex } from "./protocol.js";
+import {
+ decodePublicDataMap,
+ encodePublicDataMap,
+ encryptPublicFile,
+} from "./upload.js";
+
+const compress = (input) =>
+ new Uint8Array(
+ brotliCompressSync(input, {
+ params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
+ }),
+ );
+const decompress = (input) => new Uint8Array(brotliDecompressSync(input));
+
+test("browser encryption reproduces the native self_encryption 0.36 vector", async () => {
+ const content = new TextEncoder().encode("browser whole-file fixture\n".repeat(160));
+ const encrypted = await encryptPublicFile(content, {
+ name: "fixture.txt",
+ contentType: "text/plain",
+ compress,
+ });
+
+ assert.deepEqual(
+ encrypted.descriptor.chunks.map((chunk) => chunk.dst_hash),
+ [
+ "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9",
+ "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a",
+ "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
+ ],
+ );
+ assert.equal(
+ encrypted.descriptor.address,
+ "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b",
+ );
+ assert.deepEqual(
+ decodePublicDataMap(encrypted.records.at(-1).content),
+ encrypted.descriptor.chunks,
+ );
+
+ const sourceHashes = encrypted.descriptor.chunks.map((chunk) => chunk.src_hash);
+ const plaintext = await Promise.all(
+ encrypted.descriptor.chunks.map((chunk, index) =>
+ decryptSelfEncryptedChunk(
+ chunk,
+ encrypted.records[index].content,
+ sourceHashes.map((hash) => Uint8Array.from(Buffer.from(hash, "hex"))),
+ 0,
+ decompress,
+ ),
+ ),
+ );
+ const reconstructed = new Uint8Array(
+ plaintext.reduce((total, chunk) => total + chunk.length, 0),
+ );
+ let offset = 0;
+ for (const chunk of plaintext) {
+ reconstructed.set(chunk, offset);
+ offset += chunk.length;
+ }
+ assert.deepEqual(reconstructed, content);
+ assert.equal(bytesToHex(blake3(reconstructed)), encrypted.descriptor.blake3);
+});
+
+test("public DataMap encoder matches rmp-serde's compact native representation", () => {
+ const chunks = [0, 1, 2].map((index) => ({
+ index,
+ dst_hash: (11 + index).toString(16).padStart(2, "0").repeat(32),
+ src_hash: (21 + index).toString(16).padStart(2, "0").repeat(32),
+ src_size: 100 + index,
+ }));
+ const expected =
+ "9301939400dc00200b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bdc00201515151515151515151515151515151515151515151515151515151515151515649401dc00200c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0cdc00201616161616161616161616161616161616161616161616161616161616161616659402dc00200d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0ddc0020171717171717171717171717171717171717171717171717171717171717171766c0";
+ assert.equal(bytesToHex(encodePublicDataMap(chunks)), expected);
+ assert.deepEqual(decodePublicDataMap(Uint8Array.from(Buffer.from(expected, "hex"))), chunks);
+});
From 716bf7eba901ab99c784dd95868f02d93d3b3a47 Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Wed, 5 Aug 2026 16:26:38 +0200
Subject: [PATCH 04/31] feat(web): move file processing to Rust WASM
---
.github/workflows/ci.yml | 32 ++
.gitignore | 1 +
Cargo.lock | 39 ++-
README.md | 10 +-
ant-core/Cargo.toml | 127 +++++--
ant-core/src/browser.rs | 323 ++++++++++++++++++
ant-core/src/lib.rs | 12 +
.../ADR-0003-direct-browser-read-client.md | 95 ++++--
web/README.md | 52 ++-
web/package-lock.json | 18 -
web/package.json | 10 +-
web/setup-wasm.js | 5 +
web/src/file.js | 137 ++------
web/src/file.test.js | 98 ++----
web/src/main.js | 3 +
web/src/payment.js | 8 +-
web/src/protocol.js | 8 +-
web/src/upload.js | 170 +--------
web/src/upload.test.js | 101 ++----
web/src/wasm.test.js | 50 +++
20 files changed, 772 insertions(+), 527 deletions(-)
create mode 100644 ant-core/src/browser.rs
create mode 100644 web/setup-wasm.js
create mode 100644 web/src/wasm.test.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2382b67c..9c2ed5d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,6 +33,38 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings
+ browser-wasm:
+ name: Browser WASM
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - uses: Swatinem/rust-cache@v2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - name: Check the portable Rust library
+ run: >-
+ cargo check -p ant-core
+ --target wasm32-unknown-unknown
+ --no-default-features
+ --features browser-wasm
+ - name: Install wasm-pack
+ run: cargo install wasm-pack --version 0.15.0 --locked
+ - name: Install browser dependencies
+ working-directory: web
+ run: npm ci
+ - name: Test browser client against Rust WASM
+ working-directory: web
+ run: npm test
+ - name: Build browser bundle
+ working-directory: web
+ run: npm run build
+
test-unit:
name: Unit Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
diff --git a/.gitignore b/.gitignore
index b71f648b..0b6b5d47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
/target
/web/node_modules/
/web/dist/
+/web/pkg/
.cargo/config.toml
.claude/plans/
.claude/scheduled_tasks.lock
diff --git a/Cargo.lock b/Cargo.lock
index b61d5377..35e81e4e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -845,12 +845,15 @@ dependencies = [
"axum",
"blake3",
"bytes",
+ "console_error_panic_hook",
"flate2",
"fs2",
"futures",
"futures-core",
"futures-util",
+ "getrandom 0.2.17",
"hex",
+ "js-sys",
"libc",
"lru",
"openssl",
@@ -862,6 +865,8 @@ dependencies = [
"self_encryption",
"semver 1.0.28",
"serde",
+ "serde-wasm-bindgen",
+ "serde_bytes",
"serde_json",
"serial_test",
"sysinfo",
@@ -876,6 +881,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"utoipa",
+ "wasm-bindgen",
"windows-sys 0.61.2",
"xor_name",
"zip",
@@ -1849,6 +1855,16 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
[[package]]
name = "const-hex"
version = "1.19.1"
@@ -3255,7 +3271,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.57.0",
+ "windows-core 0.58.0",
]
[[package]]
@@ -5556,6 +5572,27 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
diff --git a/README.md b/README.md
index 4725a7fe..8ffc044f 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@ A unified CLI and Rust library for storing data on the Autonomi decentralized ne
This project provides two Rust crates and a browser client:
-- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Designed to be consumed by any frontend (CLI, GUI, AI agents, REST clients).
+- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Its portable immutable-data core also builds for browsers with the `browser-wasm` feature.
- **ant-cli** — A thin CLI binary (`ant`) built on `ant-core`.
- **web** — A direct WebTransport client and test site. It performs browser-side
- closest-node lookup, reconstructs complete public self-encrypted files, and
- saves them without a data gateway.
+ closest-node lookup and uses `ant-core` through WASM to self-encrypt and
+ reconstruct complete public files without a data gateway.
Data on Autonomi is **content-addressed**. Files are split into encrypted chunks (via [self-encryption](https://en.wikipedia.org/wiki/Convergent_encryption)), each stored at an XOR address derived from its content. A `DataMap` tracks which chunks belong to a file. Payments for storage are made on an EVM-compatible blockchain (Arbitrum).
@@ -24,7 +24,7 @@ the browser-enabled node devnet, then run the site:
cargo run --features webtransport-poc --bin ant-devnet -- \
--preset minimal --base-port 23000 \
--webtransport --webtransport-base-port 24000 \
- --serve-port 25000 --enable-logging
+ --serve-port 25000 --enable-evm --enable-logging
# In ant-client-web-support/web
npm ci
@@ -33,7 +33,7 @@ npm run dev
Open `http://127.0.0.1:5173`; the default public test file and direct node
endpoints are loaded from the local browser manifest. See [web/README.md](web/README.md)
-for the protocol flow and LAN configuration.
+for the one-time `wasm-pack` setup, protocol flow, and LAN configuration.
## Installation
diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml
index 3fcff457..11c3c93e 100644
--- a/ant-core/Cargo.toml
+++ b/ant-core/Cargo.toml
@@ -7,29 +7,41 @@ license = "MIT OR Apache-2.0"
repository = "https://github.com/WithAutonomi/ant-client"
readme = "../README.md"
+[lib]
+crate-type = ["rlib", "cdylib"]
+
[dependencies]
-# Node management
-async-stream = "0.3"
-axum = "0.8"
-flate2 = "1"
+# Cross-platform data primitives. These are the only dependencies compiled for
+# the browser-WASM target; native networking and node management stay behind
+# the `native` feature below.
blake3 = "1"
-fs2 = "0.4"
-futures-core = "0.3"
-futures-util = "0.3"
-self-replace = "1"
-semver = "1"
+bytes = "1"
+hex = "0.4"
+rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
-serde_json = "1"
-reqwest = { version = "0.12", features = ["json", "stream"] }
-tar = "0.4"
-tempfile = "3"
-toml = "0.8"
+serde_bytes = "0.11"
+self_encryption = "0.36"
thiserror = "2"
-tokio = { version = "1", features = ["full"] }
-tokio-util = { version = "0.7", features = ["rt"] }
-utoipa = { version = "5", features = ["axum_extras"] }
-zip = "2"
-tower-http = { version = "0.6.8", features = ["cors"] }
+
+# Node management
+async-stream = { version = "0.3", optional = true }
+axum = { version = "0.8", optional = true }
+flate2 = { version = "1", optional = true }
+fs2 = { version = "0.4", optional = true }
+futures-core = { version = "0.3", optional = true }
+futures-util = { version = "0.3", optional = true }
+self-replace = { version = "1", optional = true }
+semver = { version = "1", optional = true }
+serde_json = { version = "1", optional = true }
+reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
+tar = { version = "0.4", optional = true }
+tempfile = { version = "3", optional = true }
+toml = { version = "0.8", optional = true }
+tokio = { version = "1", features = ["full"], optional = true }
+tokio-util = { version = "0.7", features = ["rt"], optional = true }
+utoipa = { version = "5", features = ["axum_extras"], optional = true }
+zip = { version = "2", optional = true }
+tower-http = { version = "0.6.8", features = ["cors"], optional = true }
# Data operations
# Wire protocol crate: gives us `ant_protocol::{chunk, payment, …}` plus
@@ -37,23 +49,19 @@ tower-http = { version = "0.6.8", features = ["cors"] }
# under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for
# those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc
# deps here or the version can skew between ant-client and ant-node.
-ant-protocol = "2.3.1"
-xor_name = "5"
-self_encryption = "0.36"
-futures = "0.3"
-postcard = { version = "1.1.3", features = ["use-std"] }
-rmp-serde = "1"
-hex = "0.4"
-tracing = "0.1"
-bytes = "1"
-lru = "0.16"
-rand = "0.8"
+ant-protocol = { version = "2.3.1", optional = true }
+xor_name = { version = "5", optional = true }
+futures = { version = "0.3", optional = true }
+postcard = { version = "1.1.3", features = ["use-std"], optional = true }
+tracing = { version = "0.1", optional = true }
+lru = { version = "0.16", optional = true }
+rand = { version = "0.8", optional = true }
# Used by the daemon supervisor to scan the OS process table when adopting
# running nodes whose pid file is missing (e.g. nodes spawned by a pre-adoption
# daemon). Happy-path adoption reads the pid file directly and doesn't touch
# sysinfo, so the crate is loaded lazily and the cost is bounded to first-time
# upgrade scenarios.
-sysinfo = { version = "0.32", default-features = false, features = ["system"] }
+sysinfo = { version = "0.32", default-features = false, features = ["system"], optional = true }
# ant-node is optional. It is only linked for the `LocalDevnet` wrapper
# that spawns a local in-process network for development and testing.
# Enable with `--features devnet`.
@@ -66,7 +74,19 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"] }
# track the matching released version carrying the same saorsa-core /
# ant-protocol lineage.
ant-node = { version = "0.16.0", optional = true }
-tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
+
+# Browser bindings are optional so ordinary native consumers do not pull the
+# wasm-bindgen toolchain into their dependency graph.
+js-sys = { version = "0.3", optional = true }
+serde-wasm-bindgen = { version = "0.6", optional = true }
+wasm-bindgen = { version = "0.2", optional = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+# self_encryption/rand use getrandom 0.2. Browser entropy is supplied by the
+# Web Crypto API through its `js` feature.
+getrandom = { version = "0.2", features = ["js"] }
+console_error_panic_hook = { version = "0.1", optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -79,12 +99,47 @@ openssl = { version = "0.10", features = ["vendored"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console", "Win32_System_Threading"] }
[features]
-# No features enabled by default — consumers that want to spawn a local
-# devnet opt in with `features = ["devnet"]`.
-default = []
+# Preserve the existing native ant-core API for ordinary consumers. WASM
+# consumers build with `--no-default-features --features browser-wasm`.
+default = ["native"]
+native = [
+ "dep:ant-protocol",
+ "dep:async-stream",
+ "dep:axum",
+ "dep:flate2",
+ "dep:fs2",
+ "dep:futures",
+ "dep:futures-core",
+ "dep:futures-util",
+ "dep:lru",
+ "dep:postcard",
+ "dep:rand",
+ "dep:reqwest",
+ "dep:self-replace",
+ "dep:semver",
+ "dep:serde_json",
+ "dep:sysinfo",
+ "dep:tar",
+ "dep:tempfile",
+ "dep:tokio",
+ "dep:tokio-util",
+ "dep:toml",
+ "dep:tower-http",
+ "dep:tracing",
+ "dep:tracing-subscriber",
+ "dep:utoipa",
+ "dep:xor_name",
+ "dep:zip",
+]
+browser-wasm = [
+ "dep:console_error_panic_hook",
+ "dep:js-sys",
+ "dep:serde-wasm-bindgen",
+ "dep:wasm-bindgen",
+]
# Enable `LocalDevnet` (ant-core/src/node/devnet.rs) which wraps
# `ant_node::devnet::Devnet` and an Anvil EVM testnet.
-devnet = ["dep:ant-node"]
+devnet = ["native", "dep:ant-node"]
# Expose test-only client seams (e.g. forcing the ADR-0002 extended PUT
# fallback) used by the e2e/integration test suite.
test-utils = []
diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs
new file mode 100644
index 00000000..9efe5dfe
--- /dev/null
+++ b/ant-core/src/browser.rs
@@ -0,0 +1,323 @@
+//! Browser-safe immutable-data primitives.
+//!
+//! This module deliberately contains no transport, filesystem, Tokio runtime,
+//! or EVM provider. It is the shared compatibility-sensitive core used by the
+//! native client and by the browser WASM package: native self-encryption,
+//! public DataMap encoding, content addressing, and reconstruction.
+
+use bytes::Bytes;
+use self_encryption::{DataMap, EncryptedChunk};
+use serde::{Deserialize, Serialize};
+
+/// Maximum file size accepted by the in-memory browser demo.
+pub const MAX_BROWSER_FILE_BYTES: usize = 64 * 1024 * 1024;
+
+/// One native self-encryption chunk descriptor exposed to the browser.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserChunkInfo {
+ /// Zero-based chunk index.
+ pub index: usize,
+ /// BLAKE3 address of the encrypted record.
+ pub dst_hash: String,
+ /// BLAKE3 hash of the plaintext chunk.
+ pub src_hash: String,
+ /// Plaintext chunk size.
+ pub src_size: usize,
+}
+
+/// One content-addressed record ready for network upload.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserRecord {
+ /// Lowercase hexadecimal BLAKE3 record address.
+ pub address: String,
+ /// Raw record bytes. `serde_bytes` maps this to `Uint8Array` in WASM.
+ #[serde(with = "serde_bytes")]
+ pub content: Vec,
+}
+
+/// Result of native public-file self-encryption for a browser upload.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserEncryptedFile {
+ /// Public DataMap record address.
+ pub address: String,
+ /// Whole-file plaintext BLAKE3 hash.
+ pub blake3: String,
+ /// Serialized public DataMap size.
+ pub data_map_size: usize,
+ /// Native root DataMap chunk descriptors.
+ pub chunks: Vec,
+ /// Encrypted data records followed by the public DataMap record.
+ pub records: Vec,
+}
+
+/// Browser immutable-data processing error.
+#[derive(Debug, thiserror::Error)]
+pub enum BrowserError {
+ /// Input did not satisfy the browser demo limits.
+ #[error("invalid browser data: {0}")]
+ Invalid(String),
+ /// Native self-encryption failed.
+ #[error("self-encryption failed: {0}")]
+ SelfEncryption(String),
+ /// Public DataMap encoding or decoding failed.
+ #[error("DataMap serialization failed: {0}")]
+ DataMap(String),
+}
+
+/// BLAKE3-address bytes using the same lowercase hexadecimal representation as
+/// the native chunk protocol.
+#[must_use]
+pub fn content_address(content: &[u8]) -> String {
+ blake3::hash(content).to_hex().to_string()
+}
+
+/// Verify raw record bytes against a lowercase or uppercase hexadecimal BLAKE3
+/// address.
+pub fn verify_record(address: &str, content: &[u8]) -> Result<(), BrowserError> {
+ let expected = address.strip_prefix("0x").unwrap_or(address);
+ if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(BrowserError::Invalid(
+ "record address must be 32 hexadecimal bytes".to_string(),
+ ));
+ }
+ let actual = content_address(content);
+ if !actual.eq_ignore_ascii_case(expected) {
+ return Err(BrowserError::Invalid(format!(
+ "BLAKE3 mismatch: expected {}, received {actual}",
+ expected.to_ascii_lowercase()
+ )));
+ }
+ Ok(())
+}
+
+/// Encrypt a complete public file with the native `self_encryption 0.36`
+/// implementation and append its MessagePack DataMap as a public record.
+pub fn encrypt_public_file(content: &[u8]) -> Result {
+ if content.len() < self_encryption::MIN_ENCRYPTABLE_BYTES {
+ return Err(BrowserError::Invalid(format!(
+ "self-encryption requires at least {} bytes",
+ self_encryption::MIN_ENCRYPTABLE_BYTES
+ )));
+ }
+ if content.len() > MAX_BROWSER_FILE_BYTES {
+ return Err(BrowserError::Invalid(format!(
+ "browser files are limited to {MAX_BROWSER_FILE_BYTES} bytes"
+ )));
+ }
+
+ let whole_file_hash = content_address(content);
+ let (data_map, encrypted_chunks) = self_encryption::encrypt(Bytes::copy_from_slice(content))
+ .map_err(|error| BrowserError::SelfEncryption(error.to_string()))?;
+ if data_map.is_child() {
+ return Err(BrowserError::Invalid(
+ "nested DataMaps are not supported by the browser client".to_string(),
+ ));
+ }
+ let chunks = chunk_infos(&data_map);
+ if encrypted_chunks.len() != chunks.len() {
+ return Err(BrowserError::SelfEncryption(format!(
+ "native encryptor returned {} records for {} DataMap entries",
+ encrypted_chunks.len(),
+ chunks.len()
+ )));
+ }
+
+ let mut records: Vec = encrypted_chunks
+ .into_iter()
+ .zip(&chunks)
+ .map(|(chunk, info)| BrowserRecord {
+ address: info.dst_hash.clone(),
+ content: chunk.content.to_vec(),
+ })
+ .collect();
+ let encoded_data_map =
+ rmp_serde::to_vec(&data_map).map_err(|error| BrowserError::DataMap(error.to_string()))?;
+ let address = content_address(&encoded_data_map);
+ let data_map_size = encoded_data_map.len();
+ records.push(BrowserRecord {
+ address: address.clone(),
+ content: encoded_data_map,
+ });
+
+ Ok(BrowserEncryptedFile {
+ address,
+ blake3: whole_file_hash,
+ data_map_size,
+ chunks,
+ records,
+ })
+}
+
+/// Decode and normalize a native public DataMap.
+pub fn decode_public_data_map(content: &[u8]) -> Result, BrowserError> {
+ let data_map: DataMap =
+ rmp_serde::from_slice(content).map_err(|error| BrowserError::DataMap(error.to_string()))?;
+ if data_map.is_child() {
+ return Err(BrowserError::Invalid(
+ "nested DataMaps are not supported by the browser client".to_string(),
+ ));
+ }
+ Ok(chunk_infos(&data_map))
+}
+
+/// Reconstruct a public file with native self-encryption after verifying every
+/// encrypted record against its DataMap destination address.
+pub fn decrypt_public_file(
+ data_map_content: &[u8],
+ encrypted_contents: &[Vec],
+) -> Result, BrowserError> {
+ let data_map: DataMap = rmp_serde::from_slice(data_map_content)
+ .map_err(|error| BrowserError::DataMap(error.to_string()))?;
+ if data_map.is_child() {
+ return Err(BrowserError::Invalid(
+ "nested DataMaps are not supported by the browser client".to_string(),
+ ));
+ }
+ if encrypted_contents.len() != data_map.infos().len() {
+ return Err(BrowserError::Invalid(format!(
+ "received {} encrypted records for {} DataMap entries",
+ encrypted_contents.len(),
+ data_map.infos().len()
+ )));
+ }
+
+ let encrypted_chunks = data_map
+ .infos()
+ .iter()
+ .zip(encrypted_contents)
+ .map(|(info, content)| {
+ verify_record(&hex::encode(info.dst_hash.0), content)?;
+ Ok(EncryptedChunk {
+ content: Bytes::copy_from_slice(content),
+ })
+ })
+ .collect::, BrowserError>>()?;
+ self_encryption::decrypt(&data_map, &encrypted_chunks)
+ .map(|bytes| bytes.to_vec())
+ .map_err(|error| BrowserError::SelfEncryption(error.to_string()))
+}
+
+fn chunk_infos(data_map: &DataMap) -> Vec {
+ data_map
+ .infos()
+ .iter()
+ .map(|info| BrowserChunkInfo {
+ index: info.index,
+ dst_hash: hex::encode(info.dst_hash.0),
+ src_hash: hex::encode(info.src_hash.0),
+ src_size: info.src_size,
+ })
+ .collect()
+}
+
+#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
+mod wasm {
+ use super::{content_address, decrypt_public_file, encrypt_public_file, verify_record};
+ use js_sys::{Array, Uint8Array};
+ use wasm_bindgen::prelude::*;
+
+ /// Install a readable panic hook for browser developer tools.
+ #[wasm_bindgen(start)]
+ pub fn start() {
+ console_error_panic_hook::set_once();
+ }
+
+ /// Native `self_encryption` plus public DataMap generation.
+ #[wasm_bindgen(js_name = encryptPublicFile)]
+ pub fn encrypt_public_file_wasm(content: &[u8]) -> Result {
+ let encrypted =
+ encrypt_public_file(content).map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&encrypted)
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Native BLAKE3 content address.
+ #[wasm_bindgen(js_name = contentAddress)]
+ #[must_use]
+ pub fn content_address_wasm(content: &[u8]) -> String {
+ content_address(content)
+ }
+
+ /// Verify one content-addressed record with native BLAKE3.
+ #[wasm_bindgen(js_name = verifyRecord)]
+ pub fn verify_record_wasm(address: &str, content: &[u8]) -> Result {
+ verify_record(address, content).map_err(|error| JsValue::from_str(&error.to_string()))?;
+ Ok(content_address(content))
+ }
+
+ /// Decode a native public DataMap for browser-side record retrieval.
+ #[wasm_bindgen(js_name = decodePublicDataMap)]
+ pub fn decode_public_data_map_wasm(content: &[u8]) -> Result {
+ let chunks = super::decode_public_data_map(content)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&chunks).map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Native public DataMap decoding and whole-file reconstruction.
+ #[wasm_bindgen(js_name = decryptPublicFile)]
+ pub fn decrypt_public_file_wasm(
+ data_map_content: &[u8],
+ encrypted_contents: Array,
+ ) -> Result {
+ let encrypted_contents = encrypted_contents
+ .iter()
+ .map(|value| Uint8Array::new(&value).to_vec())
+ .collect::>();
+ let plaintext = decrypt_public_file(data_map_content, &encrypted_contents)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ Ok(Uint8Array::from(plaintext.as_slice()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fixture() -> Vec {
+ "browser whole-file fixture\n".repeat(160).into_bytes()
+ }
+
+ #[test]
+ fn native_browser_encrypt_matches_existing_wire_vector() {
+ let encrypted = encrypt_public_file(&fixture()).expect("encrypt fixture");
+ assert_eq!(
+ encrypted
+ .chunks
+ .iter()
+ .map(|chunk| chunk.dst_hash.as_str())
+ .collect::>(),
+ vec![
+ "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9",
+ "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a",
+ "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
+ ]
+ );
+ assert_eq!(
+ encrypted.address,
+ "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b"
+ );
+ assert_eq!(
+ encrypted.blake3,
+ "e0e422267ac59c56bf032d6d830035d343369d20147dd5f6b63351a29b015f22"
+ );
+ }
+
+ #[test]
+ fn native_browser_round_trip_and_tamper_rejection() {
+ let content = fixture();
+ let encrypted = encrypt_public_file(&content).expect("encrypt fixture");
+ let data_map = &encrypted.records.last().expect("DataMap record").content;
+ let chunks = encrypted.records[..encrypted.records.len() - 1]
+ .iter()
+ .map(|record| record.content.clone())
+ .collect::>();
+ assert_eq!(
+ decrypt_public_file(data_map, &chunks).expect("decrypt fixture"),
+ content
+ );
+
+ let mut tampered = chunks;
+ tampered[0][0] ^= 1;
+ assert!(decrypt_public_file(data_map, &tampered).is_err());
+ }
+}
diff --git a/ant-core/src/lib.rs b/ant-core/src/lib.rs
index eab436cd..c39dea3b 100644
--- a/ant-core/src/lib.rs
+++ b/ant-core/src/lib.rs
@@ -1,6 +1,18 @@
+/// Browser-safe immutable-data primitives shared by native and WASM clients.
+pub mod browser;
+
+#[cfg(feature = "native")]
pub mod config;
+#[cfg(feature = "native")]
pub mod data;
+#[cfg(feature = "native")]
pub mod datamap_file;
+#[cfg(feature = "native")]
pub mod error;
+#[cfg(feature = "native")]
pub mod node;
+#[cfg(feature = "native")]
pub mod update;
+
+#[cfg(all(target_arch = "wasm32", not(feature = "browser-wasm")))]
+compile_error!("WASM builds of ant-core require the `browser-wasm` feature");
diff --git a/docs/adr/ADR-0003-direct-browser-read-client.md b/docs/adr/ADR-0003-direct-browser-read-client.md
index e6121b67..b9410205 100644
--- a/docs/adr/ADR-0003-direct-browser-read-client.md
+++ b/docs/adr/ADR-0003-direct-browser-read-client.md
@@ -32,21 +32,39 @@ records, and testnet bootstrap-manifest production under ant-node ADR-0009.
- The wallet secret must be provided at runtime, used only by the local EVM
signer, and never sent to a node or persisted in bootstrap metadata.
- Local testnets need a reproducible bootstrap and default-file workflow.
+- Compatibility-sensitive file processing should be shared with the Rust
+ client instead of being independently reimplemented in JavaScript.
## Considered Options
1. **Use the daemon REST API as a data gateway.** Rejected for lookup and file
bytes because it would not exercise a full browser client.
2. **Compile the complete native Rust client to WebAssembly.** Deferred because
- the native transport, EVM, and filesystem dependency graph is not currently
- browser-compatible.
-3. **Implement a narrow JavaScript WebTransport immutable-data client
- (chosen).** It maps directly to the versioned browser node protocol and
- keeps the application boundary small enough to audit.
+ the native transport, EVM provider, Tokio, and filesystem dependency graph
+ is not currently browser-compatible.
+3. **Use a Rust/WASM data core with thin JavaScript browser adapters
+ (chosen).** Compile the portable immutable-data part of `ant-core` to WASM
+ while retaining JavaScript only where browser APIs or currently
+ native-only dependencies require it.
## Decision
-The `web/` package will implement the direct browser client:
+`ant-core` has two build surfaces. Its default `native` feature preserves the
+existing native library. A `wasm32-unknown-unknown` build with
+`--no-default-features --features browser-wasm` excludes node management,
+native transport, Tokio, filesystem, and native EVM-provider dependencies and
+exports browser-safe immutable-data operations through `wasm-bindgen`.
+
+The Rust/WASM core will:
+
+- self-encrypt complete public files with the same `self_encryption 0.36`
+ implementation used by the Rust client;
+- encode and decode the native MessagePack `DataMap` representation;
+- calculate and verify BLAKE3 content addresses;
+- verify every encrypted record against its DataMap destination address; and
+- authenticate, decompress, and reconstruct complete public files.
+
+The `web/` package will remain responsible for browser-specific orchestration:
- load a versioned browser bootstrap manifest containing WebTransport
multiaddresses and published immutable-file metadata;
@@ -58,8 +76,8 @@ The `web/` package will implement the direct browser client:
and `ALPHA = 3`;
- query closest direct endpoints with `GET_CHUNK`, retrying `not_found` and
unavailable nodes without routing bytes through the manifest service;
-- self-encrypt selected public files with the native `self_encryption 0.36`
- format and generate the public MessagePack DataMap;
+- call the Rust/WASM core to self-encrypt selected public files and generate
+ their public MessagePack DataMaps;
- request ordinary node storage quotes, verify their ML-DSA peer/content
binding, forced price, and signed storage commitment before payment;
- construct an EVM wallet only from the runtime secret field, approve the
@@ -67,14 +85,13 @@ The `web/` package will implement the direct browser client:
- upload each content-addressed encrypted record with the signed quote and
transaction hash through paid `PUT_CHUNK`; the wallet key never crosses the
WebTransport session;
-- fetch the public MessagePack DataMap and every resolved encrypted data chunk;
-- reconstruct the file with the native `self_encryption 0.36` BLAKE3 KDF,
- ChaCha20-Poly1305 authentication, and Brotli decompression;
-- verify encrypted-record addresses, per-chunk plaintext hashes and sizes, and
- the final whole-file BLAKE3 hash before allowing a save;
+- fetch the public MessagePack DataMap and every resolved encrypted data chunk,
+ then pass those records to the Rust/WASM reconstruction API;
+- verify the final file size and use the Rust/WASM BLAKE3 verifier before
+ allowing a save;
- expose a small test site that loads the local testnet manifest, displays the
- startup-published file, uploads paid files, and downloads either through the
- browser save flow.
+ startup-published file, uploads paid files, and downloads through the browser
+ save flow.
The local browser manifest is bootstrap metadata, not a gateway. Production
clients will replace its unsigned endpoint list with the ML-DSA-signed records
@@ -94,11 +111,21 @@ Rust nodes construct and validate this syntax through
JavaScript parser is the browser implementation of that same canonical wire
format and is covered by matching current/next-pin fixtures.
-For the local vertical slice, the bootstrap manifest carries a resolved JSON
-view of the public root DataMap alongside its ordinary on-network DataMap
-address. The browser still fetches and verifies that public DataMap record and
-all file bytes directly from nodes. Production discovery must replace this
-unsigned resolved view with parsing and validation of the signed/on-network
+Quote and storage-commitment verification remains JavaScript for now.
+`ant-protocol 2.3.1` unconditionally reaches the native Saorsa transport and
+EVM dependency graph, including Tokio networking and `mio`, and therefore
+cannot be linked into a browser WASM target. A future transport-free
+`ant-protocol` feature should expose the pure multiaddress, quote, commitment,
+and ML-DSA verification types without enabling native networking. At that
+point those compatibility-sensitive operations should also move behind the
+Rust/WASM boundary.
+
+For compatibility with the local launcher, the bootstrap manifest still
+carries a resolved JSON view of the public root DataMap alongside its ordinary
+on-network DataMap address. The download path does not use that copy to select
+records: it fetches the public DataMap from a node and uses the Rust/WASM
+decoder to derive the encrypted-record addresses. Production discovery must
+still replace the unsigned manifest with validation of the signed/on-network
metadata chain.
## Consequences
@@ -110,14 +137,20 @@ metadata chain.
- A local five-node testnet can validate multiple direct node connections,
multiaddress-embedded certificate pins, lookup convergence, fallback, and
content verification.
-- The browser protocol is independent of native Rust serialization details.
+- Self-encryption, DataMap serialization, reconstruction, and content
+ addressing have one Rust implementation across native and browser clients.
+- The browser application keeps direct control of WebTransport, wallet, and
+ DOM APIs without pulling native runtime dependencies into WASM.
### Negative / Trade-offs
- The current client reconstructs files in memory and the local launcher caps
public files at 64 MiB; upload encryption and reconstruction are not yet
streaming.
-- JavaScript lookup behavior must remain aligned with native Kademlia rules.
+- JavaScript lookup and quote-verification behavior must remain aligned with
+ native Kademlia and protocol rules until transport-free Rust APIs exist.
+- The initial WASM module is approximately 1.4 MiB uncompressed and browser
+ file processing is still in memory.
- Certificate and endpoint verification adds bootstrap-record lifecycle work.
### Neutral / Operational
@@ -129,13 +162,19 @@ metadata chain.
## Validation
-- Unit tests cover fixed-width identifiers, XOR ordering, bidirectional binary
- framing, manifest/payment validation, quote signatures and the native
- Keccak-256 EVM quote-hash vector, native-format
- encryption/DataMap generation, and BLAKE3 mismatch rejection.
+- Rust unit tests cover an exact native `self_encryption 0.36` wire vector,
+ public DataMap generation, round-trip reconstruction, and tamper rejection.
+- JavaScript unit tests cover fixed-width identifiers, XOR ordering,
+ bidirectional binary framing, manifest/payment validation, quote signatures,
+ the native Keccak-256 EVM quote-hash vector, and the browser orchestration
+ around the Rust/WASM boundary.
+- CI compiles `ant-core` for `wasm32-unknown-unknown` with native features
+ disabled, builds the generated `wasm-pack` package, runs browser-client
+ tests against it, and produces the Vite bundle.
- The browser production bundle builds without Node-specific runtime APIs.
-- A fixed vector generated by native `self_encryption 0.36` verifies browser
- KDF, authenticated decryption, Brotli reconstruction, and tamper rejection.
+- The generated WASM package encrypts and reconstructs the same fixed vector
+ as the native Rust test, covering the native KDF, authenticated decryption,
+ Brotli reconstruction, MessagePack DataMap, and BLAKE3 addresses.
- A live node integration test starts five WebTransport-enabled nodes,
publishes a public DataMap and encrypted chunks, connects with the advertised
self-contained multiaddress, retrieves every record, and reconstructs the
diff --git a/web/README.md b/web/README.md
index e60c42fe..0964bbd4 100644
--- a/web/README.md
+++ b/web/README.md
@@ -2,11 +2,11 @@
This web application is the browser-facing client for ADR-0009. It loads a
local testnet bootstrap manifest, connects directly to storage nodes over
-WebTransport, performs the XOR closest-node lookup in JavaScript, retrieves a
-public DataMap and every encrypted file chunk, reconstructs the complete file,
-and verifies its whole-file BLAKE3 hash before saving it. It can also
-self-encrypt a file, pay signed node quotes with a wallet held only in the
-page, and upload the encrypted records directly to closest storage nodes.
+WebTransport, and performs the XOR closest-node lookup in the browser. The
+portable part of the Rust `ant-core` library is compiled to WASM and performs
+native self-encryption, public DataMap serialization, reconstruction, and
+BLAKE3 content verification. The thin JavaScript layer retrieves and uploads
+records, uses the browser wallet/payment APIs, and drives the page.
The node-side WebTransport listener and testnet manifest API live in the
`ant-node-web-support` sibling repository. No HTTP gateway performs lookup or
@@ -15,6 +15,8 @@ proxies file bytes.
## Requirements
- Rust 1.88 or newer for the node's optional `wtransport` dependency.
+- The `wasm32-unknown-unknown` Rust target.
+- `wasm-pack` 0.15.
- Node.js 20.19+ or 22.12+.
- A current browser implementing WebTransport certificate hashes. The client
extracts them from node multiaddresses; users do not enter hashes separately.
@@ -22,6 +24,13 @@ proxies file bytes.
Nodes serialize these addresses from the native `saorsa_core::MultiAddr`
representation; the JavaScript parser consumes that canonical string form.
+Install the WASM build tools once if needed:
+
+```bash
+rustup target add wasm32-unknown-unknown
+cargo install wasm-pack --version 0.15.0 --locked
+```
+
## Run the browser-enabled testnet
From `ant-node-web-support`:
@@ -69,6 +78,10 @@ npm ci
npm run dev
```
+The `predev` hook builds `ant-core` with `--no-default-features --features
+browser-wasm` into the ignored `web/pkg/` directory before Vite starts. No
+published or hand-maintained JavaScript copy of the Rust algorithms is used.
+
Open `http://127.0.0.1:5173`. The page automatically loads the testnet
manifest from port 25000 and fills in the default file:
@@ -77,10 +90,11 @@ manifest from port 25000 and fills in the default file:
WebTransport `HELLO`.
3. **Find closest** runs the iterative lookup in the browser.
4. Under **Paid public file upload**, choose a file, paste the funded private
- key printed by ant-devnet, then select **Pay and upload file**. Encryption,
- quote/commitment verification, approval, and payment happen locally. The
- key field is cleared immediately and the resulting public DataMap address
- is placed in the download field.
+ key printed by ant-devnet, then select **Pay and upload file**. Rust/WASM
+ performs encryption and DataMap generation; quote/commitment verification,
+ approval, and payment happen locally in the browser. The key field is
+ cleared immediately and the resulting public DataMap address is placed in
+ the download field.
5. **Download and save file** opens the browser save flow, fetches the public
DataMap and encrypted chunks from direct closest storage nodes, reconstructs
the whole file, verifies BLAKE3, and retains a **Save again** link.
@@ -101,11 +115,19 @@ npm test
npm run build
```
+Both commands build the WASM package automatically. To validate the Rust
+boundary directly:
+
+```bash
+cargo check -p ant-core --target wasm32-unknown-unknown \
+ --no-default-features --features browser-wasm
+cargo test -p ant-core --lib browser::tests
+```
+
The tests cover fixed-width IDs, XOR ordering, bidirectional binary framing,
browser manifest/payment validation, signed quote verification including the
-native Keccak-256 EVM quote hash, native
-encryption/DataMap generation, and a `self_encryption 0.36` compatibility
-vector.
+native Keccak-256 EVM quote hash, and a Rust `self_encryption 0.36` wire vector
+with native/WASM round-trip and tamper verification.
Cross-repository live verification additionally starts the node testnet,
downloads all public-file records through WebTransport, reconstructs the
original bytes, pays a real quote on local Anvil, uploads a fresh record
@@ -113,6 +135,12 @@ through the ordinary node payment validator, and reads it back.
## Current boundary
+JavaScript currently owns WebTransport stream handling, iterative lookup,
+Ethers payment submission, ML-DSA quote verification, and DOM/save APIs.
+`ant-protocol 2.3.1` still enables native Saorsa/Tokio networking and cannot be
+linked into a browser WASM build; a future transport-free feature can move the
+remaining quote and multiaddress verification into Rust.
+
The local testnet manifest is intentionally unsigned bootstrap material. A
production deployment still needs ML-DSA-signed endpoint records, certificate
overlap/rotation, network dissemination, relayed WebTransport, and production
diff --git a/web/package-lock.json b/web/package-lock.json
index a3490305..17d41a4d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,11 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
- "@noble/ciphers": "2.2.0",
"@noble/hashes": "2.2.0",
"@noble/post-quantum": "^0.6.1",
- "brotli-dec-wasm": "2.3.2",
- "brotli-wasm": "^3.0.1",
"ethers": "^6.17.0"
},
"devDependencies": {
@@ -364,21 +361,6 @@
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
- "node_modules/brotli-dec-wasm": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/brotli-dec-wasm/-/brotli-dec-wasm-2.3.2.tgz",
- "integrity": "sha512-5H+k8eVLIJY6B4olN2HP9QzJAxcplf0jV7mWnkpxvOSeUE9Npg3dQ2pgLn30a9MUFHNro1iSmtdu6VNdlb+TIw==",
- "license": "MIT OR Apache-2.0"
- },
- "node_modules/brotli-wasm": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/brotli-wasm/-/brotli-wasm-3.0.1.tgz",
- "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=v18.0.0"
- }
- },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
diff --git a/web/package.json b/web/package.json
index 6e9f616c..4979f5d5 100644
--- a/web/package.json
+++ b/web/package.json
@@ -4,17 +4,19 @@
"private": true,
"type": "module",
"scripts": {
+ "wasm:dev": "wasm-pack build --target web --out-dir ../web/pkg --dev ../ant-core --no-default-features --features browser-wasm",
+ "wasm:release": "wasm-pack build --target web --out-dir ../web/pkg --release ../ant-core --no-default-features --features browser-wasm",
+ "predev": "npm run wasm:dev",
"dev": "vite --host 127.0.0.1 --port 5173",
+ "prebuild": "npm run wasm:release",
"build": "vite build",
- "test": "node --test"
+ "pretest": "npm run wasm:dev",
+ "test": "node --import ./setup-wasm.js --test"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
- "@noble/ciphers": "2.2.0",
"@noble/hashes": "2.2.0",
"@noble/post-quantum": "^0.6.1",
- "brotli-dec-wasm": "2.3.2",
- "brotli-wasm": "^3.0.1",
"ethers": "^6.17.0"
},
"devDependencies": {
diff --git a/web/setup-wasm.js b/web/setup-wasm.js
new file mode 100644
index 00000000..ee494ea8
--- /dev/null
+++ b/web/setup-wasm.js
@@ -0,0 +1,5 @@
+import { readFile } from "node:fs/promises";
+import initAntCore from "./pkg/ant_core.js";
+
+const wasm = await readFile(new URL("./pkg/ant_core_bg.wasm", import.meta.url));
+await initAntCore({ module_or_path: wasm });
diff --git a/web/src/file.js b/web/src/file.js
index a857f567..76d774e8 100644
--- a/web/src/file.js
+++ b/web/src/file.js
@@ -1,95 +1,11 @@
-import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
-import { blake3 } from "@noble/hashes/blake3.js";
-import { bytesToHex, getChunkFromClosest, hexToBytes } from "./protocol.js";
+import {
+ decodePublicDataMap as decodePublicDataMapNative,
+ decryptPublicFile as decryptPublicFileNative,
+} from "../pkg/ant_core.js";
+import { getChunkFromClosest, hexToBytes, verifyChunk } from "./protocol.js";
-const KDF_CONTEXT = new TextEncoder().encode("self_encryption/chunk/v2");
-const PAD_SIZE = 52;
-const KEY_SIZE = 32;
-const NONCE_SIZE = 12;
-const DERIVED_SIZE = PAD_SIZE + KEY_SIZE + NONCE_SIZE;
const MAX_DOWNLOAD_CONCURRENCY = 6;
-function writeU64LittleEndian(target, offset, value) {
- const numeric = BigInt(value);
- new DataView(target.buffer, target.byteOffset, target.byteLength).setBigUint64(
- offset,
- numeric,
- true,
- );
-}
-
-function predecessorIndices(index, count) {
- if (!Number.isSafeInteger(index) || index < 0 || index >= count || count < 3) {
- throw new Error(`Invalid self-encryption chunk index ${index}/${count}`);
- }
- if (index === 0) return [count - 1, count - 2];
- if (index === 1) return [0, count - 1];
- return [index - 1, index - 2];
-}
-
-export function deriveChunkMaterial(chunk, sourceHashes, childLevel = 0) {
- const [previous, previousPrevious] = predecessorIndices(chunk.index, sourceHashes.length);
- const context = new Uint8Array(32 * 3 + 8 * 2);
- context.set(sourceHashes[chunk.index], 0);
- context.set(sourceHashes[previous], 32);
- context.set(sourceHashes[previousPrevious], 64);
- writeU64LittleEndian(context, 96, chunk.index);
- writeU64LittleEndian(context, 104, childLevel);
-
- const derived = blake3(context, { context: KDF_CONTEXT, dkLen: DERIVED_SIZE });
- return {
- pad: derived.slice(0, PAD_SIZE),
- key: derived.slice(PAD_SIZE, PAD_SIZE + KEY_SIZE),
- nonce: derived.slice(PAD_SIZE + KEY_SIZE),
- };
-}
-
-export async function decryptSelfEncryptedChunk(
- chunk,
- encryptedContent,
- sourceHashes,
- childLevel = 0,
- decompress = decompressBrotli,
-) {
- const { pad, key, nonce } = deriveChunkMaterial(chunk, sourceHashes, childLevel);
- const ciphertext = new Uint8Array(encryptedContent.length);
- for (let index = 0; index < encryptedContent.length; index += 1) {
- ciphertext[index] = encryptedContent[index] ^ pad[index % pad.length];
- }
-
- let compressed;
- try {
- compressed = chacha20poly1305(key, nonce).decrypt(ciphertext);
- } catch (error) {
- throw new Error(`Chunk ${chunk.index} authentication failed`, { cause: error });
- }
-
- let plaintext;
- try {
- plaintext = await decompress(compressed);
- } catch (error) {
- throw new Error(`Chunk ${chunk.index} Brotli decompression failed`, { cause: error });
- }
- if (plaintext.length !== chunk.src_size) {
- throw new Error(
- `Chunk ${chunk.index} reconstructed ${plaintext.length} bytes, expected ${chunk.src_size}`,
- );
- }
- const sourceHash = bytesToHex(blake3(plaintext));
- if (sourceHash !== chunk.src_hash) {
- throw new Error(
- `Chunk ${chunk.index} plaintext hash mismatch: expected ${chunk.src_hash}, received ${sourceHash}`,
- );
- }
- return plaintext;
-}
-
-async function decompressBrotli(compressed) {
- const { default: brotliPromise } = await import("brotli-dec-wasm");
- const brotli = await brotliPromise;
- return brotli.decompress(compressed);
-}
-
async function mapWithConcurrency(items, concurrency, operation) {
const results = new Array(items.length);
let next = 0;
@@ -113,7 +29,8 @@ export async function downloadPublicFile(
concurrency = 3,
onProgress = () => {},
downloadChunk = getChunkFromClosest,
- decompress = decompressBrotli,
+ decodeDataMap = decodePublicDataMapNative,
+ decrypt = decryptPublicFileNative,
} = {},
) {
hexToBytes(file.address, 32);
@@ -131,44 +48,34 @@ export async function downloadPublicFile(
);
}
onProgress(`Verified public DataMap (${dataMap.content.length} bytes)`);
+ const chunks = decodeDataMap(dataMap.content);
+ if (!Array.isArray(chunks) || chunks.length < 3) {
+ throw new Error("ant-core WASM returned an invalid public DataMap");
+ }
- const sourceHashes = file.chunks.map((chunk) => hexToBytes(chunk.src_hash, 32));
- const plaintextChunks = await mapWithConcurrency(
- file.chunks,
+ const encryptedChunks = await mapWithConcurrency(
+ chunks,
boundedConcurrency,
async (chunk) => {
onProgress(
- `Fetching encrypted file chunk ${chunk.index + 1}/${file.chunks.length} (${chunk.dst_hash})`,
+ `Fetching encrypted file chunk ${chunk.index + 1}/${chunks.length} (${chunk.dst_hash})`,
);
const downloaded = await downloadChunk(seedEndpoints, chunk.dst_hash, {
onProgress,
});
- const plaintext = await decryptSelfEncryptedChunk(
- chunk,
- downloaded.content,
- sourceHashes,
- 0,
- decompress,
- );
- onProgress(`Reconstructed file chunk ${chunk.index + 1}/${file.chunks.length}`);
- return plaintext;
+ return downloaded.content;
},
);
- const totalSize = plaintextChunks.reduce((total, chunk) => total + chunk.length, 0);
- if (totalSize !== file.size) {
- throw new Error(`Reconstructed file has ${totalSize} bytes, expected ${file.size}`);
- }
- const content = new Uint8Array(totalSize);
- let offset = 0;
- for (const chunk of plaintextChunks) {
- content.set(chunk, offset);
- offset += chunk.length;
+ onProgress(`Reconstructing ${file.name} with native ant-core WASM`);
+ const content = decrypt(dataMap.content, encryptedChunks);
+ if (!(content instanceof Uint8Array)) {
+ throw new Error("ant-core WASM returned non-byte file content");
}
- const hash = bytesToHex(blake3(content));
- if (hash !== file.blake3) {
- throw new Error(`Whole-file BLAKE3 mismatch: expected ${file.blake3}, received ${hash}`);
+ if (content.length !== file.size) {
+ throw new Error(`Reconstructed file has ${content.length} bytes, expected ${file.size}`);
}
+ const hash = verifyChunk(file.blake3, content);
onProgress(`Verified complete ${file.name} as ${hash}`);
return { content, hash, dataMapNode: dataMap.node };
}
diff --git a/web/src/file.test.js b/web/src/file.test.js
index fbe46329..86200dab 100644
--- a/web/src/file.test.js
+++ b/web/src/file.test.js
@@ -1,81 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { brotliDecompressSync } from "node:zlib";
import { blake3 } from "@noble/hashes/blake3.js";
-import { decryptSelfEncryptedChunk, downloadPublicFile } from "./file.js";
-import { bytesToHex, hexToBytes } from "./protocol.js";
+import { downloadPublicFile } from "./file.js";
+import { bytesToHex } from "./protocol.js";
-// Generated by self_encryption 0.36 from
-// "browser whole-file fixture\n" repeated 160 times. This is a compatibility
-// vector, not a JavaScript-generated round trip.
-const chunks = [
- {
- index: 0,
- dst_hash: "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9",
- src_hash: "a049deadbb9eb8ef8102a99698ec8484ba4ba9f1d82089af28b31d70783aae8b",
- src_size: 1440,
- encrypted:
- "7508d7886030b903dce0178555d3df9115240fb3efe6029c4b57c05f84d82b85aedb898c09345c65a55dc32ff125e6af96ceab",
- },
- {
- index: 1,
- dst_hash: "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a",
- src_hash: "a15219e6c26233304a4e733946b97282c2d7ac270e6bd31b7ebdd22ee428cbab",
- src_size: 1440,
- encrypted:
- "5244d1869387ef32f8bec83ff7227fac29d3963a198f271f4ec74b84c1f364bdf2f7964850ecb51c36e30deef5e96c440782e17401",
- },
- {
- index: 2,
- dst_hash: "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
- src_hash: "ab7ff601a29bd95610dea57dad100e54d53a837db06604afa3c2eaf482d2a82e",
- src_size: 1440,
- encrypted:
- "601efca604bda1cdcde9e79722e5e71d9fcd5764c0fd52a9d0dbf6aeaec53fc462cbc01a6a372a2247ab7a847538ed8e623609",
- },
-];
-
-const decompress = (input) => new Uint8Array(brotliDecompressSync(input));
-
-test("decrypts a native self_encryption 0.36 whole-file vector", async () => {
- const sourceHashes = chunks.map((chunk) => hexToBytes(chunk.src_hash, 32));
- const plaintextChunks = await Promise.all(
- chunks.map((chunk) =>
- decryptSelfEncryptedChunk(chunk, hexToBytes(chunk.encrypted), sourceHashes, 0, decompress),
- ),
- );
- const plaintext = new Uint8Array(
- plaintextChunks.reduce((total, chunk) => total + chunk.length, 0),
- );
- let offset = 0;
- for (const chunk of plaintextChunks) {
- plaintext.set(chunk, offset);
- offset += chunk.length;
- }
-
- assert.equal(new TextDecoder().decode(plaintext), "browser whole-file fixture\n".repeat(160));
- assert.equal(
- bytesToHex(blake3(plaintext)),
- "e0e422267ac59c56bf032d6d830035d343369d20147dd5f6b63351a29b015f22",
- );
-});
-
-test("rejects an authenticated self-encryption chunk after tampering", async () => {
- const sourceHashes = chunks.map((chunk) => hexToBytes(chunk.src_hash, 32));
- const tampered = hexToBytes(chunks[0].encrypted);
- tampered[0] ^= 1;
- await assert.rejects(
- decryptSelfEncryptedChunk(chunks[0], tampered, sourceHashes, 0, decompress),
- /authentication failed/,
- );
-});
-
-test("downloads a public DataMap and reconstructs a complete saveable file", async () => {
+test("downloads records and delegates reconstruction to ant-core WASM", async () => {
const content = new TextEncoder().encode("browser whole-file fixture\n".repeat(160));
- const dataMapContent = new TextEncoder().encode("native public DataMap fixture");
+ const dataMapContent = Uint8Array.of(9, 8, 7);
const dataMapAddress = bytesToHex(blake3(dataMapContent));
+ const chunks = [0, 1, 2].map((index) => ({
+ index,
+ dst_hash: (index + 1).toString(16).padStart(2, "0").repeat(32),
+ src_hash: (index + 4).toString(16).padStart(2, "0").repeat(32),
+ src_size: content.length / 3,
+ }));
const encryptedByAddress = new Map(
- chunks.map((chunk) => [chunk.dst_hash, hexToBytes(chunk.encrypted)]),
+ chunks.map((chunk, index) => [chunk.dst_hash, Uint8Array.of(index)]),
);
encryptedByAddress.set(dataMapAddress, dataMapContent);
const requested = [];
@@ -97,7 +37,21 @@ test("downloads a public DataMap and reconstructs a complete saveable file", asy
data_map_size: dataMapContent.length,
chunks,
},
- { downloadChunk, decompress },
+ {
+ downloadChunk,
+ decodeDataMap: (receivedDataMap) => {
+ assert.deepEqual(receivedDataMap, dataMapContent);
+ return chunks;
+ },
+ decrypt: (receivedDataMap, encryptedContents) => {
+ assert.deepEqual(receivedDataMap, dataMapContent);
+ assert.deepEqual(
+ encryptedContents,
+ chunks.map((_, index) => Uint8Array.of(index)),
+ );
+ return content;
+ },
+ },
);
assert.deepEqual(result.content, content);
diff --git a/web/src/main.js b/web/src/main.js
index 192de1a9..dc2c27f5 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -1,4 +1,5 @@
import "./style.css";
+import initAntCore from "../pkg/ant_core.js";
import {
BrowserNodeClient,
bytesToHex,
@@ -9,6 +10,8 @@ import { downloadPublicFile } from "./file.js";
import { fetchBrowserManifest } from "./manifest.js";
import { uploadPublicFile } from "./upload.js";
+await initAntCore();
+
const elements = {
manifestUrl: document.querySelector("#manifest-url"),
loadManifest: document.querySelector("#load-manifest"),
diff --git a/web/src/payment.js b/web/src/payment.js
index 7f61c032..f5ce0e94 100644
--- a/web/src/payment.js
+++ b/web/src/payment.js
@@ -1,4 +1,3 @@
-import { blake3 } from "@noble/hashes/blake3.js";
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { decode } from "@msgpack/msgpack";
import {
@@ -9,6 +8,7 @@ import {
NonceManager,
Wallet,
} from "ethers";
+import { contentAddress as contentAddressNative } from "../pkg/ant_core.js";
import { bytesToHex, hexToBytes } from "./protocol.js";
const U256_MAX = (1n << 256n) - 1n;
@@ -172,7 +172,7 @@ function verifyCommitment(commitment, quote) {
if (!Number.isSafeInteger(keyCount) || keyCount !== quote.committed_key_count) {
throw new Error("Storage commitment key count does not match quote");
}
- if (bytesToHex(blake3(publicKey)) !== quote.peer_id.toLowerCase()) {
+ if (contentAddressNative(publicKey) !== quote.peer_id.toLowerCase()) {
throw new Error("Storage commitment public key is not bound to quote peer");
}
if (bytesToHex(peerId) !== quote.peer_id.toLowerCase()) {
@@ -209,7 +209,7 @@ function verifyCommitment(commitment, quote) {
postcardVarint(signature.length),
signature,
);
- const pin = bytesToHex(blake3(concatBytes(DOMAIN_COMMITMENT_HASH, postcard)));
+ const pin = contentAddressNative(concatBytes(DOMAIN_COMMITMENT_HASH, postcard));
if (pin !== quote.commitment_pin.toLowerCase()) {
throw new Error("Storage commitment does not resolve the quote pin");
}
@@ -234,7 +234,7 @@ export function verifyStorageQuote(quote, expectedAddress, expectedPeerId) {
if (signature.length !== ml_dsa65.lengths.signature) {
throw new Error(`Storage quote has a ${signature.length}-byte signature`);
}
- if (bytesToHex(blake3(publicKey)) !== quote.peer_id.toLowerCase()) {
+ if (contentAddressNative(publicKey) !== quote.peer_id.toLowerCase()) {
throw new Error("Storage quote public key is not bound to its peer ID");
}
const signedBytes = canonicalQuoteBytes(quote);
diff --git a/web/src/protocol.js b/web/src/protocol.js
index b439c394..153ce6a0 100644
--- a/web/src/protocol.js
+++ b/web/src/protocol.js
@@ -1,5 +1,5 @@
-import { blake3 } from "@noble/hashes/blake3.js";
import { bytesToHex as nobleBytesToHex } from "@noble/hashes/utils.js";
+import { verifyRecord as verifyRecordNative } from "../pkg/ant_core.js";
export const PROTOCOL_VERSION = 3;
export const PROTOCOL_NAME = "autonomi.web.poc.v3";
@@ -47,11 +47,7 @@ export function xorDistance(peerId, target) {
export function verifyChunk(address, content) {
const expected = address.trim().replace(/^0x/i, "").toLowerCase();
hexToBytes(expected, 32);
- const actual = nobleBytesToHex(blake3(content));
- if (actual !== expected) {
- throw new Error(`BLAKE3 mismatch: expected ${expected}, received ${actual}`);
- }
- return actual;
+ return verifyRecordNative(expected, content);
}
export function parseResponseFrame(frame) {
diff --git a/web/src/upload.js b/web/src/upload.js
index 5cc234b6..f5bff9ce 100644
--- a/web/src/upload.js
+++ b/web/src/upload.js
@@ -1,174 +1,36 @@
-import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
-import { blake3 } from "@noble/hashes/blake3.js";
-import { decode, encode } from "@msgpack/msgpack";
-import { deriveChunkMaterial } from "./file.js";
-import {
- BrowserNodeClient,
- bytesToHex,
- hexToBytes,
- iterativeFindClosest,
-} from "./protocol.js";
+import { encryptPublicFile as encryptPublicFileNative } from "../pkg/ant_core.js";
+import { BrowserNodeClient, iterativeFindClosest } from "./protocol.js";
import { payForStorageQuotes, verifyStorageQuote } from "./payment.js";
export const MAX_BROWSER_UPLOAD_BYTES = 64 * 1024 * 1024;
-const SELF_ENCRYPTION_MAX_CHUNK_SIZE = 4_190_208;
const MAX_STORE_TARGETS = 7;
-function numberOfChunks(fileSize) {
- if (fileSize < 3) return 0;
- if (fileSize < 3 * SELF_ENCRYPTION_MAX_CHUNK_SIZE) return 3;
- return Math.ceil(fileSize / SELF_ENCRYPTION_MAX_CHUNK_SIZE);
-}
-
-function chunkSize(fileSize, index) {
- if (fileSize < 3 * SELF_ENCRYPTION_MAX_CHUNK_SIZE) {
- return index < 2 ? Math.floor(fileSize / 3) : fileSize - 2 * Math.floor(fileSize / 3);
- }
- const count = numberOfChunks(fileSize);
- const remainder = fileSize % SELF_ENCRYPTION_MAX_CHUNK_SIZE;
- if (index < count - 2 || remainder === 0) return SELF_ENCRYPTION_MAX_CHUNK_SIZE;
- return index === count - 2 ? SELF_ENCRYPTION_MAX_CHUNK_SIZE : remainder;
-}
-
-function chunkStart(fileSize, index) {
- const count = numberOfChunks(fileSize);
- if (index === count - 1) {
- return chunkSize(fileSize, 0) * (index - 1) + chunkSize(fileSize, index - 1);
- }
- return chunkSize(fileSize, 0) * index;
-}
-
-async function defaultCompress(input) {
- const { default: brotliPromise } = await import("brotli-wasm");
- const brotli = await brotliPromise;
- return brotli.compress(input, { quality: 6 });
-}
-
-function xorPad(content, pad) {
- const output = new Uint8Array(content.length);
- for (let index = 0; index < content.length; index += 1) {
- output[index] = content[index] ^ pad[index % pad.length];
- }
- return output;
-}
-
-export function encodePublicDataMap(chunks) {
- const compact = [
- 1,
- chunks.map((chunk) => [
- chunk.index,
- Array.from(hexToBytes(chunk.dst_hash, 32)),
- Array.from(hexToBytes(chunk.src_hash, 32)),
- chunk.src_size,
- ]),
- null,
- ];
- return encode(compact, { sortKeys: false });
-}
-
-function fixedBytes(value, length, label) {
- const bytes = value instanceof Uint8Array ? value : Uint8Array.from(value ?? []);
- if (bytes.length !== length) throw new Error(`${label} must contain ${length} bytes`);
- return bytes;
-}
-
-export function decodePublicDataMap(content) {
- let dataMap;
- try {
- dataMap = decode(content);
- } catch (error) {
- throw new Error(`Public DataMap is not valid MessagePack: ${error.message}`, {
- cause: error,
- });
- }
- if (!Array.isArray(dataMap) || dataMap.length !== 3 || dataMap[0] !== 1) {
- throw new Error("Public DataMap does not use self_encryption version 1");
- }
- if (dataMap[2] !== null) {
- throw new Error("Nested DataMaps are not yet supported by the browser uploader");
- }
- if (!Array.isArray(dataMap[1]) || dataMap[1].length < 3) {
- throw new Error("Public DataMap has fewer than three chunks");
- }
- const chunks = dataMap[1]
- .map((chunk) => {
- if (!Array.isArray(chunk) || chunk.length !== 4) {
- throw new Error("Public DataMap contains an invalid chunk descriptor");
- }
- const [index, dstHash, srcHash, srcSize] = chunk;
- if (!Number.isSafeInteger(index) || index < 0) {
- throw new Error(`Invalid DataMap chunk index ${index}`);
- }
- if (!Number.isSafeInteger(srcSize) || srcSize < 1) {
- throw new Error(`Invalid DataMap plaintext chunk size ${srcSize}`);
- }
- return {
- index,
- dst_hash: bytesToHex(fixedBytes(dstHash, 32, "DataMap destination hash")),
- src_hash: bytesToHex(fixedBytes(srcHash, 32, "DataMap source hash")),
- src_size: srcSize,
- };
- })
- .sort((left, right) => left.index - right.index);
- chunks.forEach((chunk, index) => {
- if (chunk.index !== index) throw new Error("DataMap chunk indices are not contiguous");
- });
- return chunks;
-}
-
export async function encryptPublicFile(
content,
- { name = "upload.bin", contentType = "application/octet-stream", compress = defaultCompress } = {},
+ {
+ name = "upload.bin",
+ contentType = "application/octet-stream",
+ encrypt = encryptPublicFileNative,
+ } = {},
) {
if (!(content instanceof Uint8Array)) throw new Error("Upload content must be bytes");
if (content.length < 3) throw new Error("Self-encryption requires a file of at least 3 bytes");
if (content.length > MAX_BROWSER_UPLOAD_BYTES) {
throw new Error(`Browser uploads are limited to ${MAX_BROWSER_UPLOAD_BYTES} bytes`);
}
- const count = numberOfChunks(content.length);
- const plaintextChunks = Array.from({ length: count }, (_, index) => {
- const start = chunkStart(content.length, index);
- return content.slice(start, start + chunkSize(content.length, index));
- });
- const sourceHashes = plaintextChunks.map((chunk) => blake3(chunk));
- const encrypted = await Promise.all(
- plaintextChunks.map(async (plaintext, index) => {
- const descriptor = { index };
- const { pad, key, nonce } = deriveChunkMaterial(descriptor, sourceHashes, 0);
- const compressed = await compress(plaintext);
- const ciphertext = chacha20poly1305(key, nonce).encrypt(compressed);
- const bytes = xorPad(ciphertext, pad);
- return {
- content: bytes,
- info: {
- index,
- dst_hash: bytesToHex(blake3(bytes)),
- src_hash: bytesToHex(sourceHashes[index]),
- src_size: plaintext.length,
- },
- };
- }),
- );
- const chunks = encrypted.map(({ info }) => info);
- const dataMap = encodePublicDataMap(chunks);
- const address = bytesToHex(blake3(dataMap));
- const records = encrypted.map(({ content: bytes, info }) => ({
- address: info.dst_hash,
- content: bytes,
- }));
- records.push({ address, content: dataMap });
+ const encrypted = encrypt(content);
return {
descriptor: {
name,
- address,
+ address: encrypted.address,
size: content.length,
content_type: contentType || "application/octet-stream",
- blake3: bytesToHex(blake3(content)),
- data_map_size: dataMap.length,
- chunks,
+ blake3: encrypted.blake3,
+ data_map_size: encrypted.data_map_size,
+ chunks: encrypted.chunks,
replicas: 0,
},
- records,
+ records: encrypted.records,
};
}
@@ -276,14 +138,14 @@ export async function uploadPublicFile(
paymentNetwork,
file,
walletSecret,
- { onProgress = () => {}, compress = defaultCompress } = {},
+ { onProgress = () => {}, encrypt = encryptPublicFileNative } = {},
) {
const content = new Uint8Array(await file.arrayBuffer());
- onProgress(`Self-encrypting ${file.name} (${content.length.toLocaleString()} bytes)`);
+ onProgress(`Self-encrypting ${file.name} with native ant-core WASM (${content.length.toLocaleString()} bytes)`);
const encrypted = await encryptPublicFile(content, {
name: file.name,
contentType: file.type,
- compress,
+ encrypt,
});
const prepared = [];
for (let index = 0; index < encrypted.records.length; index += 1) {
diff --git a/web/src/upload.test.js b/web/src/upload.test.js
index 37b12425..e53ec6ae 100644
--- a/web/src/upload.test.js
+++ b/web/src/upload.test.js
@@ -1,81 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { brotliCompressSync, brotliDecompressSync, constants } from "node:zlib";
-import { blake3 } from "@noble/hashes/blake3.js";
-import { decryptSelfEncryptedChunk } from "./file.js";
-import { bytesToHex } from "./protocol.js";
-import {
- decodePublicDataMap,
- encodePublicDataMap,
- encryptPublicFile,
-} from "./upload.js";
+import { encryptPublicFile } from "./upload.js";
-const compress = (input) =>
- new Uint8Array(
- brotliCompressSync(input, {
- params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
- }),
- );
-const decompress = (input) => new Uint8Array(brotliDecompressSync(input));
-
-test("browser encryption reproduces the native self_encryption 0.36 vector", async () => {
- const content = new TextEncoder().encode("browser whole-file fixture\n".repeat(160));
+test("adapts native ant-core WASM output to browser file metadata", async () => {
+ const content = new TextEncoder().encode("browser fixture");
+ const dataMap = Uint8Array.of(7, 8, 9);
+ const encryptedRecord = Uint8Array.of(1, 2, 3);
const encrypted = await encryptPublicFile(content, {
name: "fixture.txt",
contentType: "text/plain",
- compress,
+ encrypt: () => ({
+ address: "44".repeat(32),
+ blake3: "55".repeat(32),
+ data_map_size: dataMap.length,
+ chunks: [
+ {
+ index: 0,
+ dst_hash: "66".repeat(32),
+ src_hash: "77".repeat(32),
+ src_size: content.length,
+ },
+ ],
+ records: [
+ { address: "66".repeat(32), content: encryptedRecord },
+ { address: "44".repeat(32), content: dataMap },
+ ],
+ }),
});
- assert.deepEqual(
- encrypted.descriptor.chunks.map((chunk) => chunk.dst_hash),
- [
- "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9",
- "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a",
- "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
- ],
- );
- assert.equal(
- encrypted.descriptor.address,
- "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b",
- );
- assert.deepEqual(
- decodePublicDataMap(encrypted.records.at(-1).content),
- encrypted.descriptor.chunks,
- );
-
- const sourceHashes = encrypted.descriptor.chunks.map((chunk) => chunk.src_hash);
- const plaintext = await Promise.all(
- encrypted.descriptor.chunks.map((chunk, index) =>
- decryptSelfEncryptedChunk(
- chunk,
- encrypted.records[index].content,
- sourceHashes.map((hash) => Uint8Array.from(Buffer.from(hash, "hex"))),
- 0,
- decompress,
- ),
- ),
- );
- const reconstructed = new Uint8Array(
- plaintext.reduce((total, chunk) => total + chunk.length, 0),
- );
- let offset = 0;
- for (const chunk of plaintext) {
- reconstructed.set(chunk, offset);
- offset += chunk.length;
- }
- assert.deepEqual(reconstructed, content);
- assert.equal(bytesToHex(blake3(reconstructed)), encrypted.descriptor.blake3);
-});
-
-test("public DataMap encoder matches rmp-serde's compact native representation", () => {
- const chunks = [0, 1, 2].map((index) => ({
- index,
- dst_hash: (11 + index).toString(16).padStart(2, "0").repeat(32),
- src_hash: (21 + index).toString(16).padStart(2, "0").repeat(32),
- src_size: 100 + index,
- }));
- const expected =
- "9301939400dc00200b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bdc00201515151515151515151515151515151515151515151515151515151515151515649401dc00200c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0cdc00201616161616161616161616161616161616161616161616161616161616161616659402dc00200d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0ddc0020171717171717171717171717171717171717171717171717171717171717171766c0";
- assert.equal(bytesToHex(encodePublicDataMap(chunks)), expected);
- assert.deepEqual(decodePublicDataMap(Uint8Array.from(Buffer.from(expected, "hex"))), chunks);
+ assert.equal(encrypted.descriptor.name, "fixture.txt");
+ assert.equal(encrypted.descriptor.address, "44".repeat(32));
+ assert.equal(encrypted.descriptor.content_type, "text/plain");
+ assert.equal(encrypted.descriptor.size, content.length);
+ assert.equal(encrypted.records.length, 2);
+ assert.deepEqual(encrypted.records[0].content, encryptedRecord);
+ assert.deepEqual(encrypted.records[1].content, dataMap);
});
diff --git a/web/src/wasm.test.js b/web/src/wasm.test.js
new file mode 100644
index 00000000..9e3fd206
--- /dev/null
+++ b/web/src/wasm.test.js
@@ -0,0 +1,50 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ decodePublicDataMap,
+ decryptPublicFile,
+ encryptPublicFile,
+ verifyRecord,
+} from "../pkg/ant_core.js";
+
+const EXPECTED_CHUNK_ADDRESSES = [
+ "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9",
+ "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a",
+ "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
+];
+
+test("generated ant-core WASM matches the native self-encryption vector", () => {
+ const content = new TextEncoder().encode("browser whole-file fixture\n".repeat(160));
+ const encrypted = encryptPublicFile(content);
+
+ assert.equal(
+ encrypted.address,
+ "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b",
+ );
+ assert.equal(
+ encrypted.blake3,
+ "e0e422267ac59c56bf032d6d830035d343369d20147dd5f6b63351a29b015f22",
+ );
+ assert.deepEqual(
+ encrypted.chunks.map((chunk) => chunk.dst_hash),
+ EXPECTED_CHUNK_ADDRESSES,
+ );
+ assert.equal(encrypted.records.length, 4);
+
+ for (const record of encrypted.records) {
+ assert(record.content instanceof Uint8Array);
+ assert.equal(verifyRecord(record.address, record.content), record.address);
+ }
+
+ const dataMap = encrypted.records.at(-1).content;
+ const chunks = encrypted.records.slice(0, -1).map((record) => record.content);
+ assert.deepEqual(
+ decodePublicDataMap(dataMap).map((chunk) => chunk.dst_hash),
+ EXPECTED_CHUNK_ADDRESSES,
+ );
+ assert.deepEqual(decryptPublicFile(dataMap, chunks), content);
+
+ const tampered = chunks.map((chunk) => chunk.slice());
+ tampered[0][0] ^= 1;
+ assert.throws(() => decryptPublicFile(dataMap, tampered), /BLAKE3 mismatch/);
+});
From ca21f0d53d8d0dc4a1cbab179d30981b1f3b719c Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Wed, 5 Aug 2026 18:00:03 +0200
Subject: [PATCH 05/31] feat(web): use shared Rust DHT lookup runner
---
Cargo.lock | 12 +-
Cargo.toml | 4 +
README.md | 6 +-
ant-core/Cargo.toml | 3 +
ant-core/src/browser.rs | 274 +++++++++++++++++-
.../ADR-0003-direct-browser-read-client.md | 23 +-
web/README.md | 29 +-
web/src/protocol.js | 83 ++----
web/src/protocol.test.js | 7 -
web/src/wasm.test.js | 51 ++++
10 files changed, 399 insertions(+), 93 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 35e81e4e..2a43938a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -861,6 +861,7 @@ dependencies = [
"rand 0.8.6",
"reqwest 0.12.28",
"rmp-serde",
+ "saorsa-dht-lookup",
"self-replace",
"self_encryption",
"semver 1.0.28",
@@ -882,6 +883,7 @@ dependencies = [
"tracing-subscriber",
"utoipa",
"wasm-bindgen",
+ "wasm-bindgen-futures",
"windows-sys 0.61.2",
"xor_name",
"zip",
@@ -5215,8 +5217,6 @@ dependencies = [
[[package]]
name = "saorsa-core"
version = "0.26.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "454529f8a72b4cf22f7d9c3b009ad9d4ba78520e11f6444ae460795d55c002da"
dependencies = [
"anyhow",
"async-trait",
@@ -5231,6 +5231,7 @@ dependencies = [
"parking_lot",
"postcard",
"rand 0.8.6",
+ "saorsa-dht-lookup",
"saorsa-pqc 0.5.1",
"saorsa-transport",
"serde",
@@ -5244,6 +5245,10 @@ dependencies = [
"wyz",
]
+[[package]]
+name = "saorsa-dht-lookup"
+version = "0.1.0"
+
[[package]]
name = "saorsa-pqc"
version = "0.4.2"
@@ -5330,12 +5335,11 @@ dependencies = [
[[package]]
name = "saorsa-transport"
version = "0.35.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3284026c300f642077315b782462b22558e24d621265a8deeae23287b1c5542"
dependencies = [
"anyhow",
"async-trait",
"aws-lc-rs",
+ "base64",
"blake3",
"bytes",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index f3978b22..07d8c6e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,7 @@
[workspace]
members = ["ant-core", "ant-cli"]
resolver = "2"
+
+[patch.crates-io]
+saorsa-core = { path = "../saorsa-core-web-support" }
+saorsa-transport = { path = "../saorsa-transport-web-support" }
diff --git a/README.md b/README.md
index 8ffc044f..e48d81ad 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@ This project provides two Rust crates and a browser client:
- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Its portable immutable-data core also builds for browsers with the `browser-wasm` feature.
- **ant-cli** — A thin CLI binary (`ant`) built on `ant-core`.
-- **web** — A direct WebTransport client and test site. It performs browser-side
- closest-node lookup and uses `ant-core` through WASM to self-encrypt and
- reconstruct complete public files without a data gateway.
+- **web** — A direct WebTransport client and test site. It drives Saorsa's
+ shared iterative lookup engine and uses `ant-core` through WASM to
+ self-encrypt and reconstruct complete public files without a data gateway.
Data on Autonomi is **content-addressed**. Files are split into encrypted chunks (via [self-encryption](https://en.wikipedia.org/wiki/Convergent_encryption)), each stored at an XOR address derived from its content. A `DataMap` tracks which chunks belong to a file. Payments for storage are made on an EVM-compatible blockchain (Arbitrum).
diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml
index 11c3c93e..7ebfd32a 100644
--- a/ant-core/Cargo.toml
+++ b/ant-core/Cargo.toml
@@ -21,6 +21,7 @@ rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
self_encryption = "0.36"
+saorsa-dht-lookup = { version = "0.1.0", path = "../../saorsa-core-web-support/crates/saorsa-dht-lookup" }
thiserror = "2"
# Node management
@@ -81,6 +82,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = tr
js-sys = { version = "0.3", optional = true }
serde-wasm-bindgen = { version = "0.6", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
+wasm-bindgen-futures = { version = "0.4", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# self_encryption/rand use getrandom 0.2. Browser entropy is supplied by the
@@ -136,6 +138,7 @@ browser-wasm = [
"dep:js-sys",
"dep:serde-wasm-bindgen",
"dep:wasm-bindgen",
+ "dep:wasm-bindgen-futures",
]
# Enable `LocalDevnet` (ant-core/src/node/devnet.rs) which wraps
# `ant_node::devnet::Devnet` and an Anvil EVM testnet.
diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs
index 9efe5dfe..0e9f1ed8 100644
--- a/ant-core/src/browser.rs
+++ b/ant-core/src/browser.rs
@@ -213,8 +213,280 @@ fn chunk_infos(data_map: &DataMap) -> Vec {
#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
mod wasm {
use super::{content_address, decrypt_public_file, encrypt_public_file, verify_record};
- use js_sys::{Array, Uint8Array};
+ use js_sys::{Array, Function, Promise, Uint8Array};
+ use saorsa_dht_lookup::{
+ run_iterative_lookup, IterativeLookup, LookupConfig, LookupKey, LookupNode, LookupQuery,
+ LookupQueryOutcome,
+ };
+ use serde::{Deserialize, Serialize};
+ use std::collections::HashMap;
use wasm_bindgen::prelude::*;
+ use wasm_bindgen_futures::JsFuture;
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ #[serde(untagged)]
+ enum BrowserLookupEndpoint {
+ Structured { multiaddr: String },
+ Multiaddr(String),
+ }
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ struct BrowserLookupNode {
+ peer_id: String,
+ #[serde(default)]
+ native_addresses: Vec,
+ #[serde(default)]
+ reliability: f64,
+ #[serde(default)]
+ webtransport: Option,
+ }
+
+ #[derive(Debug, Serialize)]
+ struct BrowserLookupBatch {
+ target: String,
+ count: usize,
+ iteration: usize,
+ candidates: Vec,
+ }
+
+ #[derive(Debug, Deserialize)]
+ #[serde(tag = "status", rename_all = "snake_case")]
+ enum BrowserLookupQueryOutcome {
+ Succeeded {
+ responder: String,
+ #[serde(default)]
+ candidates: Vec,
+ },
+ Failed {
+ responder: String,
+ },
+ Unresponsive {
+ responder: String,
+ },
+ }
+
+ #[derive(Debug, Clone)]
+ struct BrowserLookupCandidate {
+ peer_id: LookupKey,
+ wire: BrowserLookupNode,
+ }
+
+ impl LookupNode for BrowserLookupCandidate {
+ fn lookup_peer_id(&self) -> LookupKey {
+ self.peer_id
+ }
+ }
+
+ impl BrowserLookupCandidate {
+ fn parse(mut wire: BrowserLookupNode) -> Result {
+ let peer_id = parse_lookup_key(&wire.peer_id, "peer ID")?;
+ wire.peer_id = hex::encode(peer_id);
+ Ok(Self { peer_id, wire })
+ }
+ }
+
+ /// Shared Saorsa iterative lookup state driven by browser WebTransport.
+ #[wasm_bindgen(js_name = BrowserIterativeLookup)]
+ pub struct BrowserIterativeLookup {
+ lookup: IterativeLookup,
+ known_endpoints: HashMap,
+ }
+
+ #[wasm_bindgen(js_class = BrowserIterativeLookup)]
+ impl BrowserIterativeLookup {
+ /// Construct a browser lookup using the same scheduler as native QUIC.
+ #[wasm_bindgen(constructor)]
+ pub fn new(
+ target: &str,
+ count: usize,
+ alpha: usize,
+ max_iterations: usize,
+ ) -> Result {
+ let target = parse_lookup_key(target, "lookup target")?;
+ let config = LookupConfig {
+ count,
+ alpha,
+ max_iterations,
+ ..LookupConfig::saorsa(count)
+ };
+ let lookup = IterativeLookup::new(target, config)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ Ok(Self {
+ lookup,
+ known_endpoints: HashMap::new(),
+ })
+ }
+
+ /// Add validated bootstrap or FIND_NODE candidates.
+ #[wasm_bindgen(js_name = addCandidates)]
+ pub fn add_candidates(&mut self, nodes: JsValue) -> Result<(), JsValue> {
+ for candidate in parse_lookup_nodes(nodes)? {
+ self.add_candidate(candidate);
+ }
+ Ok(())
+ }
+
+ /// Run the complete shared Saorsa walk through a WebTransport batch callback.
+ #[wasm_bindgen(js_name = run)]
+ pub async fn run(&mut self, query_batch: Function) -> Result {
+ let mut query = BrowserLookupQuery {
+ callback: query_batch,
+ known_endpoints: &mut self.known_endpoints,
+ };
+ run_iterative_lookup(&mut self.lookup, &mut query)
+ .await
+ .map(|termination| format!("{termination:?}"))
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Successful responders in final closest-first order.
+ #[wasm_bindgen(js_name = results)]
+ pub fn results(&self) -> Result {
+ let nodes = self
+ .lookup
+ .results()
+ .into_iter()
+ .map(|candidate| candidate.wire)
+ .collect::>();
+ serde_wasm_bindgen::to_value(&nodes)
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Peer IDs selected for network queries, in query order.
+ #[wasm_bindgen(js_name = queriedPeers)]
+ pub fn queried_peers(&self) -> Result {
+ let peers = self
+ .lookup
+ .queried_peers()
+ .iter()
+ .map(hex::encode)
+ .collect::>();
+ serde_wasm_bindgen::to_value(&peers)
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+ }
+
+ impl BrowserIterativeLookup {
+ fn add_candidate(&mut self, candidate: BrowserLookupCandidate) {
+ if let Some(candidate) =
+ resolve_candidate_endpoint(&mut self.known_endpoints, candidate)
+ {
+ let _ = self.lookup.add_candidate(candidate);
+ }
+ }
+ }
+
+ struct BrowserLookupQuery<'a> {
+ callback: Function,
+ known_endpoints: &'a mut HashMap,
+ }
+
+ impl LookupQuery for BrowserLookupQuery<'_> {
+ type Error = String;
+
+ async fn query_batch(
+ &mut self,
+ target: LookupKey,
+ count: usize,
+ iteration: usize,
+ batch: Vec,
+ ) -> Result>, Self::Error> {
+ let request = BrowserLookupBatch {
+ target: hex::encode(target),
+ count,
+ iteration,
+ candidates: batch.into_iter().map(|candidate| candidate.wire).collect(),
+ };
+ let request = serde_wasm_bindgen::to_value(&request)
+ .map_err(|error| format!("could not encode lookup batch: {error}"))?;
+ let returned = self
+ .callback
+ .call1(&JsValue::NULL, &request)
+ .map_err(js_error_message)?;
+ let returned = JsFuture::from(Promise::resolve(&returned))
+ .await
+ .map_err(js_error_message)?;
+ let outcomes: Vec = serde_wasm_bindgen::from_value(returned)
+ .map_err(|error| format!("invalid lookup batch response: {error}"))?;
+
+ outcomes
+ .into_iter()
+ .map(|outcome| match outcome {
+ BrowserLookupQueryOutcome::Succeeded {
+ responder,
+ candidates,
+ } => {
+ let responder = parse_lookup_key(&responder, "lookup responder")
+ .map_err(js_error_message)?;
+ let candidates = candidates
+ .into_iter()
+ .map(BrowserLookupCandidate::parse)
+ .collect::, _>>()
+ .map_err(js_error_message)?
+ .into_iter()
+ .filter_map(|candidate| {
+ resolve_candidate_endpoint(self.known_endpoints, candidate)
+ })
+ .collect();
+ Ok(LookupQueryOutcome::Succeeded {
+ responder,
+ candidates,
+ })
+ }
+ BrowserLookupQueryOutcome::Failed { responder } => {
+ parse_lookup_key(&responder, "lookup responder")
+ .map(|responder| LookupQueryOutcome::Failed { responder })
+ .map_err(js_error_message)
+ }
+ BrowserLookupQueryOutcome::Unresponsive { responder } => {
+ parse_lookup_key(&responder, "lookup responder")
+ .map(|responder| LookupQueryOutcome::Unresponsive { responder })
+ .map_err(js_error_message)
+ }
+ })
+ .collect()
+ }
+ }
+
+ fn resolve_candidate_endpoint(
+ known_endpoints: &mut HashMap,
+ mut candidate: BrowserLookupCandidate,
+ ) -> Option {
+ if let Some(endpoint) = candidate.wire.webtransport.clone() {
+ known_endpoints.insert(candidate.peer_id, endpoint);
+ } else if let Some(endpoint) = known_endpoints.get(&candidate.peer_id) {
+ candidate.wire.webtransport = Some(endpoint.clone());
+ }
+ candidate.wire.webtransport.as_ref()?;
+ Some(candidate)
+ }
+
+ fn js_error_message(value: JsValue) -> String {
+ value
+ .as_string()
+ .unwrap_or_else(|| format!("JavaScript lookup callback failed: {value:?}"))
+ }
+
+ fn parse_lookup_nodes(value: JsValue) -> Result, JsValue> {
+ let nodes: Vec = serde_wasm_bindgen::from_value(value)
+ .map_err(|error| JsValue::from_str(&format!("invalid lookup nodes: {error}")))?;
+ nodes
+ .into_iter()
+ .map(BrowserLookupCandidate::parse)
+ .collect()
+ }
+
+ fn parse_lookup_key(value: &str, label: &str) -> Result {
+ let value = value.strip_prefix("0x").unwrap_or(value);
+ let bytes = hex::decode(value)
+ .map_err(|error| JsValue::from_str(&format!("invalid {label}: {error}")))?;
+ bytes.try_into().map_err(|bytes: Vec| {
+ JsValue::from_str(&format!(
+ "invalid {label}: expected 32 bytes, received {}",
+ bytes.len()
+ ))
+ })
+ }
/// Install a readable panic hook for browser developer tools.
#[wasm_bindgen(start)]
diff --git a/docs/adr/ADR-0003-direct-browser-read-client.md b/docs/adr/ADR-0003-direct-browser-read-client.md
index b9410205..0d7206a4 100644
--- a/docs/adr/ADR-0003-direct-browser-read-client.md
+++ b/docs/adr/ADR-0003-direct-browser-read-client.md
@@ -57,6 +57,8 @@ exports browser-safe immutable-data operations through `wasm-bindgen`.
The Rust/WASM core will:
+- run iterative closest-node lookup through Saorsa's transport-independent
+ `run_iterative_lookup` driver and `LookupQuery` interface;
- self-encrypt complete public files with the same `self_encryption 0.36`
implementation used by the Rust client;
- encode and decode the native MessagePack `DataMap` representation;
@@ -72,8 +74,8 @@ The `web/` package will remain responsible for browser-specific orchestration:
`/certhash` SHA-256 multihashes internally, and pass them to WebTransport;
- require `/p2p/` in every address, verify its `HELLO` peer ID, and
reject discovered endpoint/peer mismatches;
-- perform iterative `FIND_NODE` queries using 256-bit XOR ordering, `K = 20`,
- and `ALPHA = 3`;
+- execute each WebTransport `FIND_NODE` request selected by the Rust/WASM
+ lookup engine and return its response or failure to that engine;
- query closest direct endpoints with `GET_CHUNK`, retrying `not_found` and
unavailable nodes without routing bytes through the manifest service;
- call the Rust/WASM core to self-encrypt selected public files and generate
@@ -120,6 +122,14 @@ and ML-DSA verification types without enabling native networking. At that
point those compatibility-sensitive operations should also move behind the
Rust/WASM boundary.
+The shared lookup engine is intentionally a small Saorsa crate rather than a
+second implementation inside `ant-core`. Native `DhtNetworkManager` and the
+browser WASM adapter both drive the same candidate queue, peer-state, α-batch,
+and convergence implementation. Native QUIC retains transport authentication,
+address-report consensus, failure-cache integration, and trust updates; the
+browser adapter retains WebTransport session establishment and endpoint
+validation.
+
For compatibility with the local launcher, the bootstrap manifest still
carries a resolved JSON view of the public root DataMap alongside its ordinary
on-network DataMap address. The download path does not use that copy to select
@@ -147,8 +157,8 @@ metadata chain.
- The current client reconstructs files in memory and the local launcher caps
public files at 64 MiB; upload encryption and reconstruction are not yet
streaming.
-- JavaScript lookup and quote-verification behavior must remain aligned with
- native Kademlia and protocol rules until transport-free Rust APIs exist.
+- JavaScript quote-verification behavior must remain aligned with native
+ protocol rules until transport-free `ant-protocol` APIs exist.
- The initial WASM module is approximately 1.4 MiB uncompressed and browser
file processing is still in memory.
- Certificate and endpoint verification adds bootstrap-record lifecycle work.
@@ -164,7 +174,10 @@ metadata chain.
- Rust unit tests cover an exact native `self_encryption 0.36` wire vector,
public DataMap generation, round-trip reconstruction, and tamper rejection.
-- JavaScript unit tests cover fixed-width identifiers, XOR ordering,
+- Saorsa engine tests cover XOR ordering, α limits, final peer states, bounded
+ candidate eviction, multi-round discovery, and top-K convergence; the native
+ DHT manager and browser WASM adapter both invoke the same generic runner.
+- JavaScript unit tests cover fixed-width identifiers,
bidirectional binary framing, manifest/payment validation, quote signatures,
the native Keccak-256 EVM quote-hash vector, and the browser orchestration
around the Rust/WASM boundary.
diff --git a/web/README.md b/web/README.md
index 0964bbd4..8f156ea3 100644
--- a/web/README.md
+++ b/web/README.md
@@ -2,11 +2,12 @@
This web application is the browser-facing client for ADR-0009. It loads a
local testnet bootstrap manifest, connects directly to storage nodes over
-WebTransport, and performs the XOR closest-node lookup in the browser. The
-portable part of the Rust `ant-core` library is compiled to WASM and performs
-native self-encryption, public DataMap serialization, reconstruction, and
-BLAKE3 content verification. The thin JavaScript layer retrieves and uploads
-records, uses the browser wallet/payment APIs, and drives the page.
+WebTransport, and performs closest-node lookup through Saorsa's shared Rust
+lookup engine. The portable part of `ant-core` is compiled to WASM and drives
+the Kademlia walk, native self-encryption, public DataMap serialization,
+reconstruction, and BLAKE3 content verification. The thin JavaScript layer
+executes WebTransport requests, retrieves and uploads records, uses the browser
+wallet/payment APIs, and drives the page.
The node-side WebTransport listener and testnet manifest API live in the
`ant-node-web-support` sibling repository. No HTTP gateway performs lookup or
@@ -88,7 +89,8 @@ manifest from port 25000 and fills in the default file:
1. **Load testnet** refreshes the manifest and direct multiaddress catalog.
2. **Connect** parses the first node multiaddress and performs a pinned
WebTransport `HELLO`.
-3. **Find closest** runs the iterative lookup in the browser.
+3. **Find closest** runs Saorsa's iterative lookup engine in WASM, with
+ JavaScript executing its WebTransport query batches.
4. Under **Paid public file upload**, choose a file, paste the funded private
key printed by ant-devnet, then select **Pay and upload file**. Rust/WASM
performs encryption and DataMap generation; quote/commitment verification,
@@ -124,10 +126,11 @@ cargo check -p ant-core --target wasm32-unknown-unknown \
cargo test -p ant-core --lib browser::tests
```
-The tests cover fixed-width IDs, XOR ordering, bidirectional binary framing,
-browser manifest/payment validation, signed quote verification including the
-native Keccak-256 EVM quote hash, and a Rust `self_encryption 0.36` wire vector
-with native/WASM round-trip and tamper verification.
+The tests cover Saorsa's shared lookup engine and generic query driver, fixed-width IDs,
+bidirectional binary framing, browser manifest/payment validation, signed
+quote verification including the native Keccak-256 EVM quote hash, and a Rust
+`self_encryption 0.36` wire vector with native/WASM round-trip and tamper
+verification.
Cross-repository live verification additionally starts the node testnet,
downloads all public-file records through WebTransport, reconstructs the
original bytes, pays a real quote on local Anvil, uploads a fresh record
@@ -135,8 +138,10 @@ through the ordinary node payment validator, and reads it back.
## Current boundary
-JavaScript currently owns WebTransport stream handling, iterative lookup,
-Ethers payment submission, ML-DSA quote verification, and DOM/save APIs.
+JavaScript currently owns WebTransport stream handling, Ethers payment
+submission, ML-DSA quote verification, and DOM/save APIs. Rust/WASM owns the
+complete iterative lookup loop; JavaScript implements only its transport batch
+callback.
`ant-protocol 2.3.1` still enables native Saorsa/Tokio networking and cannot be
linked into a browser WASM build; a future transport-free feature can move the
remaining quote and multiaddress verification into Rust.
diff --git a/web/src/protocol.js b/web/src/protocol.js
index 153ce6a0..af8b2205 100644
--- a/web/src/protocol.js
+++ b/web/src/protocol.js
@@ -1,5 +1,8 @@
import { bytesToHex as nobleBytesToHex } from "@noble/hashes/utils.js";
-import { verifyRecord as verifyRecordNative } from "../pkg/ant_core.js";
+import {
+ BrowserIterativeLookup as BrowserIterativeLookupNative,
+ verifyRecord as verifyRecordNative,
+} from "../pkg/ant_core.js";
export const PROTOCOL_VERSION = 3;
export const PROTOCOL_NAME = "autonomi.web.poc.v3";
@@ -34,16 +37,6 @@ export function bytesToHex(bytes) {
return nobleBytesToHex(bytes);
}
-export function xorDistance(peerId, target) {
- const peer = hexToBytes(peerId, 32);
- const key = hexToBytes(target, 32);
- let distance = 0n;
- for (let index = 0; index < peer.length; index += 1) {
- distance = (distance << 8n) | BigInt(peer[index] ^ key[index]);
- }
- return distance;
-}
-
export function verifyChunk(address, content) {
const expected = address.trim().replace(/^0x/i, "").toLowerCase();
hexToBytes(expected, 32);
@@ -455,9 +448,8 @@ export async function iterativeFindClosest(
}
const clients = new Map();
- const known = new Map();
- const queried = new Set();
const failures = [];
+ const seedNodes = [];
const clientFor = (endpoint) => {
const key = endpointKey(endpoint);
@@ -476,7 +468,7 @@ export async function iterativeFindClosest(
try {
const client = clientFor(endpoint);
const hello = await client.hello();
- known.set(hello.peer_id, {
+ seedNodes.push({
peer_id: hello.peer_id,
native_addresses: [],
reliability: 1,
@@ -489,70 +481,39 @@ export async function iterativeFindClosest(
}
}),
);
- if (known.size === 0) {
+ if (seedNodes.length === 0) {
for (const client of clients.values()) client.close();
const detail = failures.map(({ error }) => error.message).join("; ");
throw new Error(`Could not connect to any WebTransport seed: ${detail}`);
}
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
- const ordered = [...known.values()].sort((left, right) => {
- const a = xorDistance(left.peer_id, target);
- const b = xorDistance(right.peer_id, target);
- return a < b ? -1 : a > b ? 1 : 0;
- });
- const candidates = ordered
- .filter((node) => node.webtransport && !queried.has(node.peer_id))
- .slice(0, alpha);
- if (candidates.length === 0) break;
-
- let discovered = 0;
- await Promise.all(
+ const lookup = new BrowserIterativeLookupNative(target, k, alpha, maxIterations);
+ lookup.addCandidates(seedNodes);
+ await lookup.run(async ({ target: lookupTarget, count, iteration, candidates }) =>
+ Promise.all(
candidates.map(async (candidate) => {
- queried.add(candidate.peer_id);
try {
const candidateClient = clientFor(candidate.webtransport);
if (!candidateClient.peerId) await candidateClient.hello();
- const nodes = await candidateClient.findNode(target, k);
+ const nodes = await candidateClient.findNode(lookupTarget, count);
onProgress(
- `Iteration ${iteration + 1}: ${candidate.peer_id} returned ${nodes.length} nodes`,
+ `Iteration ${iteration}: ${candidate.peer_id} returned ${nodes.length} nodes`,
);
- for (const node of nodes) {
- const existing = known.get(node.peer_id);
- if (!existing) discovered += 1;
- known.set(node.peer_id, {
- ...existing,
- ...node,
- webtransport: node.webtransport ?? existing?.webtransport,
- });
- }
+ return {
+ status: "succeeded",
+ responder: candidate.peer_id,
+ candidates: nodes,
+ };
} catch (error) {
failures.push({ peerId: candidate.peer_id, error });
onProgress(`Query ${candidate.peer_id} failed: ${error.message}`);
+ return { status: "failed", responder: candidate.peer_id };
}
}),
- );
+ ),
+ );
- const remainingQueryable = [...known.values()]
- .sort((left, right) => {
- const a = xorDistance(left.peer_id, target);
- const b = xorDistance(right.peer_id, target);
- return a < b ? -1 : a > b ? 1 : 0;
- })
- .slice(0, k)
- .some((node) => node.webtransport && !queried.has(node.peer_id));
- if (discovered === 0 && !remainingQueryable) break;
- }
-
- const nodes = [...known.values()]
- .sort((left, right) => {
- const a = xorDistance(left.peer_id, target);
- const b = xorDistance(right.peer_id, target);
- return a < b ? -1 : a > b ? 1 : 0;
- })
- .slice(0, k);
-
- return { nodes, queried: [...queried], failures, clients };
+ return { nodes: lookup.results(), queried: lookup.queriedPeers(), failures, clients };
}
export async function getChunkFromClosest(
diff --git a/web/src/protocol.test.js b/web/src/protocol.test.js
index 833ca2a8..5959317d 100644
--- a/web/src/protocol.test.js
+++ b/web/src/protocol.test.js
@@ -9,7 +9,6 @@ import {
parseResponseFrame,
parseWebTransportMultiaddr,
verifyChunk,
- xorDistance,
} from "./protocol.js";
test("hex conversion enforces fixed widths", () => {
@@ -19,12 +18,6 @@ test("hex conversion enforces fixed widths", () => {
assert.throws(() => hexToBytes("zz", 1), /hexadecimal/);
});
-test("XOR distance is an unsigned 256-bit ordering value", () => {
- assert.equal(xorDistance("00".repeat(32), "00".repeat(32)), 0n);
- assert.equal(xorDistance("00".repeat(31) + "01", "00".repeat(32)), 1n);
- assert(xorDistance("80" + "00".repeat(31), "00".repeat(32)) > 1n);
-});
-
test("response framing preserves a raw binary body", () => {
const header = new TextEncoder().encode(
JSON.stringify({
diff --git a/web/src/wasm.test.js b/web/src/wasm.test.js
index 9e3fd206..8f376039 100644
--- a/web/src/wasm.test.js
+++ b/web/src/wasm.test.js
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
+ BrowserIterativeLookup,
decodePublicDataMap,
decryptPublicFile,
encryptPublicFile,
@@ -13,6 +14,56 @@ const EXPECTED_CHUNK_ADDRESSES = [
"d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
];
+function lookupNode(lastByte, stringEndpoint = false) {
+ return {
+ peer_id: `${"00".repeat(31)}${lastByte.toString(16).padStart(2, "0")}`,
+ native_addresses: [],
+ reliability: 1,
+ webtransport: stringEndpoint ? `/test/${lastByte}` : { multiaddr: `/test/${lastByte}` },
+ };
+}
+
+test("generated WASM drives Saorsa's complete shared iterative lookup", async () => {
+ const lookup = new BrowserIterativeLookup("00".repeat(32), 2, 2, 20);
+ lookup.addCandidates([lookupNode(3), lookupNode(1), lookupNode(2, true)]);
+ const batches = [];
+ const termination = await lookup.run(async ({ iteration, candidates }) => {
+ batches.push(candidates.map((node) => node.peer_id));
+ if (iteration === 1) {
+ assert.equal(candidates[1].webtransport, "/test/2");
+ return [
+ {
+ status: "succeeded",
+ responder: candidates[0].peer_id,
+ candidates: [lookupNode(0)],
+ },
+ { status: "failed", responder: candidates[1].peer_id },
+ ];
+ }
+ return candidates.map((candidate) => ({
+ status: "succeeded",
+ responder: candidate.peer_id,
+ candidates: [],
+ }));
+ });
+
+ assert.equal(termination, "Exhausted");
+ assert.deepEqual(batches, [
+ [lookupNode(1).peer_id, lookupNode(2).peer_id],
+ [lookupNode(0).peer_id, lookupNode(3).peer_id],
+ ]);
+ assert.deepEqual(
+ lookup.results().map((node) => node.peer_id),
+ [lookupNode(0).peer_id, lookupNode(1).peer_id],
+ );
+ assert.deepEqual(lookup.queriedPeers(), [
+ lookupNode(1).peer_id,
+ lookupNode(2).peer_id,
+ lookupNode(0).peer_id,
+ lookupNode(3).peer_id,
+ ]);
+});
+
test("generated ant-core WASM matches the native self-encryption vector", () => {
const content = new TextEncoder().encode("browser whole-file fixture\n".repeat(160));
const encrypted = encryptPublicFile(content);
From b01971ea1cf01d848f6862ed001234a995085c82 Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:39:09 +0200
Subject: [PATCH 06/31] feat(web): use Saorsa WebRTC Direct transport
---
README.md | 6 +-
ant-core/src/browser.rs | 12 +-
.../ADR-0003-direct-browser-read-client.md | 77 +-
web/README.md | 41 +-
web/index.html | 7 +-
web/package-lock.json | 139 +++
web/src/file.js | 86 +-
web/src/main.js | 32 +-
web/src/manifest.js | 8 +-
web/src/manifest.test.js | 24 +-
web/src/payment.js | 2 +-
web/src/payment.test.js | 2 +-
web/src/protocol.js | 877 +++++++++++++-----
web/src/protocol.test.js | 461 ++++-----
web/src/upload.js | 224 +++--
web/src/wasm.test.js | 4 +-
16 files changed, 1352 insertions(+), 650 deletions(-)
diff --git a/README.md b/README.md
index e48d81ad..75bac8fc 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ This project provides two Rust crates and a browser client:
- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Its portable immutable-data core also builds for browsers with the `browser-wasm` feature.
- **ant-cli** — A thin CLI binary (`ant`) built on `ant-core`.
-- **web** — A direct WebTransport client and test site. It drives Saorsa's
+- **web** — A direct WebRTC Direct client and test site. It drives Saorsa's
shared iterative lookup engine and uses `ant-core` through WASM to
self-encrypt and reconstruct complete public files without a data gateway.
@@ -21,9 +21,9 @@ the browser-enabled node devnet, then run the site:
```bash
# In ant-node-web-support
-cargo run --features webtransport-poc --bin ant-devnet -- \
+cargo run --features webrtc-direct --bin ant-devnet -- \
--preset minimal --base-port 23000 \
- --webtransport --webtransport-base-port 24000 \
+ --webrtc-direct --webrtc-direct-base-port 24000 \
--serve-port 25000 --enable-evm --enable-logging
# In ant-client-web-support/web
diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs
index 0e9f1ed8..899b7b53 100644
--- a/ant-core/src/browser.rs
+++ b/ant-core/src/browser.rs
@@ -238,7 +238,7 @@ mod wasm {
#[serde(default)]
reliability: f64,
#[serde(default)]
- webtransport: Option,
+ webrtc_direct: Option,
}
#[derive(Debug, Serialize)]
@@ -285,7 +285,7 @@ mod wasm {
}
}
- /// Shared Saorsa iterative lookup state driven by browser WebTransport.
+ /// Shared Saorsa iterative lookup state driven by browser WebRtcDirect.
#[wasm_bindgen(js_name = BrowserIterativeLookup)]
pub struct BrowserIterativeLookup {
lookup: IterativeLookup,
@@ -326,7 +326,7 @@ mod wasm {
Ok(())
}
- /// Run the complete shared Saorsa walk through a WebTransport batch callback.
+ /// Run the complete shared Saorsa walk through a WebRtcDirect batch callback.
#[wasm_bindgen(js_name = run)]
pub async fn run(&mut self, query_batch: Function) -> Result {
let mut query = BrowserLookupQuery {
@@ -452,12 +452,12 @@ mod wasm {
known_endpoints: &mut HashMap,
mut candidate: BrowserLookupCandidate,
) -> Option {
- if let Some(endpoint) = candidate.wire.webtransport.clone() {
+ if let Some(endpoint) = candidate.wire.webrtc_direct.clone() {
known_endpoints.insert(candidate.peer_id, endpoint);
} else if let Some(endpoint) = known_endpoints.get(&candidate.peer_id) {
- candidate.wire.webtransport = Some(endpoint.clone());
+ candidate.wire.webrtc_direct = Some(endpoint.clone());
}
- candidate.wire.webtransport.as_ref()?;
+ candidate.wire.webrtc_direct.as_ref()?;
Some(candidate)
}
diff --git a/docs/adr/ADR-0003-direct-browser-read-client.md b/docs/adr/ADR-0003-direct-browser-read-client.md
index 0d7206a4..32e2bceb 100644
--- a/docs/adr/ADR-0003-direct-browser-read-client.md
+++ b/docs/adr/ADR-0003-direct-browser-read-client.md
@@ -1,13 +1,13 @@
-# ADR-0003: Direct browser immutable-data client over WebTransport
+# ADR-0003: Direct browser immutable-data client over WebRTC Direct
- **Status:** Proposed
- **Date:** 2026-08-03
-- **Last amended:** 2026-08-05
+- **Last amended:** 2026-08-25
- **Decision owners:**
- **Reviewers:**
- **Supersedes:** none
- **Superseded by:** none
-- **Related:** ant-node ADR-0009; W3C WebTransport
+- **Related:** ant-node ADR-0009; Saorsa WebRTC Direct transport
## Context
@@ -15,8 +15,9 @@ The Autonomi web client must perform closest-node lookup, immutable-data
download, quote verification, payment, and upload itself. Sending those
operations through an HTTP application gateway would make the gateway an
availability, privacy, and bandwidth chokepoint.
-Browsers cannot use the native Saorsa QUIC protocol, but they can establish
-WebTransport sessions with browser-compatible node listeners.
+Browsers cannot use the native Saorsa QUIC protocol, but they can dial
+Saorsa-owned WebRTC Direct listeners over ICE-lite, DTLS, SCTP, and
+DataChannels without a signaling server or a libp2p layer.
This repository owns the client and UI side of that split. Nodes own transport
termination, local DHT answers, storage reads and paid writes, endpoint
@@ -26,8 +27,12 @@ records, and testnet bootstrap-manifest production under ant-node ADR-0009.
- File bytes must travel directly from a storage node to the browser.
- The browser must own iterative XOR lookup rather than ask a gateway to do it.
-- Self-signed node certificates must be authenticated through hashes embedded
- in self-contained node multiaddresses, without a separate client argument.
+- A bootstrap list must remain usable for months: each node therefore persists
+ its self-signed DTLS certificate and embeds one stable fingerprint in its
+ self-contained multiaddress.
+- Bootstrap endpoints must use literal IP addresses. Running a node must not
+ require DNS, a public CA certificate, an HTTPS endpoint, or a signaling
+ service.
- Downloaded immutable content must be verified before it is exposed to users.
- The wallet secret must be provided at runtime, used only by the local EVM
signer, and never sent to a node or persisted in bootstrap metadata.
@@ -68,13 +73,20 @@ The Rust/WASM core will:
The `web/` package will remain responsible for browser-specific orchestration:
-- load a versioned browser bootstrap manifest containing WebTransport
+- load a versioned browser bootstrap manifest containing WebRTC Direct
multiaddresses and published immutable-file metadata;
-- accept a single multiaddress per seed, extract its one or two
- `/certhash` SHA-256 multihashes internally, and pass them to WebTransport;
-- require `/p2p/` in every address, verify its `HELLO` peer ID, and
- reject discovered endpoint/peer mismatches;
-- execute each WebTransport `FIND_NODE` request selected by the Rust/WASM
+- accept one canonical multiaddress per seed, extract its single `/certhash`
+ SHA-256 multihash internally, and construct a native browser
+ `RTCPeerConnection` description from its literal IP and UDP port;
+- open one persistent reliable ordered `RTCDataChannel` per association and
+ exchange bounded, declared-length request/response frames directly, without
+ Noise, multistream negotiation, or libp2p stream envelopes;
+- reuse authenticated associations through a bounded client pool for the
+ duration of a complete upload or download instead of opening a new peer
+ connection for every encrypted record;
+- require `/p2p/` in every address, verify the challenge-bound ML-DSA
+ `HELLO` signature, and reject certificate, endpoint, or peer mismatches;
+- execute each WebRTC Direct `FIND_NODE` request selected by the Rust/WASM
lookup engine and return its response or failure to that engine;
- query closest direct endpoints with `GET_CHUNK`, retrying `not_found` and
unavailable nodes without routing bytes through the manifest service;
@@ -86,7 +98,7 @@ The `web/` package will remain responsible for browser-specific orchestration:
public vault when required, and make one batched `payForQuotes` transaction;
- upload each content-addressed encrypted record with the signed quote and
transaction hash through paid `PUT_CHUNK`; the wallet key never crosses the
- WebTransport session;
+ WebRTC Direct connection;
- fetch the public MessagePack DataMap and every resolved encrypted data chunk,
then pass those records to the Rust/WASM reconstruction API;
- verify the final file size and use the Rust/WASM BLAKE3 verifier before
@@ -101,17 +113,17 @@ defined by ant-node ADR-0009.
The JavaScript API and demo never accept a certificate hash separately from an
endpoint. Their sole dialing input is a canonical address of the form
-`/.../quic-v1/webtransport/certhash//p2p/`. Repeated
-`/certhash` components permit current/next certificate overlap. Keeping the
-transport location, accepted TLS key, and expected peer identity in one value
-prevents callers from accidentally combining fields belonging to different
-nodes.
+`/ip4|ip6//udp//webrtc-direct/certhash//p2p/`.
+DNS protocols, port zero, and repeated `/certhash` components are rejected.
+Keeping the transport location, accepted DTLS key, and expected ANT peer
+identity in one value prevents callers from accidentally combining fields
+belonging to different nodes.
Rust nodes construct and validate this syntax through
-`saorsa_transport::TransportAddr::WebTransport` wrapped by
+`saorsa_transport::TransportAddr::WebRtcDirect` wrapped by
`saorsa_core::MultiAddr`; it is not a browser-specific string type. The
JavaScript parser is the browser implementation of that same canonical wire
-format and is covered by matching current/next-pin fixtures.
+format and is covered by matching stable-pin fixtures.
Quote and storage-commitment verification remains JavaScript for now.
`ant-protocol 2.3.1` unconditionally reaches the native Saorsa transport and
@@ -127,7 +139,7 @@ second implementation inside `ant-core`. Native `DhtNetworkManager` and the
browser WASM adapter both drive the same candidate queue, peer-state, α-batch,
and convergence implementation. Native QUIC retains transport authentication,
address-report consensus, failure-cache integration, and trust updates; the
-browser adapter retains WebTransport session establishment and endpoint
+browser adapter retains WebRTC Direct connection establishment and endpoint
validation.
For compatibility with the local launcher, the bootstrap manifest still
@@ -149,7 +161,7 @@ metadata chain.
content verification.
- Self-encryption, DataMap serialization, reconstruction, and content
addressing have one Rust implementation across native and browser clients.
-- The browser application keeps direct control of WebTransport, wallet, and
+- The browser application keeps direct control of WebRTC, wallet, and
DOM APIs without pulling native runtime dependencies into WASM.
### Negative / Trade-offs
@@ -161,13 +173,24 @@ metadata chain.
protocol rules until transport-free `ant-protocol` APIs exist.
- The initial WASM module is approximately 1.4 MiB uncompressed and browser
file processing is still in memory.
-- Certificate and endpoint verification adds bootstrap-record lifecycle work.
+- The node must preserve its DTLS certificate file; deleting it changes the
+ certhash and invalidates old bootstrap entries.
+- WebRTC DataChannels need explicit 16-KiB message fragmentation, declared-
+ length reassembly, and `bufferedAmount` backpressure for large chunk
+ transfers.
+- The Safari PoC observed later DataChannels timing out after repeatedly
+ replacing all seed associations between records, even though callers closed
+ earlier connections. Bounded association reuse avoids relying on prompt
+ browser resource reclamation and is required for multi-record operations.
+- A browser may reuse a local UDP source port for a replacement association.
+ Node-side UDP multiplexing must route STUN binding requests by ICE credential
+ before consulting a possibly stale source-address mapping.
### Neutral / Operational
- The manifest HTTP service carries only small bootstrap metadata.
-- WebTransport still requires a secure browser context; localhost qualifies
- for development.
+- Browser WebRTC APIs still require a secure browser context; localhost
+ qualifies for development.
- Node and client repositories must run compatible browser protocol versions.
## Validation
@@ -188,7 +211,7 @@ metadata chain.
- The generated WASM package encrypts and reconstructs the same fixed vector
as the native Rust test, covering the native KDF, authenticated decryption,
Brotli reconstruction, MessagePack DataMap, and BLAKE3 addresses.
-- A live node integration test starts five WebTransport-enabled nodes,
+- A live node integration test starts five WebRTC Direct-enabled nodes,
publishes a public DataMap and encrypted chunks, connects with the advertised
self-contained multiaddress, retrieves every record, and reconstructs the
exact file, pays a real signed quote, accepts a paid binary PUT through the
diff --git a/web/README.md b/web/README.md
index 8f156ea3..43d0cb92 100644
--- a/web/README.md
+++ b/web/README.md
@@ -2,25 +2,25 @@
This web application is the browser-facing client for ADR-0009. It loads a
local testnet bootstrap manifest, connects directly to storage nodes over
-WebTransport, and performs closest-node lookup through Saorsa's shared Rust
+WebRTC Direct, and performs closest-node lookup through Saorsa's shared Rust
lookup engine. The portable part of `ant-core` is compiled to WASM and drives
the Kademlia walk, native self-encryption, public DataMap serialization,
reconstruction, and BLAKE3 content verification. The thin JavaScript layer
-executes WebTransport requests, retrieves and uploads records, uses the browser
+executes WebRTC Direct requests, retrieves and uploads records, uses the browser
wallet/payment APIs, and drives the page.
-The node-side WebTransport listener and testnet manifest API live in the
+The node-side WebRTC Direct listener and testnet manifest API live in the
`ant-node-web-support` sibling repository. No HTTP gateway performs lookup or
proxies file bytes.
## Requirements
-- Rust 1.88 or newer for the node's optional `wtransport` dependency.
+- Rust 1.88 or newer for the node's optional Saorsa WebRTC transport.
- The `wasm32-unknown-unknown` Rust target.
- `wasm-pack` 0.15.
- Node.js 20.19+ or 22.12+.
-- A current browser implementing WebTransport certificate hashes. The client
- extracts them from node multiaddresses; users do not enter hashes separately.
+- A current browser implementing `RTCPeerConnection`. The WebRTC Direct dialer
+ extracts the stable certificate fingerprint from each node multiaddress.
Nodes serialize these addresses from the native `saorsa_core::MultiAddr`
representation; the JavaScript parser consumes that canonical string form.
@@ -37,11 +37,11 @@ cargo install wasm-pack --version 0.15.0 --locked
From `ant-node-web-support`:
```bash
-cargo run --features webtransport-poc --bin ant-devnet -- \
+cargo run --features webrtc-direct --bin ant-devnet -- \
--preset minimal \
--base-port 23000 \
- --webtransport \
- --webtransport-base-port 24000 \
+ --webrtc-direct \
+ --webrtc-direct-base-port 24000 \
--serve-port 25000 \
--enable-evm \
--enable-logging
@@ -88,9 +88,9 @@ manifest from port 25000 and fills in the default file:
1. **Load testnet** refreshes the manifest and direct multiaddress catalog.
2. **Connect** parses the first node multiaddress and performs a pinned
- WebTransport `HELLO`.
+ WebRTC Direct `HELLO` and verifies its ML-DSA identity signature.
3. **Find closest** runs Saorsa's iterative lookup engine in WASM, with
- JavaScript executing its WebTransport query batches.
+ JavaScript executing its WebRTC Direct query batches.
4. Under **Paid public file upload**, choose a file, paste the funded private
key printed by ant-devnet, then select **Pay and upload file**. Rust/WASM
performs encryption and DataMap generation; quote/commitment verification,
@@ -102,12 +102,11 @@ manifest from port 25000 and fills in the default file:
the whole file, verifies BLAKE3, and retains a **Save again** link.
The browser receives the DataMap and all encrypted file bytes from UDP
-24000-24004 over HTTP/3, not from the manifest server on TCP 25000. The
-manifest server is bootstrap metadata only.
+24000-24004 over ICE, DTLS, SCTP, and data channels, not from the manifest
+server on TCP 25000. The manifest server is bootstrap metadata only.
-For a LAN test, start the node devnet with `--host `, serve Vite with
-`npm run dev -- --host 0.0.0.0`, and add the exact site Origin with
-`--webtransport-origin http://:5173`. Change the manifest URL in the
+For a LAN test, start the node devnet with `--host ` and serve Vite with
+`npm run dev -- --host 0.0.0.0`. Change the manifest URL in the
page to `http://:25000/api/browser-manifest.json`.
## Verify the client
@@ -132,13 +131,13 @@ quote verification including the native Keccak-256 EVM quote hash, and a Rust
`self_encryption 0.36` wire vector with native/WASM round-trip and tamper
verification.
Cross-repository live verification additionally starts the node testnet,
-downloads all public-file records through WebTransport, reconstructs the
+downloads all public-file records through WebRTC Direct, reconstructs the
original bytes, pays a real quote on local Anvil, uploads a fresh record
through the ordinary node payment validator, and reads it back.
## Current boundary
-JavaScript currently owns WebTransport stream handling, Ethers payment
+JavaScript currently owns native `RTCPeerConnection`/`RTCDataChannel` handling, Ethers payment
submission, ML-DSA quote verification, and DOM/save APIs. Rust/WASM owns the
complete iterative lookup loop; JavaScript implements only its transport batch
callback.
@@ -147,6 +146,6 @@ linked into a browser WASM build; a future transport-free feature can move the
remaining quote and multiaddress verification into Rust.
The local testnet manifest is intentionally unsigned bootstrap material. A
-production deployment still needs ML-DSA-signed endpoint records, certificate
-overlap/rotation, network dissemination, relayed WebTransport, and production
-traffic quotas as specified by ADR-0009.
+production deployment still needs ML-DSA-signed endpoint records, exceptional
+certificate-rotation recovery, network dissemination, relayed WebRTC, and
+production traffic quotas as specified by ADR-0009.
diff --git a/web/index.html b/web/index.html
index 8da7ece3..c1f4db1f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -7,7 +7,7 @@
name="description"
content="ADR-0009 direct browser client interoperability proof"
/>
- Autonomi WebTransport PoC
+ Autonomi WebRTC Direct PoC
@@ -51,11 +51,11 @@
Direct node endpoint
It is populated from the testnet manifest and can also be pasted manually.
@@ -99,6 +99,7 @@
Paid public file upload
=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
@@ -355,12 +415,36 @@
"undici-types": "~8.3.0"
}
},
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -843,6 +927,61 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-support/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.50.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.50.0.tgz",
+ "integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
diff --git a/web/src/file.js b/web/src/file.js
index 76d774e8..bcb5b871 100644
--- a/web/src/file.js
+++ b/web/src/file.js
@@ -2,7 +2,12 @@ import {
decodePublicDataMap as decodePublicDataMapNative,
decryptPublicFile as decryptPublicFileNative,
} from "../pkg/ant_core.js";
-import { getChunkFromClosest, hexToBytes, verifyChunk } from "./protocol.js";
+import {
+ BrowserNodeClientPool,
+ getChunkFromClosest,
+ hexToBytes,
+ verifyChunk,
+} from "./protocol.js";
const MAX_DOWNLOAD_CONCURRENCY = 6;
@@ -40,42 +45,53 @@ export async function downloadPublicFile(
}
const boundedConcurrency = Math.min(concurrency, MAX_DOWNLOAD_CONCURRENCY);
- onProgress(`Fetching public DataMap ${file.address}`);
- const dataMap = await downloadChunk(seedEndpoints, file.address, { onProgress });
- if (dataMap.content.length !== file.data_map_size) {
- throw new Error(
- `Public DataMap has ${dataMap.content.length} bytes, expected ${file.data_map_size}`,
+ const clientPool = new BrowserNodeClientPool();
+ try {
+ onProgress(`Fetching public DataMap ${file.address}`);
+ const dataMap = await downloadChunk(seedEndpoints, file.address, {
+ onProgress,
+ clientPool,
+ });
+ if (dataMap.content.length !== file.data_map_size) {
+ throw new Error(
+ `Public DataMap has ${dataMap.content.length} bytes, expected ${file.data_map_size}`,
+ );
+ }
+ onProgress(`Verified public DataMap (${dataMap.content.length} bytes)`);
+ const chunks = decodeDataMap(dataMap.content);
+ if (!Array.isArray(chunks) || chunks.length < 3) {
+ throw new Error("ant-core WASM returned an invalid public DataMap");
+ }
+
+ const encryptedChunks = await mapWithConcurrency(
+ chunks,
+ boundedConcurrency,
+ async (chunk) => {
+ onProgress(
+ `Fetching encrypted file chunk ${chunk.index + 1}/${chunks.length} (${chunk.dst_hash})`,
+ );
+ const downloaded = await downloadChunk(seedEndpoints, chunk.dst_hash, {
+ onProgress,
+ clientPool,
+ });
+ return downloaded.content;
+ },
);
- }
- onProgress(`Verified public DataMap (${dataMap.content.length} bytes)`);
- const chunks = decodeDataMap(dataMap.content);
- if (!Array.isArray(chunks) || chunks.length < 3) {
- throw new Error("ant-core WASM returned an invalid public DataMap");
- }
- const encryptedChunks = await mapWithConcurrency(
- chunks,
- boundedConcurrency,
- async (chunk) => {
- onProgress(
- `Fetching encrypted file chunk ${chunk.index + 1}/${chunks.length} (${chunk.dst_hash})`,
+ onProgress(`Reconstructing ${file.name} with native ant-core WASM`);
+ const content = decrypt(dataMap.content, encryptedChunks);
+ if (!(content instanceof Uint8Array)) {
+ throw new Error("ant-core WASM returned non-byte file content");
+ }
+ if (content.length !== file.size) {
+ throw new Error(
+ `Reconstructed file has ${content.length} bytes, expected ${file.size}`,
);
- const downloaded = await downloadChunk(seedEndpoints, chunk.dst_hash, {
- onProgress,
- });
- return downloaded.content;
- },
- );
-
- onProgress(`Reconstructing ${file.name} with native ant-core WASM`);
- const content = decrypt(dataMap.content, encryptedChunks);
- if (!(content instanceof Uint8Array)) {
- throw new Error("ant-core WASM returned non-byte file content");
- }
- if (content.length !== file.size) {
- throw new Error(`Reconstructed file has ${content.length} bytes, expected ${file.size}`);
+ }
+ const hash = verifyChunk(file.blake3, content);
+ onProgress(`Verified complete ${file.name} as ${hash}`);
+ return { content, hash, dataMapNode: dataMap.node };
+ } finally {
+ clientPool.close();
}
- const hash = verifyChunk(file.blake3, content);
- onProgress(`Verified complete ${file.name} as ${hash}`);
- return { content, hash, dataMapNode: dataMap.node };
}
diff --git a/web/src/main.js b/web/src/main.js
index dc2c27f5..3ff53bc5 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -52,7 +52,8 @@ function timestamp() {
}
function log(message, value) {
- const suffix = value === undefined ? "" : `\n${JSON.stringify(value, null, 2)}`;
+ const suffix =
+ value === undefined ? "" : `\n${JSON.stringify(value, null, 2)}`;
elements.log.textContent += `[${timestamp()}] ${message}${suffix}\n`;
elements.log.scrollTop = elements.log.scrollHeight;
}
@@ -70,7 +71,9 @@ function seedEndpoints() {
async function loadManifest() {
elements.manifestState.classList.remove("connected");
elements.manifestState.textContent = "Loading…";
- const manifest = await fetchBrowserManifest(elements.manifestUrl.value.trim());
+ const manifest = await fetchBrowserManifest(
+ elements.manifestUrl.value.trim(),
+ );
browserManifest = manifest;
const first = manifest.endpoints[0];
@@ -158,7 +161,7 @@ elements.findClosest.addEventListener("click", async () => {
message: error.message,
})),
});
- for (const lookupClient of result.clients.values()) lookupClient.close();
+ if (result.ownsClientPool) result.clientPool.close();
} catch (error) {
reportError("Lookup", error);
}
@@ -172,7 +175,8 @@ elements.uploadFile.addEventListener("click", async () => {
let walletSecret = elements.walletSecret.value.trim();
elements.walletSecret.value = "";
try {
- if (!browserManifest) throw new Error("Load the browser testnet manifest first");
+ if (!browserManifest)
+ throw new Error("Load the browser testnet manifest first");
const file = elements.uploadInput.files?.[0];
if (!file) throw new Error("Choose a file to upload");
if (!walletSecret) throw new Error("Enter the paying wallet secret key");
@@ -207,7 +211,10 @@ elements.uploadFile.addEventListener("click", async () => {
elements.uploadResult.hidden = false;
elements.uploadState.textContent = "Uploaded · ready to download";
elements.uploadState.classList.add("connected");
- log(`Uploaded and registered ${result.file.name} for immediate download`, result);
+ log(
+ `Uploaded and registered ${result.file.name} for immediate download`,
+ result,
+ );
} catch (error) {
elements.uploadState.textContent = "Upload failed";
log(`File upload failed: ${error.message}`);
@@ -225,9 +232,13 @@ elements.downloadFile.addEventListener("click", async () => {
try {
const address = elements.fileAddress.value.trim().toLowerCase();
hexToBytes(address, 32);
- const published = browserManifest?.files.find((file) => file.address === address);
+ const published = browserManifest?.files.find(
+ (file) => file.address === address,
+ );
if (!published) {
- throw new Error("That public file address is not described by the loaded testnet manifest");
+ throw new Error(
+ "That public file address is not described by the loaded testnet manifest",
+ );
}
const saveHandle = await chooseSaveHandle(published.name);
elements.downloadState.textContent = "Downloading…";
@@ -253,7 +264,8 @@ elements.downloadFile.addEventListener("click", async () => {
{ data_map_node: dataMapNode.peer_id, chunks: published.chunks.length },
);
} catch (error) {
- elements.downloadState.textContent = error.name === "AbortError" ? "Save cancelled" : "Failed";
+ elements.downloadState.textContent =
+ error.name === "AbortError" ? "Save cancelled" : "Failed";
log(`File download failed: ${error.message}`);
console.error(error);
} finally {
@@ -284,7 +296,9 @@ async function exposeSavedFile(file, content, saveHandle) {
if (downloadObjectUrl) URL.revokeObjectURL(downloadObjectUrl);
downloadObjectUrl = URL.createObjectURL(
- new Blob([content], { type: file.content_type ?? "application/octet-stream" }),
+ new Blob([content], {
+ type: file.content_type ?? "application/octet-stream",
+ }),
);
const anchor = document.createElement("a");
anchor.href = downloadObjectUrl;
diff --git a/web/src/manifest.js b/web/src/manifest.js
index b42edd43..63ba8c04 100644
--- a/web/src/manifest.js
+++ b/web/src/manifest.js
@@ -1,6 +1,6 @@
-import { hexToBytes, parseWebTransportMultiaddr } from "./protocol.js";
+import { hexToBytes, parseWebRtcDirectMultiaddr } from "./protocol.js";
-export const BROWSER_MANIFEST_VERSION = 4;
+export const BROWSER_MANIFEST_VERSION = 5;
const MAX_PUBLIC_FILE_BYTES = 64 * 1024 * 1024;
const MAX_DATA_MAP_BYTES = 4 * 1024 * 1024;
const MAX_FILE_CHUNKS = 1024;
@@ -13,10 +13,10 @@ export function parseBrowserManifest(value) {
throw new Error("Browser manifest has no network ID");
}
if (!Array.isArray(value.endpoints) || value.endpoints.length === 0) {
- throw new Error("Browser manifest contains no WebTransport endpoints");
+ throw new Error("Browser manifest contains no WebRtcDirect endpoints");
}
const endpoints = value.endpoints.map((endpoint) => {
- const parsed = parseWebTransportMultiaddr(endpoint);
+ const parsed = parseWebRtcDirectMultiaddr(endpoint);
return { multiaddr: parsed.multiaddr };
});
const payment = parsePaymentNetwork(value.payment);
diff --git a/web/src/manifest.test.js b/web/src/manifest.test.js
index 4322ca1e..62efca80 100644
--- a/web/src/manifest.test.js
+++ b/web/src/manifest.test.js
@@ -4,13 +4,13 @@ import { parseBrowserManifest } from "./manifest.js";
test("browser manifest validates and normalizes endpoints and files", () => {
const manifest = parseBrowserManifest({
- version: 4,
+ version: 5,
network_id: "local-test",
created_at: "2026-08-03T00:00:00Z",
payment: paymentNetwork(),
endpoints: [
{
- multiaddr: webtransportMultiaddr("AA".repeat(32), 0xbb),
+ multiaddr: webrtc_directMultiaddr("AA".repeat(32), 0xbb),
},
],
files: [
@@ -33,7 +33,7 @@ test("browser manifest validates and normalizes endpoints and files", () => {
assert.equal(
manifest.endpoints[0].multiaddr,
- webtransportMultiaddr("AA".repeat(32), 0xbb),
+ webrtc_directMultiaddr("AA".repeat(32), 0xbb),
);
assert.equal(manifest.files[0].address, "cc".repeat(32));
assert.equal(manifest.files[0].blake3, "dd".repeat(32));
@@ -49,23 +49,23 @@ test("browser manifest rejects missing endpoints and malformed multiaddresses",
assert.throws(
() =>
parseBrowserManifest({
- version: 4,
+ version: 5,
network_id: "test",
payment: paymentNetwork(),
endpoints: [],
}),
- /no WebTransport endpoints/,
+ /no WebRtcDirect endpoints/,
);
assert.throws(
() =>
parseBrowserManifest({
- version: 4,
+ version: 5,
network_id: "test",
payment: paymentNetwork(),
endpoints: [
{
multiaddr:
- "/ip4/127.0.0.1/udp/22000/quic-v1/webtransport/certhash/uAA/p2p/wrong",
+ "/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/uAA/p2p/wrong",
},
],
}),
@@ -74,15 +74,15 @@ test("browser manifest rejects missing endpoints and malformed multiaddresses",
});
test("browser manifest requires public payment contract configuration", () => {
- const endpoint = { multiaddr: webtransportMultiaddr("aa".repeat(32), 0xbb) };
+ const endpoint = { multiaddr: webrtc_directMultiaddr("aa".repeat(32), 0xbb) };
assert.throws(
- () => parseBrowserManifest({ version: 4, network_id: "test", endpoints: [endpoint] }),
+ () => parseBrowserManifest({ version: 5, network_id: "test", endpoints: [endpoint] }),
/no payment network/,
);
assert.throws(
() =>
parseBrowserManifest({
- version: 4,
+ version: 5,
network_id: "test",
endpoints: [endpoint],
payment: { ...paymentNetwork(), rpc_url: "file:///tmp/anvil" },
@@ -99,12 +99,12 @@ function paymentNetwork() {
};
}
-function webtransportMultiaddr(peerId, certificateByte) {
+function webrtc_directMultiaddr(peerId, certificateByte) {
const multihash = Uint8Array.from([
0x12,
0x20,
...Array(32).fill(certificateByte),
]);
const certhash = `u${Buffer.from(multihash).toString("base64url")}`;
- return `/ip4/127.0.0.1/udp/22000/quic-v1/webtransport/certhash/${certhash}/p2p/${peerId}`;
+ return `/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/${certhash}/p2p/${peerId}`;
}
diff --git a/web/src/payment.js b/web/src/payment.js
index f5ce0e94..7f2cc62d 100644
--- a/web/src/payment.js
+++ b/web/src/payment.js
@@ -224,7 +224,7 @@ export function verifyStorageQuote(quote, expectedAddress, expectedPeerId) {
throw new Error("Storage quote is for a different chunk");
}
if (quote.peer_id?.toLowerCase() !== expectedPeerId.toLowerCase()) {
- throw new Error("Storage quote belongs to a different WebTransport peer");
+ throw new Error("Storage quote belongs to a different WebRtcDirect peer");
}
const publicKey = hexToBytes(quote.public_key);
const signature = hexToBytes(quote.signature);
diff --git a/web/src/payment.test.js b/web/src/payment.test.js
index bdb7d1d7..84a4f680 100644
--- a/web/src/payment.test.js
+++ b/web/src/payment.test.js
@@ -191,7 +191,7 @@ test("rejects quote field tampering before any payment", () => {
);
assert.throws(
() => verifyStorageQuote(fixture.quote, fixture.address, "ff".repeat(32)),
- /different WebTransport peer/,
+ /different WebRtcDirect peer/,
);
const signature = Uint8Array.from(Buffer.from(fixture.quote.signature, "hex"));
signature[100] ^= 1;
diff --git a/web/src/protocol.js b/web/src/protocol.js
index af8b2205..bfab9f99 100644
--- a/web/src/protocol.js
+++ b/web/src/protocol.js
@@ -1,4 +1,6 @@
import { bytesToHex as nobleBytesToHex } from "@noble/hashes/utils.js";
+import { blake3 } from "@noble/hashes/blake3.js";
+import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import {
BrowserIterativeLookup as BrowserIterativeLookupNative,
verifyRecord as verifyRecordNative,
@@ -6,16 +8,23 @@ import {
export const PROTOCOL_VERSION = 3;
export const PROTOCOL_NAME = "autonomi.web.poc.v3";
-export const WEBTRANSPORT_PATH = "/autonomi/webtransport/v1";
+export const WEBRTC_DIRECT_DATA_CHANNEL = "autonomi.web.v3";
export const MAX_CHUNK_SIZE = 4 * 1024 * 1024;
export const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 4 + MAX_RESPONSE_HEADER_BYTES + MAX_CHUNK_SIZE;
-const MAX_WEBTRANSPORT_MULTIADDR_LENGTH = 2048;
-const MAX_CERTIFICATE_HASHES = 2;
+const MAX_WEBRTC_DIRECT_MULTIADDR_LENGTH = 2048;
+const WEBRTC_WRITE_CHUNK_BYTES = 16 * 1024;
+const MAX_BUFFERED_AMOUNT = 2 * 1024 * 1024;
+const REQUEST_TIMEOUT_MS = 10_000;
+const ICE_CREDENTIAL_PREFIX = "saorsa+webrtc+v1/";
+const ICE_RANDOM_LENGTH = 32;
+const ICE_ALPHABET =
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const SHA2_256_MULTIHASH_CODE = 0x12;
const SHA2_256_MULTIHASH_LENGTH = 32;
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });
+const HELLO_DOMAIN = encoder.encode("autonomi-webrtc-direct-hello-v1\0");
let nextRequestId = 1;
@@ -28,7 +37,9 @@ export function hexToBytes(value, expectedLength) {
normalized.match(/.{2}/g)?.map((pair) => Number.parseInt(pair, 16)) ?? [],
);
if (expectedLength !== undefined && bytes.length !== expectedLength) {
- throw new Error(`Expected ${expectedLength} bytes, received ${bytes.length}`);
+ throw new Error(
+ `Expected ${expectedLength} bytes, received ${bytes.length}`,
+ );
}
return bytes;
}
@@ -37,6 +48,18 @@ export function bytesToHex(bytes) {
return nobleBytesToHex(bytes);
}
+function concatBytes(...parts) {
+ const result = new Uint8Array(
+ parts.reduce((total, part) => total + part.length, 0),
+ );
+ let offset = 0;
+ for (const part of parts) {
+ result.set(part, offset);
+ offset += part.length;
+ }
+ return result;
+}
+
export function verifyChunk(address, content) {
const expected = address.trim().replace(/^0x/i, "").toLowerCase();
hexToBytes(expected, 32);
@@ -44,6 +67,16 @@ export function verifyChunk(address, content) {
}
export function parseResponseFrame(frame) {
+ const { header, contentOffset, frameLength } = parseResponseHeader(frame);
+ if (frame.length !== frameLength) {
+ throw new Error(
+ `Response length mismatch: declared ${header.content_length} content bytes`,
+ );
+ }
+ return { header, content: frame.slice(contentOffset) };
+}
+
+function parseResponseHeader(frame) {
if (!(frame instanceof Uint8Array) || frame.length < 4) {
throw new Error("Response ended before its four-byte header length");
}
@@ -61,7 +94,9 @@ export function parseResponseFrame(frame) {
try {
header = JSON.parse(decoder.decode(frame.subarray(4, contentOffset)));
} catch (error) {
- throw new Error(`Invalid response JSON: ${error.message}`, { cause: error });
+ throw new Error(`Invalid response JSON: ${error.message}`, {
+ cause: error,
+ });
}
if (header.version !== PROTOCOL_VERSION) {
throw new Error(`Unsupported response version ${header.version}`);
@@ -73,43 +108,63 @@ export function parseResponseFrame(frame) {
) {
throw new Error(`Invalid response content length ${header.content_length}`);
}
- if (frame.length !== contentOffset + header.content_length) {
- throw new Error(
- `Response length mismatch: declared ${header.content_length} content bytes`,
- );
- }
- return { header, content: frame.slice(contentOffset) };
+ return {
+ header,
+ contentOffset,
+ frameLength: contentOffset + header.content_length,
+ };
}
-async function readAll(readable, limit = MAX_RESPONSE_BYTES) {
- const reader = readable.getReader();
- const chunks = [];
+export async function readResponseFrame(stream, limit = MAX_RESPONSE_BYTES) {
+ if (!Number.isSafeInteger(limit) || limit < 4) {
+ throw new Error(`Invalid response limit ${limit}`);
+ }
+ let frame = new Uint8Array(Math.min(limit, 8 * 1024));
let total = 0;
- try {
- while (true) {
- const { value, done } = await reader.read();
- if (done) break;
- if (!(value instanceof Uint8Array)) {
- throw new Error("WebTransport returned a non-byte stream chunk");
+ let expectedLength;
+ for await (const value of stream) {
+ const chunk = value instanceof Uint8Array ? value : value.subarray();
+ const nextTotal = total + chunk.length;
+ if (nextTotal > limit) {
+ throw new Error(`Response exceeded the ${limit}-byte client limit`);
+ }
+ if (nextTotal > frame.length) {
+ let capacity = frame.length;
+ while (capacity < nextTotal) capacity = Math.min(limit, capacity * 2);
+ const grown = new Uint8Array(capacity);
+ grown.set(frame.subarray(0, total));
+ frame = grown;
+ }
+ frame.set(chunk, total);
+ total = nextTotal;
+
+ if (expectedLength === undefined && total >= 4) {
+ const headerLength = new DataView(
+ frame.buffer,
+ frame.byteOffset,
+ total,
+ ).getUint32(0, false);
+ if (headerLength === 0 || headerLength > MAX_RESPONSE_HEADER_BYTES) {
+ throw new Error(`Invalid response header length ${headerLength}`);
}
- total += value.length;
- if (total > limit) {
- await reader.cancel("response exceeded client limit");
- throw new Error(`Response exceeded the ${limit}-byte client limit`);
+ if (total >= 4 + headerLength) {
+ expectedLength = parseResponseHeader(
+ frame.subarray(0, total),
+ ).frameLength;
+ if (expectedLength > limit) {
+ throw new Error(`Response exceeded the ${limit}-byte client limit`);
+ }
}
- chunks.push(value);
}
- } finally {
- reader.releaseLock();
- }
- const result = new Uint8Array(total);
- let offset = 0;
- for (const chunk of chunks) {
- result.set(chunk, offset);
- offset += chunk.length;
+ if (expectedLength !== undefined && total >= expectedLength) {
+ if (total !== expectedLength) {
+ throw new Error("Response contains bytes after its declared frame");
+ }
+ return frame.slice(0, total);
+ }
}
- return result;
+ throw new Error("Response ended before its declared frame was complete");
}
function decodeBase64Url(value) {
@@ -138,7 +193,9 @@ function decodeCertificateMultihash(value) {
decoded[0] !== SHA2_256_MULTIHASH_CODE ||
decoded[1] !== SHA2_256_MULTIHASH_LENGTH
) {
- throw new Error("Certificate multihash must contain a 32-byte SHA-256 digest");
+ throw new Error(
+ "Certificate multihash must contain a 32-byte SHA-256 digest",
+ );
}
return decoded.slice(2);
}
@@ -149,7 +206,8 @@ function validateIpv4(value) {
octets.length !== 4 ||
octets.some(
(octet) =>
- !/^(0|[1-9][0-9]{0,2})$/.test(octet) || Number.parseInt(octet, 10) > 255,
+ !/^(0|[1-9][0-9]{0,2})$/.test(octet) ||
+ Number.parseInt(octet, 10) > 255,
)
) {
throw new Error(`Invalid IPv4 address ${value}`);
@@ -159,165 +217,438 @@ function validateIpv4(value) {
function endpointMultiaddr(endpoint) {
if (typeof endpoint === "string") return endpoint;
- if (endpoint && typeof endpoint.multiaddr === "string") return endpoint.multiaddr;
- throw new Error("A WebTransport multiaddress is required");
+ if (endpoint && typeof endpoint.multiaddr === "string")
+ return endpoint.multiaddr;
+ throw new Error("A WebRtcDirect multiaddress is required");
}
-export function parseWebTransportMultiaddr(endpoint) {
+export function parseWebRtcDirectMultiaddr(endpoint) {
const multiaddr = endpointMultiaddr(endpoint).trim();
if (
multiaddr.length === 0 ||
- multiaddr.length > MAX_WEBTRANSPORT_MULTIADDR_LENGTH ||
+ multiaddr.length > MAX_WEBRTC_DIRECT_MULTIADDR_LENGTH ||
!multiaddr.startsWith("/")
) {
- throw new Error("Invalid WebTransport multiaddress length or prefix");
+ throw new Error("Invalid WebRtcDirect multiaddress length or prefix");
}
const segments = multiaddr.split("/");
- if (segments.length < 9) {
- throw new Error("WebTransport multiaddress is incomplete");
+ if (segments.length !== 10) {
+ throw new Error("WebRtcDirect multiaddress is incomplete");
}
const hostProtocol = segments[1];
const hostValue = segments[2];
- if (!hostValue) throw new Error("WebTransport multiaddress host is empty");
- let urlHost;
+ if (!hostValue) throw new Error("WebRtcDirect multiaddress host is empty");
if (hostProtocol === "ip4") {
- urlHost = validateIpv4(hostValue);
+ validateIpv4(hostValue);
} else if (hostProtocol === "ip6") {
- urlHost = `[${hostValue}]`;
- } else if (["dns", "dns4", "dns6"].includes(hostProtocol)) {
- urlHost = hostValue.toLowerCase();
+ if (!hostValue.includes(":"))
+ throw new Error(`Invalid IPv6 address ${hostValue}`);
} else {
- throw new Error(`Unsupported WebTransport host protocol ${hostProtocol}`);
+ throw new Error(
+ "WebRTC Direct multiaddresses must use a literal IP address",
+ );
}
if (segments[3] !== "udp") {
- throw new Error("WebTransport multiaddress must use UDP");
+ throw new Error("WebRtcDirect multiaddress must use UDP");
}
if (!/^[0-9]{1,5}$/.test(segments[4])) {
- throw new Error("WebTransport multiaddress has an invalid UDP port");
+ throw new Error("WebRtcDirect multiaddress has an invalid UDP port");
}
const port = Number.parseInt(segments[4], 10);
if (port < 1 || port > 65535) {
- throw new Error("WebTransport multiaddress has an invalid UDP port");
+ throw new Error("WebRtcDirect multiaddress has an invalid UDP port");
+ }
+ if (segments[5] !== "webrtc-direct") {
+ throw new Error("WebRTC Direct multiaddress must contain /webrtc-direct");
}
- if (segments[5] !== "quic-v1" || segments[6] !== "webtransport") {
+ if (segments[6] !== "certhash" || !segments[7]) {
throw new Error(
- "WebTransport multiaddress must contain /quic-v1/webtransport",
+ "WebRTC Direct multiaddress must contain exactly one certhash",
);
}
+ const certificateHash = decodeCertificateMultihash(segments[7]);
+ if (segments[8] !== "p2p") {
+ throw new Error("WebRtcDirect multiaddress must end with /p2p/");
+ }
+ const peerId = segments[9]?.toLowerCase() ?? "";
+ hexToBytes(peerId, 32);
+ return {
+ multiaddr,
+ hostProtocol,
+ host: hostValue,
+ port,
+ peerId,
+ certificateHash,
+ };
+}
+
+const normalizeEndpoint = parseWebRtcDirectMultiaddr;
- let index = 7;
- const certificateHashes = [];
- const certificateHashMultihashes = [];
- while (segments[index] === "certhash") {
- const encoded = segments[index + 1];
- if (!encoded) throw new Error("WebTransport multiaddress has an empty certhash");
- certificateHashes.push(decodeCertificateMultihash(encoded));
- certificateHashMultihashes.push(encoded);
- index += 2;
+export function verifyHelloIdentity(header, expectedEndpoint, challengeBytes) {
+ const endpoint = normalizeEndpoint(expectedEndpoint);
+ if (!(challengeBytes instanceof Uint8Array) || challengeBytes.length !== 32) {
+ throw new Error("HELLO verification requires a 32-byte challenge");
+ }
+ if (header.type !== "hello") throw new Error("Expected a HELLO response");
+ if (header.protocol !== PROTOCOL_NAME) {
+ throw new Error(`Unsupported browser protocol ${header.protocol}`);
}
+ hexToBytes(header.peer_id, 32);
+ if (header.challenge?.toLowerCase() !== bytesToHex(challengeBytes)) {
+ throw new Error("Node signed a different HELLO challenge");
+ }
+ const advertisedEndpoint = normalizeEndpoint(header.endpoint);
if (
- certificateHashes.length < 1 ||
- certificateHashes.length > MAX_CERTIFICATE_HASHES
+ advertisedEndpoint.multiaddr !== endpoint.multiaddr ||
+ advertisedEndpoint.peerId !== header.peer_id.toLowerCase()
) {
+ throw new Error("Node advertised a different WebRTC Direct endpoint");
+ }
+ if (header.peer_id.toLowerCase() !== endpoint.peerId.toLowerCase()) {
throw new Error(
- `WebTransport multiaddress must contain between 1 and ${MAX_CERTIFICATE_HASHES} certificate hashes`,
+ `Endpoint identity mismatch: expected ${endpoint.peerId}, received ${header.peer_id}`,
);
}
- if (new Set(certificateHashMultihashes).size !== certificateHashes.length) {
- throw new Error("WebTransport multiaddress contains duplicate certificate hashes");
+ const publicKey = hexToBytes(header.public_key);
+ const signature = hexToBytes(header.signature);
+ if (publicKey.length !== ml_dsa65.lengths.publicKey) {
+ throw new Error(`HELLO has a ${publicKey.length}-byte public key`);
}
- if (segments[index] !== "p2p" || index + 2 !== segments.length) {
- throw new Error("WebTransport multiaddress must end with /p2p/");
+ if (signature.length !== ml_dsa65.lengths.signature) {
+ throw new Error(`HELLO has a ${signature.length}-byte signature`);
}
- const peerId = segments[index + 1]?.toLowerCase() ?? "";
- hexToBytes(peerId, 32);
+ if (bytesToHex(blake3(publicKey)) !== header.peer_id.toLowerCase()) {
+ throw new Error("HELLO public key is not bound to the ANT peer ID");
+ }
+ const transcript = concatBytes(
+ HELLO_DOMAIN,
+ challengeBytes,
+ encoder.encode(header.peer_id.toLowerCase()),
+ encoder.encode(advertisedEndpoint.multiaddr),
+ );
+ if (!ml_dsa65.verify(signature, transcript, publicKey)) {
+ throw new Error("HELLO has an invalid ML-DSA-65 signature");
+ }
+ return header.peer_id.toLowerCase();
+}
- let url;
- try {
- url = new URL(`https://${urlHost}:${port}${WEBTRANSPORT_PATH}`).toString();
- } catch (error) {
- throw new Error("WebTransport multiaddress contains an invalid host", {
- cause: error,
- });
+function randomIceCredential() {
+ const random = crypto.getRandomValues(new Uint8Array(ICE_RANDOM_LENGTH));
+ let suffix = "";
+ for (const byte of random) suffix += ICE_ALPHABET[byte % ICE_ALPHABET.length];
+ return ICE_CREDENTIAL_PREFIX + suffix;
+}
+
+function certificateFingerprint(certificateHash) {
+ return Array.from(certificateHash, (byte) =>
+ byte.toString(16).padStart(2, "0").toUpperCase(),
+ ).join(":");
+}
+
+export function serverAnswerFromEndpoint(endpoint, iceCredential) {
+ const normalized = normalizeEndpoint(endpoint);
+ if (
+ typeof iceCredential !== "string" ||
+ !iceCredential.startsWith(ICE_CREDENTIAL_PREFIX) ||
+ !/^[a-zA-Z0-9+/]{22,256}$/.test(iceCredential)
+ ) {
+ throw new Error("Invalid Saorsa WebRTC Direct ICE credential");
}
+ const ipVersion = normalized.hostProtocol === "ip4" ? "IP4" : "IP6";
return {
- multiaddr,
- url,
- peerId,
- certificateHashes,
+ type: "answer",
+ sdp: `v=0\r
+o=- 0 0 IN ${ipVersion} ${normalized.host}\r
+s=-\r
+t=0 0\r
+a=ice-lite\r
+m=application ${normalized.port} UDP/DTLS/SCTP webrtc-datachannel\r
+c=IN ${ipVersion} ${normalized.host}\r
+a=mid:0\r
+a=ice-options:ice2\r
+a=ice-ufrag:${iceCredential}\r
+a=ice-pwd:${iceCredential}\r
+a=fingerprint:sha-256 ${certificateFingerprint(normalized.certificateHash)}\r
+a=setup:passive\r
+a=sctp-port:5000\r
+a=max-message-size:${WEBRTC_WRITE_CHUNK_BYTES}\r
+a=candidate:1467250027 1 UDP 1467250027 ${normalized.host} ${normalized.port} typ host\r
+a=end-of-candidates\r
+`,
};
}
-const normalizeEndpoint = parseWebTransportMultiaddr;
+export function mungeOfferIceCredentials(offer, iceCredential) {
+ if (!offer?.sdp) throw new Error("Browser created an empty WebRTC offer");
+ const sdp = offer.sdp
+ .replace(/a=ice-ufrag:[^\r\n]+/, `a=ice-ufrag:${iceCredential}`)
+ .replace(/a=ice-pwd:[^\r\n]+/, `a=ice-pwd:${iceCredential}`);
+ if (
+ !sdp.includes(`a=ice-ufrag:${iceCredential}`) ||
+ !sdp.includes(`a=ice-pwd:${iceCredential}`)
+ ) {
+ throw new Error("Browser offer did not contain ICE credentials");
+ }
+ return { type: "offer", sdp };
+}
+
+class DataChannelInbox {
+ constructor(channel) {
+ this.queue = [];
+ this.waiters = [];
+ this.closed = false;
+ this.error = undefined;
+ channel.binaryType = "arraybuffer";
+ channel.addEventListener("message", ({ data }) => {
+ try {
+ let message;
+ if (data instanceof ArrayBuffer) {
+ message = new Uint8Array(data);
+ } else if (ArrayBuffer.isView(data)) {
+ message = new Uint8Array(
+ data.buffer,
+ data.byteOffset,
+ data.byteLength,
+ );
+ } else {
+ throw new Error("Node sent a non-binary DataChannel message");
+ }
+ this.push(message);
+ } catch (error) {
+ this.fail(error);
+ }
+ });
+ channel.addEventListener("error", (event) => {
+ this.fail(event.error ?? new Error("WebRTC DataChannel failed"));
+ });
+ channel.addEventListener("close", () => this.finish());
+ }
+
+ push(message) {
+ const waiter = this.waiters.shift();
+ if (waiter) waiter.resolve({ value: message, done: false });
+ else this.queue.push(message);
+ }
+
+ finish() {
+ this.closed = true;
+ for (const waiter of this.waiters.splice(0)) waiter.resolve({ done: true });
+ }
+
+ fail(error) {
+ this.error = error;
+ for (const waiter of this.waiters.splice(0)) waiter.reject(error);
+ }
+
+ next() {
+ if (this.queue.length > 0) {
+ return Promise.resolve({ value: this.queue.shift(), done: false });
+ }
+ if (this.error) return Promise.reject(this.error);
+ if (this.closed) return Promise.resolve({ done: true });
+ return new Promise((resolve, reject) =>
+ this.waiters.push({ resolve, reject }),
+ );
+ }
+
+ [Symbol.asyncIterator]() {
+ return this;
+ }
+}
+
+function waitForDataChannelOpen(channel, timeoutMs = REQUEST_TIMEOUT_MS) {
+ if (channel.readyState === "open") return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ cleanup();
+ reject(new Error("WebRTC DataChannel opening timed out"));
+ }, timeoutMs);
+ const cleanup = () => {
+ clearTimeout(timeout);
+ channel.removeEventListener("open", opened);
+ channel.removeEventListener("close", closed);
+ channel.removeEventListener("error", failed);
+ };
+ const opened = () => {
+ cleanup();
+ resolve();
+ };
+ const closed = () => {
+ cleanup();
+ reject(new Error("WebRTC DataChannel closed before opening"));
+ };
+ const failed = (event) => {
+ cleanup();
+ reject(
+ event.error ?? new Error("WebRTC DataChannel failed while opening"),
+ );
+ };
+ channel.addEventListener("open", opened, { once: true });
+ channel.addEventListener("close", closed, { once: true });
+ channel.addEventListener("error", failed, { once: true });
+ });
+}
+
+async function waitForDataChannelCapacity(channel) {
+ if (channel.bufferedAmount <= MAX_BUFFERED_AMOUNT) return;
+ channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2;
+ await new Promise((resolve, reject) => {
+ const drained = () => {
+ cleanup();
+ resolve();
+ };
+ const closed = () => {
+ cleanup();
+ reject(new Error("WebRTC DataChannel closed while draining"));
+ };
+ const cleanup = () => {
+ channel.removeEventListener("bufferedamountlow", drained);
+ channel.removeEventListener("close", closed);
+ };
+ channel.addEventListener("bufferedamountlow", drained, { once: true });
+ channel.addEventListener("close", closed, { once: true });
+ });
+}
export class BrowserNodeClient {
constructor(endpoint) {
this.endpoint = normalizeEndpoint(endpoint);
- this.transport = undefined;
+ this.peerConnection = undefined;
+ this.dataChannel = undefined;
+ this.inbox = undefined;
+ this.connectPromise = undefined;
+ this.requestTail = Promise.resolve();
this.peerId = undefined;
+ this.helloResponse = undefined;
+ this.helloPromise = undefined;
}
async connect() {
- if (this.transport) return;
- if (typeof WebTransport === "undefined") {
- throw new Error("This browser does not expose the WebTransport API");
- }
- const transport = new WebTransport(this.endpoint.url, {
- serverCertificateHashes: this.endpoint.certificateHashes.map((value) => ({
- algorithm: "sha-256",
- value,
- })),
- });
+ if (this.dataChannel?.readyState === "open") return;
+ if (this.connectPromise) return this.connectPromise;
+ if (typeof RTCPeerConnection === "undefined") {
+ throw new Error("This browser does not expose the RTCPeerConnection API");
+ }
+ this.connectPromise = this.openDirectConnection();
try {
- await transport.ready;
+ await this.connectPromise;
} catch (error) {
- transport.close();
+ this.close();
throw error;
+ } finally {
+ this.connectPromise = undefined;
}
- this.transport = transport;
}
async request(type, fields = {}, content = new Uint8Array()) {
+ const operation = this.requestTail.then(() =>
+ this.requestDirect(type, fields, content),
+ );
+ this.requestTail = operation.then(
+ () => undefined,
+ () => undefined,
+ );
+ return operation;
+ }
+
+ async openDirectConnection() {
+ const configuration = { iceServers: [] };
+ if (typeof RTCPeerConnection.generateCertificate === "function") {
+ configuration.certificates = [
+ await RTCPeerConnection.generateCertificate({
+ name: "ECDSA",
+ namedCurve: "P-256",
+ }),
+ ];
+ }
+ const peerConnection = new RTCPeerConnection(configuration);
+ const dataChannel = peerConnection.createDataChannel(
+ WEBRTC_DIRECT_DATA_CHANNEL,
+ {
+ ordered: true,
+ },
+ );
+ const inbox = new DataChannelInbox(dataChannel);
+ this.peerConnection = peerConnection;
+ this.dataChannel = dataChannel;
+ this.inbox = inbox;
+ const iceCredential = randomIceCredential();
+ const offer = mungeOfferIceCredentials(
+ await peerConnection.createOffer(),
+ iceCredential,
+ );
+ await peerConnection.setLocalDescription(offer);
+ await peerConnection.setRemoteDescription(
+ serverAnswerFromEndpoint(this.endpoint, iceCredential),
+ );
+ await waitForDataChannelOpen(dataChannel);
+ }
+
+ async requestDirect(type, fields, content) {
await this.connect();
if (!(content instanceof Uint8Array) || content.length > MAX_CHUNK_SIZE) {
- throw new Error(`Request content must be at most ${MAX_CHUNK_SIZE} bytes`);
+ throw new Error(
+ `Request content must be at most ${MAX_CHUNK_SIZE} bytes`,
+ );
}
const requestId = nextRequestId;
nextRequestId += 1;
- const stream = await this.transport.createBidirectionalStream();
- const writer = stream.writable.getWriter();
+ const header = encoder.encode(
+ JSON.stringify({
+ version: PROTOCOL_VERSION,
+ request_id: requestId,
+ content_length: content.length,
+ type,
+ ...fields,
+ }),
+ );
+ if (header.length === 0 || header.length > MAX_RESPONSE_HEADER_BYTES) {
+ throw new Error(`Request header is ${header.length} bytes`);
+ }
+ const frame = new Uint8Array(4 + header.length + content.length);
+ new DataView(frame.buffer).setUint32(0, header.length, false);
+ frame.set(header, 4);
+ frame.set(content, 4 + header.length);
+ for (
+ let offset = 0;
+ offset < frame.length;
+ offset += WEBRTC_WRITE_CHUNK_BYTES
+ ) {
+ await waitForDataChannelCapacity(this.dataChannel);
+ this.dataChannel.send(
+ frame.subarray(offset, offset + WEBRTC_WRITE_CHUNK_BYTES),
+ );
+ }
+ let timeout;
+ let responseFrame;
try {
- const header = encoder.encode(
- JSON.stringify({
- version: PROTOCOL_VERSION,
- request_id: requestId,
- content_length: content.length,
- type,
- ...fields,
+ responseFrame = await Promise.race([
+ readResponseFrame(this.inbox),
+ new Promise((_, reject) => {
+ timeout = setTimeout(
+ () => reject(new Error("WebRTC request timed out")),
+ REQUEST_TIMEOUT_MS,
+ );
}),
- );
- if (header.length === 0 || header.length > MAX_RESPONSE_HEADER_BYTES) {
- throw new Error(`Request header is ${header.length} bytes`);
- }
- const prefix = new Uint8Array(4);
- new DataView(prefix.buffer).setUint32(0, header.length, false);
- await writer.write(prefix);
- await writer.write(header);
- if (content.length > 0) await writer.write(content);
- await writer.close();
+ ]);
+ } catch (error) {
+ this.close();
+ throw error;
} finally {
- writer.releaseLock();
+ clearTimeout(timeout);
}
- const response = parseResponseFrame(await readAll(stream.readable));
+ const response = parseResponseFrame(responseFrame);
if (response.header.request_id !== requestId) {
throw new Error(
`Response ID ${response.header.request_id} does not match request ${requestId}`,
);
}
if (response.header.status === "error") {
- const error = new Error(response.header.message ?? "Node returned an error");
+ const error = new Error(
+ response.header.message ?? "Node returned an error",
+ );
error.code = response.header.code;
throw error;
}
@@ -325,29 +656,23 @@ export class BrowserNodeClient {
}
async hello() {
- const { header } = await this.request("hello");
- if (header.type !== "hello") throw new Error("Expected a HELLO response");
- if (header.protocol !== PROTOCOL_NAME) {
- throw new Error(`Unsupported browser protocol ${header.protocol}`);
- }
- hexToBytes(header.peer_id, 32);
- const advertisedEndpoint = normalizeEndpoint(header.endpoint);
- if (
- advertisedEndpoint.multiaddr !== this.endpoint.multiaddr ||
- advertisedEndpoint.peerId !== header.peer_id.toLowerCase()
- ) {
- throw new Error("Node advertised a different WebTransport endpoint");
+ if (this.helloResponse && this.dataChannel?.readyState === "open") {
+ return this.helloResponse;
}
- if (
- this.endpoint.peerId &&
- header.peer_id.toLowerCase() !== this.endpoint.peerId.toLowerCase()
- ) {
- throw new Error(
- `Endpoint identity mismatch: expected ${this.endpoint.peerId}, received ${header.peer_id}`,
- );
+ if (this.helloPromise) return this.helloPromise;
+ this.helloPromise = (async () => {
+ const challengeBytes = crypto.getRandomValues(new Uint8Array(32));
+ const challenge = bytesToHex(challengeBytes);
+ const { header } = await this.request("hello", { challenge });
+ this.peerId = verifyHelloIdentity(header, this.endpoint, challengeBytes);
+ this.helloResponse = header;
+ return header;
+ })();
+ try {
+ return await this.helloPromise;
+ } finally {
+ this.helloPromise = undefined;
}
- this.peerId = header.peer_id;
- return header;
}
async findNode(target, count = 20) {
@@ -362,10 +687,12 @@ export class BrowserNodeClient {
}
for (const node of header.nodes) {
hexToBytes(node.peer_id, 32);
- if (node.webtransport) {
- const parsed = normalizeEndpoint(node.webtransport);
+ if (node.webrtc_direct) {
+ const parsed = normalizeEndpoint(node.webrtc_direct);
if (parsed.peerId !== node.peer_id.toLowerCase()) {
- throw new Error(`Node ${node.peer_id} advertised another peer's endpoint`);
+ throw new Error(
+ `Node ${node.peer_id} advertised another peer's endpoint`,
+ );
}
}
}
@@ -405,7 +732,10 @@ export class BrowserNodeClient {
if (header.address.toLowerCase() !== address.toLowerCase()) {
throw new Error("Node returned a quote for a different chunk address");
}
- return { quote: header.quote, alreadyStored: Boolean(header.already_stored) };
+ return {
+ quote: header.quote,
+ alreadyStored: Boolean(header.already_stored),
+ };
}
async putChunk(address, content, quote, transactionHash) {
@@ -423,12 +753,22 @@ export class BrowserNodeClient {
if (header.address.toLowerCase() !== address.toLowerCase()) {
throw new Error("Node stored a different chunk address");
}
- return { address: header.address, alreadyStored: Boolean(header.already_stored) };
+ return {
+ address: header.address,
+ alreadyStored: Boolean(header.already_stored),
+ };
}
close() {
- this.transport?.close({ closeCode: 0, reason: "client closed" });
- this.transport = undefined;
+ this.dataChannel?.close();
+ this.peerConnection?.close();
+ this.dataChannel = undefined;
+ this.peerConnection = undefined;
+ this.inbox = undefined;
+ this.connectPromise = undefined;
+ this.peerId = undefined;
+ this.helloResponse = undefined;
+ this.helloPromise = undefined;
}
}
@@ -437,42 +777,145 @@ function endpointKey(endpoint) {
return normalized.multiaddr;
}
+const DEFAULT_MAX_POOLED_CLIENTS = 10;
+
+/**
+ * A bounded set of reusable browser-to-node WebRTC associations.
+ *
+ * In Safari, the PoC exhausted WebRTC resources when it rapidly replaced every
+ * seed connection for each chunk, even though callers invoked `close()`.
+ * Keeping authenticated, persistent DataChannels avoids relying on prompt
+ * reclamation and avoids repeating ICE, DTLS, SCTP, and HELLO for each lookup
+ * and record.
+ */
+export class BrowserNodeClientPool {
+ constructor({
+ maxClients = DEFAULT_MAX_POOLED_CLIENTS,
+ clientFactory = (endpoint) => new BrowserNodeClient(endpoint),
+ } = {}) {
+ if (!Number.isSafeInteger(maxClients) || maxClients < 1) {
+ throw new Error("WebRTC client pool size must be a positive integer");
+ }
+ if (typeof clientFactory !== "function") {
+ throw new Error("WebRTC client pool factory must be a function");
+ }
+ this.maxClients = maxClients;
+ this.clientFactory = clientFactory;
+ this.entries = new Map();
+ this.waiters = [];
+ this.clock = 0;
+ this.closed = false;
+ }
+
+ get size() {
+ return this.entries.size;
+ }
+
+ async withClient(endpoint, operation) {
+ if (typeof operation !== "function") {
+ throw new Error("WebRTC client pool operation must be a function");
+ }
+ const entry = await this.acquire(endpoint);
+ try {
+ return await operation(entry.client);
+ } finally {
+ this.release(entry);
+ }
+ }
+
+ async acquire(endpoint) {
+ const key = endpointKey(endpoint);
+ for (;;) {
+ if (this.closed) throw new Error("WebRTC client pool is closed");
+
+ const existing = this.entries.get(key);
+ if (existing) {
+ existing.active += 1;
+ existing.lastUsed = ++this.clock;
+ return existing;
+ }
+
+ if (this.entries.size < this.maxClients) {
+ const entry = {
+ key,
+ client: this.clientFactory(endpoint),
+ active: 1,
+ lastUsed: ++this.clock,
+ };
+ this.entries.set(key, entry);
+ return entry;
+ }
+
+ let oldestIdle;
+ for (const candidate of this.entries.values()) {
+ if (
+ candidate.active === 0 &&
+ (!oldestIdle || candidate.lastUsed < oldestIdle.lastUsed)
+ ) {
+ oldestIdle = candidate;
+ }
+ }
+ if (oldestIdle) {
+ this.entries.delete(oldestIdle.key);
+ oldestIdle.client.close();
+ continue;
+ }
+
+ await new Promise((resolve) => this.waiters.push(resolve));
+ }
+ }
+
+ release(entry) {
+ entry.active = Math.max(0, entry.active - 1);
+ entry.lastUsed = ++this.clock;
+ this.waiters.shift()?.();
+ }
+
+ close() {
+ if (this.closed) return;
+ this.closed = true;
+ for (const entry of this.entries.values()) entry.client.close();
+ this.entries.clear();
+ for (const wake of this.waiters.splice(0)) wake();
+ }
+}
+
export async function iterativeFindClosest(
seedEndpoints,
target,
- { k = 20, alpha = 3, maxIterations = 20, onProgress = () => {} } = {},
+ {
+ k = 20,
+ alpha = 3,
+ maxIterations = 20,
+ onProgress = () => {},
+ clientPool,
+ } = {},
) {
hexToBytes(target, 32);
if (!Array.isArray(seedEndpoints) || seedEndpoints.length === 0) {
throw new Error("At least one seed endpoint is required");
}
- const clients = new Map();
+ const ownsClientPool = clientPool === undefined;
+ const pool = clientPool ?? new BrowserNodeClientPool();
const failures = [];
const seedNodes = [];
- const clientFor = (endpoint) => {
- const key = endpointKey(endpoint);
- let client = clients.get(key);
- if (!client) {
- client = new BrowserNodeClient(endpoint);
- clients.set(key, client);
- }
- return client;
- };
-
await Promise.all(
seedEndpoints.map(async (endpoint) => {
const seedName =
- typeof endpoint === "string" ? endpoint : endpoint?.multiaddr ?? "seed";
+ typeof endpoint === "string"
+ ? endpoint
+ : (endpoint?.multiaddr ?? "seed");
try {
- const client = clientFor(endpoint);
- const hello = await client.hello();
+ const hello = await pool.withClient(endpoint, (client) =>
+ client.hello(),
+ );
seedNodes.push({
peer_id: hello.peer_id,
native_addresses: [],
reliability: 1,
- webtransport: hello.endpoint,
+ webrtc_direct: hello.endpoint,
});
onProgress(`Connected seed ${hello.peer_id}`);
} catch (error) {
@@ -482,38 +925,59 @@ export async function iterativeFindClosest(
}),
);
if (seedNodes.length === 0) {
- for (const client of clients.values()) client.close();
+ if (ownsClientPool) pool.close();
const detail = failures.map(({ error }) => error.message).join("; ");
- throw new Error(`Could not connect to any WebTransport seed: ${detail}`);
+ throw new Error(`Could not connect to any WebRtcDirect seed: ${detail}`);
}
- const lookup = new BrowserIterativeLookupNative(target, k, alpha, maxIterations);
- lookup.addCandidates(seedNodes);
- await lookup.run(async ({ target: lookupTarget, count, iteration, candidates }) =>
- Promise.all(
- candidates.map(async (candidate) => {
- try {
- const candidateClient = clientFor(candidate.webtransport);
- if (!candidateClient.peerId) await candidateClient.hello();
- const nodes = await candidateClient.findNode(lookupTarget, count);
- onProgress(
- `Iteration ${iteration}: ${candidate.peer_id} returned ${nodes.length} nodes`,
- );
- return {
- status: "succeeded",
- responder: candidate.peer_id,
- candidates: nodes,
- };
- } catch (error) {
- failures.push({ peerId: candidate.peer_id, error });
- onProgress(`Query ${candidate.peer_id} failed: ${error.message}`);
- return { status: "failed", responder: candidate.peer_id };
- }
- }),
- ),
+ const lookup = new BrowserIterativeLookupNative(
+ target,
+ k,
+ alpha,
+ maxIterations,
);
+ lookup.addCandidates(seedNodes);
+ try {
+ await lookup.run(
+ async ({ target: lookupTarget, count, iteration, candidates }) =>
+ Promise.all(
+ candidates.map(async (candidate) => {
+ try {
+ const nodes = await pool.withClient(
+ candidate.webrtc_direct,
+ async (client) => {
+ if (!client.peerId) await client.hello();
+ return client.findNode(lookupTarget, count);
+ },
+ );
+ onProgress(
+ `Iteration ${iteration}: ${candidate.peer_id} returned ${nodes.length} nodes`,
+ );
+ return {
+ status: "succeeded",
+ responder: candidate.peer_id,
+ candidates: nodes,
+ };
+ } catch (error) {
+ failures.push({ peerId: candidate.peer_id, error });
+ onProgress(`Query ${candidate.peer_id} failed: ${error.message}`);
+ return { status: "failed", responder: candidate.peer_id };
+ }
+ }),
+ ),
+ );
- return { nodes: lookup.results(), queried: lookup.queriedPeers(), failures, clients };
+ return {
+ nodes: lookup.results(),
+ queried: lookup.queriedPeers(),
+ failures,
+ clientPool: pool,
+ ownsClientPool,
+ };
+ } catch (error) {
+ if (ownsClientPool) pool.close();
+ throw error;
+ }
}
export async function getChunkFromClosest(
@@ -530,33 +994,32 @@ export async function getChunkFromClosest(
try {
for (const node of lookup.nodes) {
- if (!node.webtransport) continue;
- const endpoint = node.webtransport;
- const key = endpointKey(endpoint);
- let client = lookup.clients.get(key);
- if (!client) {
- client = new BrowserNodeClient(endpoint);
- lookup.clients.set(key, client);
- }
-
+ if (!node.webrtc_direct) continue;
try {
- if (!client.peerId) await client.hello();
onProgress(`Requesting ${address} from ${node.peer_id}`);
- const chunk = await client.getChunk(address);
+ const chunk = await lookup.clientPool.withClient(
+ node.webrtc_direct,
+ async (client) => {
+ if (!client.peerId) await client.hello();
+ return client.getChunk(address);
+ },
+ );
return { ...chunk, node, lookup };
} catch (error) {
attempted.push({ peerId: node.peer_id, error });
- onProgress(`Node ${node.peer_id} did not return the file: ${error.message}`);
+ onProgress(
+ `Node ${node.peer_id} did not return the file: ${error.message}`,
+ );
}
}
} finally {
- for (const client of lookup.clients.values()) client.close();
+ if (lookup.ownsClientPool) lookup.clientPool.close();
}
const detail = attempted
.map(({ peerId, error }) => `${peerId}: ${error.message}`)
.join("; ");
throw new Error(
- `No closest WebTransport node returned chunk ${address}${detail ? ` (${detail})` : ""}`,
+ `No closest WebRtcDirect node returned chunk ${address}${detail ? ` (${detail})` : ""}`,
);
}
diff --git a/web/src/protocol.test.js b/web/src/protocol.test.js
index 5959317d..dbc7998a 100644
--- a/web/src/protocol.test.js
+++ b/web/src/protocol.test.js
@@ -1,14 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { blake3 } from "@noble/hashes/blake3.js";
+import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import {
- BrowserNodeClient,
+ BrowserNodeClientPool,
bytesToHex,
- getChunkFromClosest,
hexToBytes,
+ mungeOfferIceCredentials,
parseResponseFrame,
- parseWebTransportMultiaddr,
+ parseWebRtcDirectMultiaddr,
+ readResponseFrame,
+ serverAnswerFromEndpoint,
verifyChunk,
+ verifyHelloIdentity,
} from "./protocol.js";
test("hex conversion enforces fixed widths", () => {
@@ -40,263 +44,262 @@ test("response framing preserves a raw binary body", () => {
assert.deepEqual([...parsed.content], [1, 2, 3]);
});
-test("WebTransport multiaddresses carry current and next certificate hashes", () => {
+test("response framing completes without waiting for stream EOF", async () => {
+ const header = new TextEncoder().encode(
+ JSON.stringify({
+ version: 3,
+ request_id: 10,
+ status: "ok",
+ content_length: 3,
+ type: "chunk",
+ }),
+ );
+ const frame = new Uint8Array(4 + header.length + 3);
+ new DataView(frame.buffer).setUint32(0, header.length, false);
+ frame.set(header, 4);
+ frame.set([4, 5, 6], 4 + header.length);
+ let requestedAnotherChunk = false;
+ const stream = {
+ async *[Symbol.asyncIterator]() {
+ yield frame.subarray(0, 2);
+ yield frame.subarray(2, frame.length);
+ requestedAnotherChunk = true;
+ await new Promise(() => {});
+ },
+ };
+
+ const received = await readResponseFrame(stream);
+ assert.deepEqual(received, frame);
+ assert.equal(requestedAnotherChunk, false);
+});
+
+test("WebRTC Direct multiaddresses carry one stable certificate hash", () => {
const peerId = "ab".repeat(32);
- const multiaddr = webtransportMultiaddr("ip4", "127.0.0.1", 24000, peerId, [
+ const multiaddr = webrtc_directMultiaddr(
+ "ip4",
+ "127.0.0.1",
+ 24000,
+ peerId,
0x11,
- 0x22,
- ]);
- const parsed = parseWebTransportMultiaddr(multiaddr);
+ );
+ const parsed = parseWebRtcDirectMultiaddr(multiaddr);
- assert.equal(parsed.url, "https://127.0.0.1:24000/autonomi/webtransport/v1");
+ assert.equal(parsed.hostProtocol, "ip4");
+ assert.equal(parsed.host, "127.0.0.1");
+ assert.equal(parsed.port, 24000);
assert.equal(parsed.peerId, peerId);
- assert.deepEqual(
- parsed.certificateHashes.map((hash) => [...hash]),
- [Array(32).fill(0x11), Array(32).fill(0x22)],
+ assert.deepEqual([...parsed.certificateHash], Array(32).fill(0x11));
+ assert.throws(
+ () =>
+ parseWebRtcDirectMultiaddr(
+ `/ip4/127.0.0.1/udp/24000/webrtc-direct/p2p/${peerId}`,
+ ),
+ /certhash|incomplete/,
);
assert.throws(
() =>
- parseWebTransportMultiaddr(
- `/ip4/127.0.0.1/udp/24000/quic-v1/webtransport/p2p/${peerId}`,
+ parseWebRtcDirectMultiaddr(
+ `/dns/node.example/udp/24000/webrtc-direct/certhash/${certificateMultihash(0x11)}/p2p/${peerId}`,
),
- /certificate hashes/,
+ /literal IP/,
);
});
-test("BLAKE3 verification accepts the canonical empty hash", () => {
- const emptyHash =
- "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
- assert.equal(verifyChunk(emptyHash, new Uint8Array()), emptyHash);
- assert.throws(() => verifyChunk("00".repeat(32), new Uint8Array()), /BLAKE3 mismatch/);
+test("the browser synthesizes a certificate-pinned ICE-lite answer", () => {
+ const endpoint = webrtc_directMultiaddr(
+ "ip4",
+ "127.0.0.1",
+ 24000,
+ "ab".repeat(32),
+ 0x11,
+ );
+ const credential = `saorsa+webrtc+v1/${"a".repeat(32)}`;
+ const answer = serverAnswerFromEndpoint(endpoint, credential);
+
+ assert.equal(answer.type, "answer");
+ assert.match(answer.sdp, /a=ice-lite/);
+ assert.match(
+ answer.sdp,
+ /m=application 24000 UDP\/DTLS\/SCTP webrtc-datachannel/,
+ );
+ assert.match(
+ answer.sdp,
+ new RegExp(`a=ice-ufrag:${credential.replaceAll("+", "\\+")}`),
+ );
+ assert.match(answer.sdp, /a=fingerprint:sha-256 11:11:11:11/);
+ assert.match(answer.sdp, /a=setup:passive/);
});
-test("browser lookup discovers a direct node and downloads a verified chunk", async (t) => {
- const content = new TextEncoder().encode("direct browser test file");
- const address = bytesToHex(blake3(content));
- const seedPeer = "ff".repeat(32);
- const storagePeer = address;
- const seed = endpoint("seed.test", seedPeer, 0x11);
- const storage = endpoint("storage.test", storagePeer, 0x22);
- const calls = [];
- const routes = new Map([
- [
- seed.url,
- (request) => {
- calls.push(["seed", request.type]);
- if (request.type === "hello") return helloResponse(request, seed);
- if (request.type === "find_node") {
- return response(request, {
- type: "nodes",
- target: request.target,
- nodes: [browserNode(storage), browserNode(seed)],
- });
- }
- throw new Error(`Unexpected seed request ${request.type}`);
- },
- ],
- [
- storage.url,
- (request) => {
- calls.push(["storage", request.type]);
- if (request.type === "hello") return helloResponse(request, storage);
- if (request.type === "find_node") {
- return response(request, {
- type: "nodes",
- target: request.target,
- nodes: [browserNode(storage), browserNode(seed)],
- });
- }
- if (request.type === "get_chunk") {
- return response(
- request,
- { type: "chunk", address: request.address, size: content.length },
- content,
- );
- }
- throw new Error(`Unexpected storage request ${request.type}`);
- },
- ],
- ]);
+test("the browser offer uses the Saorsa credential as ufrag and password", () => {
+ const credential = `saorsa+webrtc+v1/${"b".repeat(32)}`;
+ const offer = mungeOfferIceCredentials(
+ {
+ type: "offer",
+ sdp: "v=0\r\na=ice-ufrag:browser-generated\r\na=ice-pwd:browser-secret\r\n",
+ },
+ credential,
+ );
+ assert.match(
+ offer.sdp,
+ new RegExp(`a=ice-ufrag:${credential.replaceAll("+", "\\+")}`),
+ );
+ assert.match(
+ offer.sdp,
+ new RegExp(`a=ice-pwd:${credential.replaceAll("+", "\\+")}`),
+ );
+});
- const previousWebTransport = globalThis.WebTransport;
- globalThis.WebTransport = mockWebTransport(routes);
- t.after(() => {
- globalThis.WebTransport = previousWebTransport;
+test("the browser reuses a bounded pool of WebRTC node connections", async () => {
+ const endpoints = [0x11, 0x22, 0x33].map((hashByte, index) =>
+ webrtc_directMultiaddr(
+ "ip4",
+ "127.0.0.1",
+ 24000 + index,
+ (index + 1).toString(16).padStart(2, "0").repeat(32),
+ hashByte,
+ ),
+ );
+ const created = [];
+ const closed = [];
+ const pool = new BrowserNodeClientPool({
+ maxClients: 2,
+ clientFactory: (endpoint) => {
+ const client = { endpoint, close: () => closed.push(endpoint) };
+ created.push(client);
+ return client;
+ },
});
- const downloaded = await getChunkFromClosest([seed.multiaddr], address);
- assert.deepEqual(downloaded.content, content);
- assert.equal(downloaded.hash, address);
- assert.equal(downloaded.node.peer_id, storagePeer);
- assert.deepEqual(calls, [
- ["seed", "hello"],
- ["seed", "find_node"],
- ["storage", "hello"],
- ["storage", "find_node"],
- ["storage", "get_chunk"],
- ]);
-});
-
-test("paid PUT frames the encrypted chunk as a binary request body", async (t) => {
- const content = new TextEncoder().encode("encrypted record");
- const address = bytesToHex(blake3(content));
- const node = endpoint("storage.test", "33".repeat(32), 0x44);
- const transactionHash = "55".repeat(32);
- const quote = { quote_hash: "66".repeat(32) };
- let observed;
- const routes = new Map([
- [
- node.url,
- (request) => {
- if (request.type === "put_chunk") {
- observed = request;
- return response(request, {
- type: "chunk_stored",
- address,
- already_stored: false,
- });
- }
- throw new Error(`Unexpected request ${request.type}`);
- },
- ],
- ]);
- const previousWebTransport = globalThis.WebTransport;
- globalThis.WebTransport = mockWebTransport(routes);
- t.after(() => {
- globalThis.WebTransport = previousWebTransport;
+ let firstClient;
+ await pool.withClient(endpoints[0], async (client) => {
+ firstClient = client;
});
+ await pool.withClient(endpoints[0], async (client) => {
+ assert.equal(client, firstClient);
+ });
+ await pool.withClient(endpoints[1], async () => {});
+ await pool.withClient(endpoints[2], async () => {});
- const client = new BrowserNodeClient(node.multiaddr);
- const stored = await client.putChunk(address, content, quote, transactionHash);
- assert.deepEqual(stored, { address, alreadyStored: false });
- assert.equal(observed.content_length, content.length);
- assert.deepEqual(observed.content, content);
- assert.deepEqual(observed.quote, quote);
- assert.equal(observed.transaction_hash, transactionHash);
+ assert.equal(created.length, 3);
+ assert.equal(pool.size, 2);
+ assert.deepEqual(closed, [endpoints[0]]);
+ pool.close();
+ assert.equal(closed.length, 3);
});
-function browserNode(endpoint) {
- return {
- peer_id: endpoint.peer_id,
- native_addresses: [],
- reliability: 1,
- webtransport: {
- multiaddr: endpoint.multiaddr,
+test("the WebRTC client pool waits instead of exceeding its connection cap", async () => {
+ const firstEndpoint = webrtc_directMultiaddr(
+ "ip4",
+ "127.0.0.1",
+ 24000,
+ "11".repeat(32),
+ 0x11,
+ );
+ const secondEndpoint = webrtc_directMultiaddr(
+ "ip4",
+ "127.0.0.1",
+ 24001,
+ "22".repeat(32),
+ 0x22,
+ );
+ const created = [];
+ let releaseFirst;
+ let secondEntered = false;
+ const pool = new BrowserNodeClientPool({
+ maxClients: 1,
+ clientFactory: (endpoint) => {
+ const client = { endpoint, close() {} };
+ created.push(client);
+ return client;
},
- };
-}
+ });
+ const first = pool.withClient(
+ firstEndpoint,
+ () => new Promise((resolve) => (releaseFirst = resolve)),
+ );
+ const second = pool.withClient(secondEndpoint, async () => {
+ secondEntered = true;
+ });
-function helloResponse(request, endpoint) {
- return response(request, {
+ await Promise.resolve();
+ await Promise.resolve();
+ assert.equal(created.length, 1);
+ assert.equal(secondEntered, false);
+ releaseFirst();
+ await Promise.all([first, second]);
+ assert.equal(created.length, 2);
+ assert.equal(secondEntered, true);
+ pool.close();
+});
+
+test("HELLO binds the persistent ANT identity to the WebRTC endpoint", () => {
+ const challenge = new Uint8Array(32).fill(0x22);
+ const { publicKey, secretKey } = ml_dsa65.keygen(
+ new Uint8Array(32).fill(0x33),
+ );
+ const peerId = bytesToHex(blake3(publicKey));
+ const multiaddr = webrtc_directMultiaddr(
+ "ip4",
+ "127.0.0.1",
+ 24000,
+ peerId,
+ 0x44,
+ );
+ const transcript = concatBytes(
+ new TextEncoder().encode("autonomi-webrtc-direct-hello-v1\0"),
+ challenge,
+ new TextEncoder().encode(peerId),
+ new TextEncoder().encode(multiaddr),
+ );
+ const header = {
type: "hello",
protocol: "autonomi.web.poc.v3",
- peer_id: endpoint.peer_id,
- max_chunk_size: 4 * 1024 * 1024,
- endpoint: {
- multiaddr: endpoint.multiaddr,
- },
- capabilities: ["find_node", "get_chunk"],
- });
-}
-
-function response(request, fields, content = new Uint8Array()) {
- return {
- header: {
- version: 3,
- request_id: request.request_id,
- status: "ok",
- content_length: content.length,
- ...fields,
- },
- content,
+ challenge: bytesToHex(challenge),
+ peer_id: peerId,
+ public_key: bytesToHex(publicKey),
+ signature: bytesToHex(ml_dsa65.sign(transcript, secretKey)),
+ endpoint: { multiaddr },
};
-}
+
+ assert.equal(verifyHelloIdentity(header, multiaddr, challenge), peerId);
+ assert.throws(
+ () =>
+ verifyHelloIdentity(
+ { ...header, challenge: "00".repeat(32) },
+ multiaddr,
+ challenge,
+ ),
+ /different HELLO challenge/,
+ );
+});
+
+test("BLAKE3 verification accepts the canonical empty hash", () => {
+ const emptyHash =
+ "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
+ assert.equal(verifyChunk(emptyHash, new Uint8Array()), emptyHash);
+ assert.throws(
+ () => verifyChunk("00".repeat(32), new Uint8Array()),
+ /BLAKE3 mismatch/,
+ );
+});
function certificateMultihash(byte) {
const multihash = Uint8Array.from([0x12, 0x20, ...Array(32).fill(byte)]);
return `u${Buffer.from(multihash).toString("base64url")}`;
}
-function webtransportMultiaddr(hostProtocol, host, port, peerId, hashBytes) {
- const hashes = hashBytes
- .map((byte) => `/certhash/${certificateMultihash(byte)}`)
- .join("");
- return `/${hostProtocol}/${host}/udp/${port}/quic-v1/webtransport${hashes}/p2p/${peerId}`;
-}
-
-function endpoint(host, peerId, certificateByte) {
- return {
- peer_id: peerId,
- url: `https://${host}/autonomi/webtransport/v1`,
- multiaddr: webtransportMultiaddr("dns", host, 443, peerId, [certificateByte]),
- };
-}
-
-function encodeResponse({ header, content }) {
- const headerBytes = new TextEncoder().encode(JSON.stringify(header));
- const frame = new Uint8Array(4 + headerBytes.length + content.length);
- new DataView(frame.buffer).setUint32(0, headerBytes.length, false);
- frame.set(headerBytes, 4);
- frame.set(content, 4 + headerBytes.length);
- return frame;
+function concatBytes(...parts) {
+ const output = new Uint8Array(
+ parts.reduce((length, part) => length + part.length, 0),
+ );
+ let offset = 0;
+ for (const part of parts) {
+ output.set(part, offset);
+ offset += part.length;
+ }
+ return output;
}
-function mockWebTransport(routes) {
- return class MockWebTransport {
- constructor(url, options) {
- this.url = url;
- this.options = options;
- this.ready = routes.has(url)
- ? Promise.resolve()
- : Promise.reject(new Error(`No mock endpoint for ${url}`));
- }
-
- async createBidirectionalStream() {
- const requestChunks = [];
- let responseController;
- const readable = new ReadableStream({
- start(controller) {
- responseController = controller;
- },
- });
- const writable = new WritableStream({
- write(chunk) {
- requestChunks.push(chunk);
- },
- close: () => {
- try {
- const length = requestChunks.reduce((total, chunk) => total + chunk.length, 0);
- const encoded = new Uint8Array(length);
- let offset = 0;
- for (const chunk of requestChunks) {
- encoded.set(chunk, offset);
- offset += chunk.length;
- }
- if (encoded.length < 4) throw new Error("Request omitted its frame prefix");
- const headerLength = new DataView(
- encoded.buffer,
- encoded.byteOffset,
- encoded.byteLength,
- ).getUint32(0, false);
- const contentOffset = 4 + headerLength;
- const request = JSON.parse(
- new TextDecoder().decode(encoded.subarray(4, contentOffset)),
- );
- request.content = encoded.slice(contentOffset);
- if (request.content.length !== request.content_length) {
- throw new Error("Request content length mismatch");
- }
- const handler = routes.get(this.url);
- responseController.enqueue(encodeResponse(handler(request)));
- responseController.close();
- } catch (error) {
- responseController.error(error);
- }
- },
- });
- return { readable, writable };
- }
-
- close() {}
- };
+function webrtc_directMultiaddr(hostProtocol, host, port, peerId, hashByte) {
+ return `/${hostProtocol}/${host}/udp/${port}/webrtc-direct/certhash/${certificateMultihash(hashByte)}/p2p/${peerId}`;
}
diff --git a/web/src/upload.js b/web/src/upload.js
index f5bff9ce..45cdf7d1 100644
--- a/web/src/upload.js
+++ b/web/src/upload.js
@@ -1,5 +1,5 @@
import { encryptPublicFile as encryptPublicFileNative } from "../pkg/ant_core.js";
-import { BrowserNodeClient, iterativeFindClosest } from "./protocol.js";
+import { BrowserNodeClientPool, iterativeFindClosest } from "./protocol.js";
import { payForStorageQuotes, verifyStorageQuote } from "./payment.js";
export const MAX_BROWSER_UPLOAD_BYTES = 64 * 1024 * 1024;
@@ -13,10 +13,14 @@ export async function encryptPublicFile(
encrypt = encryptPublicFileNative,
} = {},
) {
- if (!(content instanceof Uint8Array)) throw new Error("Upload content must be bytes");
- if (content.length < 3) throw new Error("Self-encryption requires a file of at least 3 bytes");
+ if (!(content instanceof Uint8Array))
+ throw new Error("Upload content must be bytes");
+ if (content.length < 3)
+ throw new Error("Self-encryption requires a file of at least 3 bytes");
if (content.length > MAX_BROWSER_UPLOAD_BYTES) {
- throw new Error(`Browser uploads are limited to ${MAX_BROWSER_UPLOAD_BYTES} bytes`);
+ throw new Error(
+ `Browser uploads are limited to ${MAX_BROWSER_UPLOAD_BYTES} bytes`,
+ );
}
const encrypted = encrypt(content);
return {
@@ -34,10 +38,6 @@ export async function encryptPublicFile(
};
}
-function closeClients(clients) {
- for (const client of clients.values()) client.close();
-}
-
function assertUploadNode(hello, paymentNetwork) {
if (
!Array.isArray(hello.capabilities) ||
@@ -49,64 +49,92 @@ function assertUploadNode(hello, paymentNetwork) {
const advertised = hello.payment;
if (
!advertised ||
- new URL(advertised.rpc_url).toString() !== new URL(paymentNetwork.rpc_url).toString() ||
+ new URL(advertised.rpc_url).toString() !==
+ new URL(paymentNetwork.rpc_url).toString() ||
advertised.payment_token_address?.toLowerCase() !==
paymentNetwork.payment_token_address.toLowerCase() ||
advertised.payment_vault_address?.toLowerCase() !==
paymentNetwork.payment_vault_address.toLowerCase()
) {
- throw new Error("Node advertises a different payment network than the manifest");
+ throw new Error(
+ "Node advertises a different payment network than the manifest",
+ );
}
}
-async function prepareRecord(seedEndpoints, paymentNetwork, record, onProgress) {
+async function prepareRecord(
+ seedEndpoints,
+ paymentNetwork,
+ record,
+ clientPool,
+ onProgress,
+) {
onProgress(`Finding closest nodes for ${record.address}`);
- const lookup = await iterativeFindClosest(seedEndpoints, record.address, { onProgress });
+ const lookup = await iterativeFindClosest(seedEndpoints, record.address, {
+ onProgress,
+ clientPool,
+ });
const endpoints = lookup.nodes
- .filter((node) => node.webtransport)
+ .filter((node) => node.webrtc_direct)
.slice(0, MAX_STORE_TARGETS)
- .map((node) => ({ peerId: node.peer_id, endpoint: node.webtransport }));
- try {
- const failures = [];
- for (const target of endpoints) {
- const client = new BrowserNodeClient(target.endpoint);
- try {
- assertUploadNode(await client.hello(), paymentNetwork);
- const response = await client.quoteChunk(record.address, record.content.length);
- const verified = verifyStorageQuote(
- response.quote,
- record.address,
- target.peerId,
- );
- if (response.alreadyStored) {
- onProgress(`Chunk ${record.address} is already stored; skipping payment`);
- return { record, alreadyStored: true, targets: endpoints };
- }
- onProgress(`Verified storage quote ${verified.quoteHash} from ${target.peerId}`);
- return {
- record,
- alreadyStored: false,
- targets: [target, ...endpoints.filter((candidate) => candidate !== target)],
- verified,
- };
- } catch (error) {
- failures.push(`${target.peerId}: ${error.message}`);
- } finally {
- client.close();
- }
+ .map((node) => ({ peerId: node.peer_id, endpoint: node.webrtc_direct }));
+ const failures = [];
+ for (const target of endpoints) {
+ try {
+ const prepared = await clientPool.withClient(
+ target.endpoint,
+ async (client) => {
+ assertUploadNode(await client.hello(), paymentNetwork);
+ const response = await client.quoteChunk(
+ record.address,
+ record.content.length,
+ );
+ const verified = verifyStorageQuote(
+ response.quote,
+ record.address,
+ target.peerId,
+ );
+ if (response.alreadyStored) {
+ onProgress(
+ `Chunk ${record.address} is already stored; skipping payment`,
+ );
+ return { record, alreadyStored: true, targets: endpoints };
+ }
+ onProgress(
+ `Verified storage quote ${verified.quoteHash} from ${target.peerId}`,
+ );
+ return {
+ record,
+ alreadyStored: false,
+ targets: [
+ target,
+ ...endpoints.filter((candidate) => candidate !== target),
+ ],
+ verified,
+ };
+ },
+ );
+ return prepared;
+ } catch (error) {
+ failures.push(`${target.peerId}: ${error.message}`);
}
- throw new Error(`No closest node supplied a valid quote (${failures.join("; ")})`);
- } finally {
- closeClients(lookup.clients);
}
+ throw new Error(
+ `No closest node supplied a valid quote (${failures.join("; ")})`,
+ );
}
-async function storePrepared(prepared, paymentNetwork, transactionHash, onProgress) {
+async function storePrepared(
+ prepared,
+ paymentNetwork,
+ transactionHash,
+ clientPool,
+ onProgress,
+) {
if (prepared.alreadyStored) return 1;
const attempts = await Promise.allSettled(
prepared.targets.map(async (target) => {
- const client = new BrowserNodeClient(target.endpoint);
- try {
+ return clientPool.withClient(target.endpoint, async (client) => {
assertUploadNode(await client.hello(), paymentNetwork);
const result = await client.putChunk(
prepared.record.address,
@@ -118,17 +146,19 @@ async function storePrepared(prepared, paymentNetwork, transactionHash, onProgre
`${result.alreadyStored ? "Confirmed" : "Stored"} ${prepared.record.address} on ${target.peerId}`,
);
return result;
- } finally {
- client.close();
- }
+ });
}),
);
- const stored = attempts.filter((attempt) => attempt.status === "fulfilled").length;
+ const stored = attempts.filter(
+ (attempt) => attempt.status === "fulfilled",
+ ).length;
if (stored === 0) {
const failures = attempts
.filter((attempt) => attempt.status === "rejected")
.map((attempt) => attempt.reason?.message ?? String(attempt.reason));
- throw new Error(`Paid chunk was rejected by every closest node: ${failures.join("; ")}`);
+ throw new Error(
+ `Paid chunk was rejected by every closest node: ${failures.join("; ")}`,
+ );
}
return stored;
}
@@ -141,48 +171,62 @@ export async function uploadPublicFile(
{ onProgress = () => {}, encrypt = encryptPublicFileNative } = {},
) {
const content = new Uint8Array(await file.arrayBuffer());
- onProgress(`Self-encrypting ${file.name} with native ant-core WASM (${content.length.toLocaleString()} bytes)`);
+ onProgress(
+ `Self-encrypting ${file.name} with native ant-core WASM (${content.length.toLocaleString()} bytes)`,
+ );
const encrypted = await encryptPublicFile(content, {
name: file.name,
contentType: file.type,
encrypt,
});
- const prepared = [];
- for (let index = 0; index < encrypted.records.length; index += 1) {
- onProgress(`Preparing record ${index + 1}/${encrypted.records.length}`);
- prepared.push(
- await prepareRecord(seedEndpoints, paymentNetwork, encrypted.records[index], onProgress),
- );
- }
- const payable = prepared.filter((record) => !record.alreadyStored);
- let transactionHash;
- let totalAmount = 0n;
- if (payable.length > 0) {
- const payment = await payForStorageQuotes(
- paymentNetwork,
- payable.map((record) => record.verified),
- walletSecret,
- { onProgress },
- );
- transactionHash = payment.transactionHash;
- totalAmount = payment.totalAmount;
- }
- let replicas = Number.POSITIVE_INFINITY;
- for (let index = 0; index < prepared.length; index += 1) {
- onProgress(`Storing record ${index + 1}/${prepared.length}`);
- const stored = await storePrepared(
- prepared[index],
- paymentNetwork,
+ const clientPool = new BrowserNodeClientPool();
+ try {
+ const prepared = [];
+ for (let index = 0; index < encrypted.records.length; index += 1) {
+ onProgress(`Preparing record ${index + 1}/${encrypted.records.length}`);
+ prepared.push(
+ await prepareRecord(
+ seedEndpoints,
+ paymentNetwork,
+ encrypted.records[index],
+ clientPool,
+ onProgress,
+ ),
+ );
+ }
+ const payable = prepared.filter((record) => !record.alreadyStored);
+ let transactionHash;
+ let totalAmount = 0n;
+ if (payable.length > 0) {
+ const payment = await payForStorageQuotes(
+ paymentNetwork,
+ payable.map((record) => record.verified),
+ walletSecret,
+ { onProgress },
+ );
+ transactionHash = payment.transactionHash;
+ totalAmount = payment.totalAmount;
+ }
+ let replicas = Number.POSITIVE_INFINITY;
+ for (let index = 0; index < prepared.length; index += 1) {
+ onProgress(`Storing record ${index + 1}/${prepared.length}`);
+ const stored = await storePrepared(
+ prepared[index],
+ paymentNetwork,
+ transactionHash,
+ clientPool,
+ onProgress,
+ );
+ replicas = Math.min(replicas, stored);
+ }
+ encrypted.descriptor.replicas = Number.isFinite(replicas) ? replicas : 0;
+ return {
+ file: encrypted.descriptor,
transactionHash,
- onProgress,
- );
- replicas = Math.min(replicas, stored);
+ storageCostAtto: totalAmount.toString(),
+ records: encrypted.records.length,
+ };
+ } finally {
+ clientPool.close();
}
- encrypted.descriptor.replicas = Number.isFinite(replicas) ? replicas : 0;
- return {
- file: encrypted.descriptor,
- transactionHash,
- storageCostAtto: totalAmount.toString(),
- records: encrypted.records.length,
- };
}
diff --git a/web/src/wasm.test.js b/web/src/wasm.test.js
index 8f376039..8aa261f2 100644
--- a/web/src/wasm.test.js
+++ b/web/src/wasm.test.js
@@ -19,7 +19,7 @@ function lookupNode(lastByte, stringEndpoint = false) {
peer_id: `${"00".repeat(31)}${lastByte.toString(16).padStart(2, "0")}`,
native_addresses: [],
reliability: 1,
- webtransport: stringEndpoint ? `/test/${lastByte}` : { multiaddr: `/test/${lastByte}` },
+ webrtc_direct: stringEndpoint ? `/test/${lastByte}` : { multiaddr: `/test/${lastByte}` },
};
}
@@ -30,7 +30,7 @@ test("generated WASM drives Saorsa's complete shared iterative lookup", async ()
const termination = await lookup.run(async ({ iteration, candidates }) => {
batches.push(candidates.map((node) => node.peer_id));
if (iteration === 1) {
- assert.equal(candidates[1].webtransport, "/test/2");
+ assert.equal(candidates[1].webrtc_direct, "/test/2");
return [
{
status: "succeeded",
From d10567d57378ead34cf9a0027eb421485d398adc Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:46:09 +0200
Subject: [PATCH 07/31] refactor browser client into Rust WASM
---
Cargo.lock | 19 +
README.md | 8 +-
ant-core/Cargo.toml | 32 +-
ant-core/src/browser.rs | 162 ++-
ant-core/src/browser/crypto.rs | 71 ++
ant-core/src/browser/manifest.rs | 244 ++++
ant-core/src/browser/payment.rs | 476 +++++++
ant-core/src/browser/protocol.rs | 569 +++++++++
ant-core/src/browser/wasm_transport.rs | 1622 ++++++++++++++++++++++++
ant-core/src/lib.rs | 2 +-
web/README.md | 63 +-
web/package-lock.json | 74 +-
web/package.json | 3 -
web/src/file.js | 97 --
web/src/file.test.js | 60 -
web/src/main.js | 87 +-
web/src/manifest.js | 126 +-
web/src/manifest.test.js | 2 +-
web/src/payment.js | 263 +---
web/src/payment.test.js | 235 ----
web/src/protocol.js | 1025 ---------------
web/src/protocol.test.js | 284 +----
web/src/upload.js | 232 ----
web/src/upload.test.js | 38 -
24 files changed, 3334 insertions(+), 2460 deletions(-)
create mode 100644 ant-core/src/browser/crypto.rs
create mode 100644 ant-core/src/browser/manifest.rs
create mode 100644 ant-core/src/browser/payment.rs
create mode 100644 ant-core/src/browser/protocol.rs
create mode 100644 ant-core/src/browser/wasm_transport.rs
delete mode 100644 web/src/file.js
delete mode 100644 web/src/file.test.js
delete mode 100644 web/src/payment.test.js
delete mode 100644 web/src/protocol.js
delete mode 100644 web/src/upload.js
delete mode 100644 web/src/upload.test.js
diff --git a/Cargo.lock b/Cargo.lock
index 2a43938a..6c34de84 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -843,15 +843,19 @@ dependencies = [
"anyhow",
"async-stream",
"axum",
+ "base64",
"blake3",
"bytes",
"console_error_panic_hook",
+ "fips204",
"flate2",
"fs2",
"futures",
+ "futures-channel",
"futures-core",
"futures-util",
"getrandom 0.2.17",
+ "gloo-timers",
"hex",
"js-sys",
"libc",
@@ -874,6 +878,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror 2.0.18",
+ "tiny-keccak",
"tokio",
"tokio-test",
"tokio-util",
@@ -881,9 +886,11 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
+ "url",
"utoipa",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "web-sys",
"windows-sys 0.61.2",
"xor_name",
"zip",
@@ -2929,6 +2936,18 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "group"
version = "0.13.0"
diff --git a/README.md b/README.md
index 75bac8fc..f96ecc9d 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@ A unified CLI and Rust library for storing data on the Autonomi decentralized ne
This project provides two Rust crates and a browser client:
-- **ant-core** — A headless Rust library containing all business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Its portable immutable-data core also builds for browsers with the `browser-wasm` feature.
+- **ant-core** — A headless Rust library containing the Autonomi business logic: data storage/retrieval with self-encryption and EVM payments, node lifecycle management, and local devnet tooling. Its `browser` module shares manifest validation, protocol framing, authenticated WebRTC Direct access, lookup, quote verification, and complete public-file workflows with browser applications through the `browser-wasm` feature.
- **ant-cli** — A thin CLI binary (`ant`) built on `ant-core`.
-- **web** — A direct WebRTC Direct client and test site. It drives Saorsa's
- shared iterative lookup engine and uses `ant-core` through WASM to
- self-encrypt and reconstruct complete public files without a data gateway.
+- **web** — A thin browser UI around `ant-core`'s Rust/WASM client. JavaScript
+ is limited to DOM/file-save integration and submitting the already-verified
+ payment plan with Ethers; no gateway proxies Autonomi data.
Data on Autonomi is **content-addressed**. Files are split into encrypted chunks (via [self-encryption](https://en.wikipedia.org/wiki/Convergent_encryption)), each stored at an XOR address derived from its content. A `DataMap` tracks which chunks belong to a file. Payments for storage are made on an EVM-compatible blockchain (Arbitrum).
diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml
index 7ebfd32a..48721471 100644
--- a/ant-core/Cargo.toml
+++ b/ant-core/Cargo.toml
@@ -15,14 +15,19 @@ crate-type = ["rlib", "cdylib"]
# the browser-WASM target; native networking and node management stay behind
# the `native` feature below.
blake3 = "1"
+base64 = "0.22"
bytes = "1"
hex = "0.4"
+postcard = { version = "1.1.3", features = ["use-std"] }
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
+serde_json = "1"
self_encryption = "0.36"
saorsa-dht-lookup = { version = "0.1.0", path = "../../saorsa-core-web-support/crates/saorsa-dht-lookup" }
thiserror = "2"
+tiny-keccak = { version = "2", features = ["keccak"] }
+url = "2"
# Node management
async-stream = { version = "0.3", optional = true }
@@ -30,10 +35,10 @@ axum = { version = "0.8", optional = true }
flate2 = { version = "1", optional = true }
fs2 = { version = "0.4", optional = true }
futures-core = { version = "0.3", optional = true }
+futures-channel = { version = "0.3", optional = true }
futures-util = { version = "0.3", optional = true }
self-replace = { version = "1", optional = true }
semver = { version = "1", optional = true }
-serde_json = { version = "1", optional = true }
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
tar = { version = "0.4", optional = true }
tempfile = { version = "3", optional = true }
@@ -53,7 +58,6 @@ tower-http = { version = "0.6.8", features = ["cors"], optional = true }
ant-protocol = { version = "2.3.1", optional = true }
xor_name = { version = "5", optional = true }
futures = { version = "0.3", optional = true }
-postcard = { version = "1.1.3", features = ["use-std"], optional = true }
tracing = { version = "0.1", optional = true }
lru = { version = "0.16", optional = true }
rand = { version = "0.8", optional = true }
@@ -83,6 +87,23 @@ js-sys = { version = "0.3", optional = true }
serde-wasm-bindgen = { version = "0.6", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
wasm-bindgen-futures = { version = "0.4", optional = true }
+web-sys = { version = "0.3", optional = true, features = [
+ "Event",
+ "MessageEvent",
+ "RtcConfiguration",
+ "RtcDataChannel",
+ "RtcDataChannelInit",
+ "RtcDataChannelState",
+ "RtcDataChannelType",
+ "RtcPeerConnection",
+ "RtcSdpType",
+ "RtcSessionDescriptionInit",
+] }
+gloo-timers = { version = "0.3", features = ["futures"], optional = true }
+# `ant-protocol` currently pulls native Tokio networking on wasm32. Use the
+# same FIPS-204 primitive as `saorsa-pqc` for verification-only browser builds
+# until ant-protocol's pure wire layer can be built independently of transport.
+fips204 = { version = "0.4.6", default-features = false, features = ["ml-dsa-65"], optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# self_encryption/rand use getrandom 0.2. Browser entropy is supplied by the
@@ -114,12 +135,10 @@ native = [
"dep:futures-core",
"dep:futures-util",
"dep:lru",
- "dep:postcard",
"dep:rand",
"dep:reqwest",
"dep:self-replace",
"dep:semver",
- "dep:serde_json",
"dep:sysinfo",
"dep:tar",
"dep:tempfile",
@@ -135,10 +154,15 @@ native = [
]
browser-wasm = [
"dep:console_error_panic_hook",
+ "dep:futures-channel",
+ "dep:futures-util",
+ "dep:gloo-timers",
"dep:js-sys",
+ "dep:fips204",
"dep:serde-wasm-bindgen",
"dep:wasm-bindgen",
"dep:wasm-bindgen-futures",
+ "dep:web-sys",
]
# Enable `LocalDevnet` (ant-core/src/node/devnet.rs) which wraps
# `ant_node::devnet::Devnet` and an Anvil EVM testnet.
diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs
index 899b7b53..8cf4f6e1 100644
--- a/ant-core/src/browser.rs
+++ b/ant-core/src/browser.rs
@@ -1,9 +1,30 @@
-//! Browser-safe immutable-data primitives.
+//! Cross-platform Autonomi client logic and browser bindings.
//!
-//! This module deliberately contains no transport, filesystem, Tokio runtime,
-//! or EVM provider. It is the shared compatibility-sensitive core used by the
-//! native client and by the browser WASM package: native self-encryption,
-//! public DataMap encoding, content addressing, and reconstruction.
+//! The manifest, protocol, payment, and immutable-data modules are portable
+//! Rust shared by native clients and the browser WASM package. The
+//! `browser-wasm` feature additionally provides the `web-sys` WebRTC Direct
+//! host adapter; only DOM, file-save, and wallet transaction submission remain
+//! in JavaScript.
+
+mod crypto;
+pub mod manifest;
+pub mod payment;
+pub mod protocol;
+
+pub use manifest::{
+ parse_browser_manifest, validate_browser_payment_network, BrowserManifest,
+ BrowserManifestEndpoint, BrowserPaymentNetwork, PublicFileDescriptor, BROWSER_MANIFEST_VERSION,
+};
+pub use payment::{
+ storage_payment_total, verify_storage_quote, BrowserQuoteArtifact, VerifiedStorageQuote,
+};
+pub use protocol::{
+ parse_webrtc_direct_multiaddr, WebRtcDirectEndpoint, BROWSER_PROTOCOL_NAME,
+ BROWSER_PROTOCOL_VERSION, WEBRTC_DIRECT_DATA_CHANNEL,
+};
+
+#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
+mod wasm_transport;
use bytes::Bytes;
use self_encryption::{DataMap, EncryptedChunk};
@@ -212,6 +233,12 @@ fn chunk_infos(data_map: &DataMap) -> Vec {
#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
mod wasm {
+ use super::manifest::parse_browser_manifest;
+ use super::payment::{payment_quote_hash, verify_storage_quote, BrowserQuoteArtifact};
+ use super::protocol::{
+ munge_offer_ice_credentials, parse_response_frame, parse_webrtc_direct_multiaddr,
+ server_answer_sdp, verify_hello_identity, BrowserEndpointInput, BrowserHello,
+ };
use super::{content_address, decrypt_public_file, encrypt_public_file, verify_record};
use js_sys::{Array, Function, Promise, Uint8Array};
use saorsa_dht_lookup::{
@@ -223,6 +250,18 @@ mod wasm {
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
+ #[derive(Debug, Deserialize)]
+ struct BrowserOffer {
+ sdp: String,
+ }
+
+ #[derive(Debug, Serialize)]
+ struct BrowserSessionDescription {
+ #[serde(rename = "type")]
+ description_type: &'static str,
+ sdp: String,
+ }
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum BrowserLookupEndpoint {
@@ -494,6 +533,119 @@ mod wasm {
console_error_panic_hook::set_once();
}
+ /// Validate and normalize a WebRTC Direct multiaddress in shared Rust.
+ #[wasm_bindgen(js_name = parseWebRtcDirectMultiaddr)]
+ pub fn parse_webrtc_direct_multiaddr_wasm(endpoint: JsValue) -> Result {
+ let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let parsed = parse_webrtc_direct_multiaddr(input.multiaddr())
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&parsed).map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Decode and bound-check a complete WebRTC browser response frame.
+ #[wasm_bindgen(js_name = parseResponseFrame)]
+ pub fn parse_response_frame_wasm(frame: &[u8]) -> Result {
+ let parsed =
+ parse_response_frame(frame).map_err(|error| JsValue::from_str(&error.to_string()))?;
+ parsed
+ .serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true))
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Build the ICE-lite answer pinned by a WebRTC Direct endpoint.
+ #[wasm_bindgen(js_name = serverAnswerFromEndpoint)]
+ pub fn server_answer_from_endpoint_wasm(
+ endpoint: JsValue,
+ ice_credential: &str,
+ ) -> Result {
+ let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let endpoint = parse_webrtc_direct_multiaddr(input.multiaddr())
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let sdp = server_answer_sdp(&endpoint, ice_credential)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&BrowserSessionDescription {
+ description_type: "answer",
+ sdp,
+ })
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Replace a browser offer's generated ICE credentials with Saorsa's profile.
+ #[wasm_bindgen(js_name = mungeOfferIceCredentials)]
+ pub fn munge_offer_ice_credentials_wasm(
+ offer: JsValue,
+ ice_credential: &str,
+ ) -> Result {
+ let offer: BrowserOffer = serde_wasm_bindgen::from_value(offer)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let sdp = munge_offer_ice_credentials(&offer.sdp, ice_credential)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&BrowserSessionDescription {
+ description_type: "offer",
+ sdp,
+ })
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Authenticate a node HELLO against its expected endpoint and challenge.
+ #[wasm_bindgen(js_name = verifyHelloIdentity)]
+ pub fn verify_hello_identity_wasm(
+ hello: JsValue,
+ endpoint: JsValue,
+ challenge: &[u8],
+ ) -> Result {
+ let hello: BrowserHello = serde_wasm_bindgen::from_value(hello)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let endpoint = parse_webrtc_direct_multiaddr(input.multiaddr())
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let challenge: [u8; 32] = challenge
+ .try_into()
+ .map_err(|_| JsValue::from_str("HELLO verification requires a 32-byte challenge"))?;
+ verify_hello_identity(&hello, &endpoint, &challenge)
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Validate and normalize browser bootstrap and public-file metadata.
+ #[wasm_bindgen(js_name = parseBrowserManifest)]
+ pub fn parse_browser_manifest_wasm(value: JsValue) -> Result {
+ let value: serde_json::Value = serde_wasm_bindgen::from_value(value)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let manifest =
+ parse_browser_manifest(value).map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&manifest)
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
+ /// Compute the native EVM `PaymentQuote` hash.
+ #[wasm_bindgen(js_name = paymentQuoteHash)]
+ #[must_use]
+ pub fn payment_quote_hash_wasm(
+ signed_bytes: &[u8],
+ public_key: &[u8],
+ signature: &[u8],
+ ) -> String {
+ hex::encode(payment_quote_hash(signed_bytes, public_key, signature))
+ }
+
+ /// Fully verify a storage quote before exposing it to a wallet signer.
+ #[wasm_bindgen(js_name = verifyStorageQuote)]
+ pub fn verify_storage_quote_wasm(
+ quote: JsValue,
+ expected_address: &str,
+ expected_peer_id: &str,
+ ) -> Result {
+ let quote: BrowserQuoteArtifact = serde_wasm_bindgen::from_value(quote)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ let verified = verify_storage_quote(quote, expected_address, expected_peer_id)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ serde_wasm_bindgen::to_value(&verified)
+ .map_err(|error| JsValue::from_str(&error.to_string()))
+ }
+
/// Native `self_encryption` plus public DataMap generation.
#[wasm_bindgen(js_name = encryptPublicFile)]
pub fn encrypt_public_file_wasm(content: &[u8]) -> Result {
diff --git a/ant-core/src/browser/crypto.rs b/ant-core/src/browser/crypto.rs
new file mode 100644
index 00000000..acdb442b
--- /dev/null
+++ b/ant-core/src/browser/crypto.rs
@@ -0,0 +1,71 @@
+#[cfg(feature = "native")]
+use ant_protocol::pqc::api::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant};
+#[cfg(all(not(feature = "native"), feature = "browser-wasm"))]
+use fips204::{
+ ml_dsa_65,
+ traits::{SerDes as _, Verifier as _},
+};
+use tiny_keccak::{Hasher as _, Keccak};
+
+pub(crate) fn verify_ml_dsa_65(
+ public_key: &[u8],
+ signature: &[u8],
+ message: &[u8],
+ context: &[u8],
+) -> bool {
+ verify_ml_dsa_65_inner(public_key, signature, message, context).unwrap_or(false)
+}
+
+#[cfg(feature = "native")]
+fn verify_ml_dsa_65_inner(
+ public_key: &[u8],
+ signature: &[u8],
+ message: &[u8],
+ context: &[u8],
+) -> Result {
+ let public_key = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, public_key)
+ .map_err(|error| error.to_string())?;
+ let signature = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, signature)
+ .map_err(|error| error.to_string())?;
+ ml_dsa_65()
+ .verify_with_context(&public_key, message, &signature, context)
+ .map_err(|error| error.to_string())
+}
+
+#[cfg(all(not(feature = "native"), feature = "browser-wasm"))]
+fn verify_ml_dsa_65_inner(
+ public_key: &[u8],
+ signature: &[u8],
+ message: &[u8],
+ context: &[u8],
+) -> Result {
+ let public_key: [u8; ml_dsa_65::PK_LEN] = public_key
+ .try_into()
+ .map_err(|_| "invalid ML-DSA-65 public key length".to_string())?;
+ let signature: [u8; ml_dsa_65::SIG_LEN] = signature
+ .try_into()
+ .map_err(|_| "invalid ML-DSA-65 signature length".to_string())?;
+ let public_key =
+ ml_dsa_65::PublicKey::try_from_bytes(public_key).map_err(ToString::to_string)?;
+ Ok(public_key.verify(message, &signature, context))
+}
+
+#[cfg(not(any(feature = "native", feature = "browser-wasm")))]
+fn verify_ml_dsa_65_inner(
+ _public_key: &[u8],
+ _signature: &[u8],
+ _message: &[u8],
+ _context: &[u8],
+) -> Result {
+ Err("ML-DSA verification requires the native or browser-wasm feature".to_string())
+}
+
+pub(crate) fn keccak256(parts: &[&[u8]]) -> [u8; 32] {
+ let mut hasher = Keccak::v256();
+ for part in parts {
+ hasher.update(part);
+ }
+ let mut output = [0u8; 32];
+ hasher.finalize(&mut output);
+ output
+}
diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs
new file mode 100644
index 00000000..47a3c93b
--- /dev/null
+++ b/ant-core/src/browser/manifest.rs
@@ -0,0 +1,244 @@
+//! Cross-platform validation for browser bootstrap and public-file metadata.
+
+use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint};
+use super::BrowserChunkInfo;
+use serde::{Deserialize, Serialize};
+
+/// Current browser testnet manifest version.
+pub const BROWSER_MANIFEST_VERSION: u16 = 5;
+const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024;
+const MAX_FILE_CHUNKS: usize = 1024;
+
+/// A validated WebRTC Direct bootstrap endpoint.
+pub type BrowserManifestEndpoint = BrowserEndpoint;
+
+/// Public EVM configuration required to pay for storage.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserPaymentNetwork {
+ /// HTTP(S) JSON-RPC endpoint.
+ pub rpc_url: String,
+ /// ERC-20 payment token contract.
+ pub payment_token_address: String,
+ /// Autonomi payment vault contract.
+ pub payment_vault_address: String,
+}
+
+/// Complete public-file metadata shared by native tooling and the web client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PublicFileDescriptor {
+ /// Display and save-as filename.
+ pub name: String,
+ /// Public DataMap content address.
+ pub address: String,
+ /// Plaintext file size.
+ pub size: usize,
+ /// Browser MIME type.
+ pub content_type: String,
+ /// Whole-file plaintext BLAKE3 hash.
+ pub blake3: String,
+ /// Encoded public DataMap size.
+ pub data_map_size: usize,
+ /// Self-encryption chunk descriptors.
+ pub chunks: Vec,
+ /// Minimum confirmed record replica count.
+ pub replicas: usize,
+}
+
+/// Validated bootstrap, payment, and public-file description.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserManifest {
+ /// Manifest schema version.
+ pub version: u16,
+ /// Network instance identifier.
+ pub network_id: String,
+ /// Optional manifest creation timestamp.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub created_at: Option,
+ /// Stable WebRTC Direct bootstrap addresses.
+ pub endpoints: Vec,
+ /// Storage payment configuration.
+ pub payment: BrowserPaymentNetwork,
+ /// Known public files offered by the manifest.
+ #[serde(default)]
+ pub files: Vec,
+}
+
+/// Manifest validation error.
+#[derive(Debug, thiserror::Error)]
+#[error("invalid browser manifest: {0}")]
+pub struct BrowserManifestError(pub String);
+
+/// Decode, validate, and normalize an untrusted browser manifest.
+pub fn parse_browser_manifest(
+ value: serde_json::Value,
+) -> Result {
+ let mut manifest: BrowserManifest =
+ serde_json::from_value(value).map_err(|error| BrowserManifestError(error.to_string()))?;
+ if manifest.version != BROWSER_MANIFEST_VERSION {
+ return Err(BrowserManifestError(format!(
+ "unsupported browser manifest version {}",
+ manifest.version
+ )));
+ }
+ if manifest.network_id.is_empty() {
+ return Err(BrowserManifestError(
+ "browser manifest has no network ID".to_string(),
+ ));
+ }
+ if manifest.endpoints.is_empty() {
+ return Err(BrowserManifestError(
+ "browser manifest contains no WebRtcDirect endpoints".to_string(),
+ ));
+ }
+ for endpoint in &mut manifest.endpoints {
+ let parsed = parse_webrtc_direct_multiaddr(&endpoint.multiaddr)
+ .map_err(|error| BrowserManifestError(error.to_string()))?;
+ endpoint.multiaddr = parsed.multiaddr;
+ }
+ manifest.payment = validate_browser_payment_network(manifest.payment)?;
+ for file in &mut manifest.files {
+ normalize_file(file)?;
+ }
+ Ok(manifest)
+}
+
+/// Validate and normalize payment configuration supplied independently of a
+/// manifest, such as to the browser network client WASM binding.
+pub fn validate_browser_payment_network(
+ mut payment: BrowserPaymentNetwork,
+) -> Result {
+ let mut rpc_url = url::Url::parse(&payment.rpc_url)
+ .map_err(|error| BrowserManifestError(format!("payment RPC URL is invalid: {error}")))?;
+ if !matches!(rpc_url.scheme(), "http" | "https") {
+ return Err(BrowserManifestError(
+ "payment RPC URL must use HTTP or HTTPS".to_string(),
+ ));
+ }
+ if !rpc_url.username().is_empty() || rpc_url.password().is_some() {
+ return Err(BrowserManifestError(
+ "payment RPC URL must not contain credentials".to_string(),
+ ));
+ }
+ if rpc_url.path().is_empty() {
+ rpc_url.set_path("/");
+ }
+ payment.rpc_url = rpc_url.to_string();
+ payment.payment_token_address = format!(
+ "0x{}",
+ normalize_hex(&payment.payment_token_address, 20).map_err(BrowserManifestError)?
+ );
+ payment.payment_vault_address = format!(
+ "0x{}",
+ normalize_hex(&payment.payment_vault_address, 20).map_err(BrowserManifestError)?
+ );
+ Ok(payment)
+}
+
+fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifestError> {
+ if file.name.is_empty() {
+ return Err(BrowserManifestError(
+ "browser manifest file has no name".to_string(),
+ ));
+ }
+ file.address = normalize_hex(&file.address, 32).map_err(BrowserManifestError)?;
+ if !(self_encryption::MIN_ENCRYPTABLE_BYTES..=super::MAX_BROWSER_FILE_BYTES)
+ .contains(&file.size)
+ {
+ return Err(BrowserManifestError(format!(
+ "invalid public file size {}",
+ file.size
+ )));
+ }
+ file.blake3 = normalize_hex(&file.blake3, 32).map_err(BrowserManifestError)?;
+ if !(1..=MAX_DATA_MAP_BYTES).contains(&file.data_map_size) {
+ return Err(BrowserManifestError(format!(
+ "invalid DataMap size {}",
+ file.data_map_size
+ )));
+ }
+ if !(3..=MAX_FILE_CHUNKS).contains(&file.chunks.len()) {
+ return Err(BrowserManifestError(
+ "public file has an invalid self-encryption chunk list".to_string(),
+ ));
+ }
+ file.chunks.sort_by_key(|chunk| chunk.index);
+ let mut reconstructed_size = 0usize;
+ for (expected_index, chunk) in file.chunks.iter_mut().enumerate() {
+ if chunk.index != expected_index {
+ return Err(BrowserManifestError(
+ "file chunk indices must be contiguous from zero".to_string(),
+ ));
+ }
+ chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?;
+ chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?;
+ if chunk.src_size == 0 {
+ return Err(BrowserManifestError(format!(
+ "invalid plaintext chunk size {}",
+ chunk.src_size
+ )));
+ }
+ reconstructed_size = reconstructed_size
+ .checked_add(chunk.src_size)
+ .ok_or_else(|| BrowserManifestError("file size overflow".to_string()))?;
+ }
+ if reconstructed_size != file.size {
+ return Err(BrowserManifestError(format!(
+ "file chunk sizes total {reconstructed_size}, expected {}",
+ file.size
+ )));
+ }
+ if file.content_type.is_empty() {
+ file.content_type = "application/octet-stream".to_string();
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use base64::Engine as _;
+
+ fn endpoint() -> String {
+ let mut multihash = vec![0x12, 0x20];
+ multihash.extend([0xbb; 32]);
+ format!(
+ "/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/u{}/p2p/{}",
+ URL_SAFE_NO_PAD.encode(multihash),
+ "AA".repeat(32)
+ )
+ }
+
+ #[test]
+ fn validates_and_normalizes_manifest() {
+ let value = serde_json::json!({
+ "version": 5,
+ "network_id": "local-test",
+ "created_at": "2026-08-03T00:00:00Z",
+ "payment": {
+ "rpc_url": "http://127.0.0.1:8545",
+ "payment_token_address": format!("0x{}", "11".repeat(20)),
+ "payment_vault_address": format!("0x{}", "22".repeat(20)),
+ },
+ "endpoints": [{ "multiaddr": endpoint() }],
+ "files": [{
+ "name": "hello.txt",
+ "address": "CC".repeat(32),
+ "size": 12,
+ "content_type": "text/plain",
+ "blake3": "DD".repeat(32),
+ "data_map_size": 128,
+ "chunks": [
+ { "index": 2, "dst_hash": "13".repeat(32), "src_hash": "23".repeat(32), "src_size": 4 },
+ { "index": 0, "dst_hash": "11".repeat(32), "src_hash": "21".repeat(32), "src_size": 4 },
+ { "index": 1, "dst_hash": "12".repeat(32), "src_hash": "22".repeat(32), "src_size": 4 }
+ ],
+ "replicas": 5
+ }]
+ });
+ let manifest = parse_browser_manifest(value).expect("valid manifest");
+ assert_eq!(manifest.files[0].address, "cc".repeat(32));
+ assert_eq!(manifest.files[0].chunks[0].index, 0);
+ assert_eq!(manifest.payment.rpc_url, "http://127.0.0.1:8545/");
+ }
+}
diff --git a/ant-core/src/browser/payment.rs b/ant-core/src/browser/payment.rs
new file mode 100644
index 00000000..0cede796
--- /dev/null
+++ b/ant-core/src/browser/payment.rs
@@ -0,0 +1,476 @@
+//! Verification and payment planning shared by native and browser clients.
+
+use super::crypto::{keccak256, verify_ml_dsa_65};
+use super::protocol::normalize_hex;
+use serde::{Deserialize, Serialize};
+
+const PAYMENT_MULTIPLIER: u128 = 3;
+const PRICE_BASELINE_WEI: u128 = 3_906_250_000_000_000;
+const PRICE_COEFFICIENT_WEI: u128 = 35_156_250_000_000_000;
+const PRICE_DIVISOR_SQUARED: u128 = 6_000 * 6_000;
+const MAX_COMMITMENT_KEY_COUNT: u32 = 1_000_000;
+const MAX_COMMITMENT_SIDECAR_BYTES: usize = 8 * 1024;
+const DOMAIN_COMMITMENT: &[u8] = b"autonomi.ant.replication.storage_commitment.v1";
+const DOMAIN_COMMITMENT_HASH: &[u8] = b"autonomi.ant.replication.commitment_hash.v1";
+
+/// JSON-safe form of a node's signed storage commitment.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserCommitmentArtifact {
+ /// MessagePack-encoded native commitment.
+ pub encoded: String,
+ /// Merkle root.
+ pub root: String,
+ /// Number of committed keys.
+ pub key_count: u32,
+ /// Signing peer ID.
+ pub sender_peer_id: String,
+ /// ML-DSA-65 public key.
+ pub sender_public_key: String,
+ /// ML-DSA-65 signature.
+ pub signature: String,
+}
+
+/// JSON-safe form of the native EVM payment quote returned over WebRTC.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserQuoteArtifact {
+ /// Quoting node peer ID.
+ pub peer_id: String,
+ /// Content address being quoted.
+ pub content: String,
+ /// Quote timestamp in seconds since the Unix epoch.
+ pub timestamp_secs: u64,
+ /// Decimal token price.
+ pub price: String,
+ /// Twenty-byte node rewards address.
+ pub rewards_address: String,
+ /// ML-DSA-65 public key.
+ pub public_key: String,
+ /// ML-DSA-65 signature.
+ pub signature: String,
+ /// Storage commitment key count used by the pricing curve.
+ pub committed_key_count: u32,
+ /// Optional pinned storage commitment.
+ pub commitment_pin: Option,
+ /// Keccak-256 EVM payment quote hash.
+ pub quote_hash: String,
+ /// Optional resolved native storage commitment.
+ pub commitment: Option,
+}
+
+/// A quote that is safe to hand to a transaction signer.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct VerifiedStorageQuote {
+ /// Original verified quote sent back to the selected storage nodes.
+ pub quote: BrowserQuoteArtifact,
+ /// Lowercase EVM quote hash without `0x`.
+ #[serde(rename = "quoteHash")]
+ pub quote_hash: String,
+ /// Checksummed-independent lowercase rewards address with `0x`.
+ #[serde(rename = "rewardsAddress")]
+ pub rewards_address: String,
+ /// Decimal amount paid after applying Autonomi's replication multiplier.
+ pub amount: String,
+}
+
+/// Storage quote validation error.
+#[derive(Debug, thiserror::Error)]
+#[error("invalid storage quote: {0}")]
+pub struct StorageQuoteError(pub String);
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct NativeStorageCommitment {
+ root: [u8; 32],
+ key_count: u32,
+ sender_peer_id: [u8; 32],
+ sender_public_key: Vec,
+ signature: Vec,
+}
+
+/// Compute the EVM-facing `PaymentQuote` Keccak-256 hash.
+#[must_use]
+pub fn payment_quote_hash(signed_bytes: &[u8], public_key: &[u8], signature: &[u8]) -> [u8; 32] {
+ keccak256(&[signed_bytes, public_key, signature])
+}
+
+/// Sum verified decimal quote amounts without exposing integer arithmetic to
+/// JavaScript or a wallet adapter.
+pub fn storage_payment_total(quotes: &[VerifiedStorageQuote]) -> Result {
+ quotes
+ .iter()
+ .try_fold(0u128, |total, quote| {
+ let amount = parse_decimal_u128("e.amount, "storage payment amount")?;
+ total
+ .checked_add(amount)
+ .ok_or_else(|| StorageQuoteError("storage payment total overflow".to_string()))
+ })
+ .map(|total| total.to_string())
+}
+
+/// Fully verify a quote, its commitment, peer binding, price, and EVM hash.
+pub fn verify_storage_quote(
+ mut quote: BrowserQuoteArtifact,
+ expected_address: &str,
+ expected_peer_id: &str,
+) -> Result {
+ let expected_address = normalize_hex(expected_address, 32).map_err(StorageQuoteError)?;
+ let expected_peer_id = normalize_hex(expected_peer_id, 32).map_err(StorageQuoteError)?;
+ quote.content = normalize_hex("e.content, 32).map_err(StorageQuoteError)?;
+ quote.peer_id = normalize_hex("e.peer_id, 32).map_err(StorageQuoteError)?;
+ if quote.content != expected_address {
+ return Err(StorageQuoteError(
+ "storage quote is for a different chunk".to_string(),
+ ));
+ }
+ if quote.peer_id != expected_peer_id {
+ return Err(StorageQuoteError(
+ "storage quote belongs to a different WebRtcDirect peer".to_string(),
+ ));
+ }
+ if quote.committed_key_count > MAX_COMMITMENT_KEY_COUNT {
+ return Err(StorageQuoteError(format!(
+ "invalid committed key count {}",
+ quote.committed_key_count
+ )));
+ }
+ let public_key = decode_unbounded_hex("e.public_key, "quote public key")?;
+ let signature = decode_unbounded_hex("e.signature, "quote signature")?;
+ if blake3::hash(&public_key).to_hex().as_str() != quote.peer_id {
+ return Err(StorageQuoteError(
+ "storage quote public key is not bound to its peer ID".to_string(),
+ ));
+ }
+ let price = parse_decimal_u128("e.price, "quote price")?;
+ let expected_price = calculate_price(quote.committed_key_count);
+ if price != expected_price {
+ return Err(StorageQuoteError(
+ "storage quote price is not bound to its committed key count".to_string(),
+ ));
+ }
+ let rewards = normalize_hex("e.rewards_address, 20).map_err(StorageQuoteError)?;
+ quote.rewards_address.clone_from(&rewards);
+ let commitment_pin = quote
+ .commitment_pin
+ .as_deref()
+ .map(|pin| normalize_hex(pin, 32).map_err(StorageQuoteError))
+ .transpose()?;
+ quote.commitment_pin.clone_from(&commitment_pin);
+ let signed_bytes = canonical_quote_bytes("e, price, &rewards, commitment_pin.as_deref())?;
+ if !verify_ml_dsa_65(&public_key, &signature, &signed_bytes, b"") {
+ return Err(StorageQuoteError(
+ "storage quote has an invalid ML-DSA-65 signature".to_string(),
+ ));
+ }
+ let quote_hash = hex::encode(payment_quote_hash(&signed_bytes, &public_key, &signature));
+ if normalize_hex("e.quote_hash, 32).map_err(StorageQuoteError)? != quote_hash {
+ return Err(StorageQuoteError(
+ "storage quote hash does not match its signed fields".to_string(),
+ ));
+ }
+ quote.quote_hash.clone_from("e_hash);
+
+ if quote.committed_key_count == 0 {
+ if quote.commitment_pin.is_some() || quote.commitment.is_some() {
+ return Err(StorageQuoteError(
+ "baseline storage quote has an incoherent commitment".to_string(),
+ ));
+ }
+ } else {
+ let pin = commitment_pin
+ .ok_or_else(|| StorageQuoteError("bound storage quote omitted its pin".to_string()))?;
+ verify_commitment(
+ quote.commitment.as_mut().ok_or_else(|| {
+ StorageQuoteError("bound quote omitted its storage commitment".to_string())
+ })?,
+ "e.peer_id,
+ quote.committed_key_count,
+ &pin,
+ )?;
+ }
+
+ let amount = price
+ .checked_mul(PAYMENT_MULTIPLIER)
+ .ok_or_else(|| StorageQuoteError("storage payment amount overflow".to_string()))?;
+ Ok(VerifiedStorageQuote {
+ quote,
+ quote_hash,
+ rewards_address: format!("0x{rewards}"),
+ amount: amount.to_string(),
+ })
+}
+
+fn canonical_quote_bytes(
+ quote: &BrowserQuoteArtifact,
+ price: u128,
+ rewards: &str,
+ commitment_pin: Option<&str>,
+) -> Result, StorageQuoteError> {
+ let content =
+ hex::decode("e.content).map_err(|error| StorageQuoteError(error.to_string()))?;
+ let rewards = hex::decode(rewards).map_err(|error| StorageQuoteError(error.to_string()))?;
+ let mut bytes = Vec::with_capacity(32 + 8 + 32 + 20 + 4 + 33);
+ bytes.extend_from_slice(&content);
+ bytes.extend_from_slice("e.timestamp_secs.to_le_bytes());
+ bytes.extend_from_slice(&price.to_le_bytes());
+ bytes.extend_from_slice(&[0u8; 16]);
+ bytes.extend_from_slice(&rewards);
+ bytes.extend_from_slice("e.committed_key_count.to_le_bytes());
+ if let Some(pin) = commitment_pin {
+ bytes.push(1);
+ bytes.extend(hex::decode(pin).map_err(|error| StorageQuoteError(error.to_string()))?);
+ } else {
+ bytes.push(0);
+ }
+ Ok(bytes)
+}
+
+fn verify_commitment(
+ artifact: &mut BrowserCommitmentArtifact,
+ expected_peer_id: &str,
+ expected_key_count: u32,
+ expected_pin: &str,
+) -> Result<(), StorageQuoteError> {
+ let encoded = decode_unbounded_hex(&artifact.encoded, "storage commitment sidecar")?;
+ if encoded.len() > MAX_COMMITMENT_SIDECAR_BYTES {
+ return Err(StorageQuoteError(
+ "storage commitment sidecar exceeds the protocol limit".to_string(),
+ ));
+ }
+ let commitment: NativeStorageCommitment = rmp_serde::from_slice(&encoded).map_err(|error| {
+ StorageQuoteError(format!(
+ "storage commitment sidecar is not valid MessagePack: {error}"
+ ))
+ })?;
+ let root = normalize_hex(&artifact.root, 32).map_err(StorageQuoteError)?;
+ let peer_id = normalize_hex(&artifact.sender_peer_id, 32).map_err(StorageQuoteError)?;
+ let public_key =
+ decode_unbounded_hex(&artifact.sender_public_key, "storage commitment public key")?;
+ let signature = decode_unbounded_hex(&artifact.signature, "storage commitment signature")?;
+ if commitment.root != decode_array_32(&root)?
+ || commitment.key_count != artifact.key_count
+ || commitment.sender_peer_id != decode_array_32(&peer_id)?
+ || commitment.sender_public_key != public_key
+ || commitment.signature != signature
+ {
+ return Err(StorageQuoteError(
+ "storage commitment sidecar differs from the verified commitment".to_string(),
+ ));
+ }
+ artifact.root = root;
+ artifact.sender_peer_id = peer_id.clone();
+ artifact.sender_public_key = hex::encode(&public_key);
+ artifact.signature = hex::encode(&signature);
+ artifact.encoded = hex::encode(&encoded);
+ if commitment.key_count != expected_key_count {
+ return Err(StorageQuoteError(
+ "storage commitment key count does not match quote".to_string(),
+ ));
+ }
+ if peer_id != expected_peer_id
+ || blake3::hash(&public_key).to_hex().as_str() != expected_peer_id
+ {
+ return Err(StorageQuoteError(
+ "storage commitment belongs to a different peer".to_string(),
+ ));
+ }
+ let signed_payload = commitment_signed_payload(&commitment)?;
+ if !verify_ml_dsa_65(&public_key, &signature, &signed_payload, DOMAIN_COMMITMENT) {
+ return Err(StorageQuoteError(
+ "storage commitment has an invalid ML-DSA-65 signature".to_string(),
+ ));
+ }
+ let postcard =
+ postcard::to_allocvec(&commitment).map_err(|error| StorageQuoteError(error.to_string()))?;
+ let mut hasher = blake3::Hasher::new();
+ hasher.update(DOMAIN_COMMITMENT_HASH);
+ hasher.update(&postcard);
+ if hasher.finalize().to_hex().as_str() != expected_pin {
+ return Err(StorageQuoteError(
+ "storage commitment does not resolve the quote pin".to_string(),
+ ));
+ }
+ Ok(())
+}
+
+fn commitment_signed_payload(
+ commitment: &NativeStorageCommitment,
+) -> Result, StorageQuoteError> {
+ let key_length = u32::try_from(commitment.sender_public_key.len())
+ .map_err(|_| StorageQuoteError("storage commitment public key is too large".to_string()))?;
+ let mut payload = Vec::with_capacity(32 + 4 + 32 + 4 + commitment.sender_public_key.len());
+ payload.extend_from_slice(&commitment.root);
+ payload.extend_from_slice(&commitment.key_count.to_le_bytes());
+ payload.extend_from_slice(&commitment.sender_peer_id);
+ payload.extend_from_slice(&key_length.to_le_bytes());
+ payload.extend_from_slice(&commitment.sender_public_key);
+ Ok(payload)
+}
+
+fn calculate_price(key_count: u32) -> u128 {
+ let count = u128::from(key_count);
+ PRICE_BASELINE_WEI
+ + count
+ .saturating_mul(count)
+ .saturating_mul(PRICE_COEFFICIENT_WEI)
+ / PRICE_DIVISOR_SQUARED
+}
+
+fn parse_decimal_u128(value: &str, label: &str) -> Result {
+ if value.is_empty()
+ || (value.len() > 1 && value.starts_with('0'))
+ || !value.bytes().all(|byte| byte.is_ascii_digit())
+ {
+ return Err(StorageQuoteError(format!("invalid {label}")));
+ }
+ value
+ .parse::()
+ .map_err(|_| StorageQuoteError(format!("{label} exceeds the supported protocol range")))
+}
+
+fn decode_unbounded_hex(value: &str, label: &str) -> Result, StorageQuoteError> {
+ let value = value.strip_prefix("0x").unwrap_or(value);
+ if (value.len() & 1) != 0 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(StorageQuoteError(format!("invalid {label}")));
+ }
+ hex::decode(value).map_err(|error| StorageQuoteError(format!("invalid {label}: {error}")))
+}
+
+fn decode_array_32(value: &str) -> Result<[u8; 32], StorageQuoteError> {
+ let decoded = hex::decode(value).map_err(|error| StorageQuoteError(error.to_string()))?;
+ decoded.try_into().map_err(|bytes: Vec| {
+ StorageQuoteError(format!("expected 32 bytes, received {}", bytes.len()))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ant_protocol::pqc::api::ml_dsa_65;
+
+ fn baseline_quote() -> (BrowserQuoteArtifact, String, String) {
+ let content = [0x31; 32];
+ let rewards = [0x44; 20];
+ let timestamp = 1_775_000_000;
+ let (public_key, secret_key) = ml_dsa_65().generate_keypair().expect("keypair");
+ let public_key = public_key.to_bytes();
+ let peer_id = blake3::hash(&public_key).to_hex().to_string();
+ let mut quote = BrowserQuoteArtifact {
+ peer_id: peer_id.clone(),
+ content: hex::encode(content),
+ timestamp_secs: timestamp,
+ price: PRICE_BASELINE_WEI.to_string(),
+ rewards_address: hex::encode(rewards),
+ public_key: hex::encode(&public_key),
+ signature: String::new(),
+ committed_key_count: 0,
+ commitment_pin: None,
+ quote_hash: String::new(),
+ commitment: None,
+ };
+ let payload =
+ canonical_quote_bytes("e, PRICE_BASELINE_WEI, &hex::encode(rewards), None)
+ .expect("payload");
+ let signature = ml_dsa_65()
+ .sign(&secret_key, &payload)
+ .expect("signature")
+ .to_bytes();
+ quote.signature = hex::encode(&signature);
+ quote.quote_hash = hex::encode(payment_quote_hash(&payload, &public_key, &signature));
+ (quote, hex::encode(content), peer_id)
+ }
+
+ fn bound_quote() -> (BrowserQuoteArtifact, String, String) {
+ let content = [0x31; 32];
+ let rewards = [0x42; 20];
+ let root = [0x53; 32];
+ let key_count = 23;
+ let timestamp = 1_775_000_001;
+ let (public_key, secret_key) = ml_dsa_65().generate_keypair().expect("keypair");
+ let public_key = public_key.to_bytes();
+ let peer_id = blake3::hash(&public_key).into();
+ let mut commitment = NativeStorageCommitment {
+ root,
+ key_count,
+ sender_peer_id: peer_id,
+ sender_public_key: public_key.clone(),
+ signature: Vec::new(),
+ };
+ let commitment_payload = commitment_signed_payload(&commitment).expect("payload");
+ commitment.signature = ml_dsa_65()
+ .sign_with_context(&secret_key, &commitment_payload, DOMAIN_COMMITMENT)
+ .expect("commitment signature")
+ .to_bytes();
+ let encoded = rmp_serde::to_vec(&commitment).expect("MessagePack commitment");
+ let postcard = postcard::to_allocvec(&commitment).expect("postcard commitment");
+ let mut pin_hasher = blake3::Hasher::new();
+ pin_hasher.update(DOMAIN_COMMITMENT_HASH);
+ pin_hasher.update(&postcard);
+ let pin = pin_hasher.finalize().to_hex().to_string();
+ let peer_id = hex::encode(peer_id);
+ let price = calculate_price(key_count);
+ let mut quote = BrowserQuoteArtifact {
+ peer_id: peer_id.clone(),
+ content: hex::encode(content),
+ timestamp_secs: timestamp,
+ price: price.to_string(),
+ rewards_address: hex::encode(rewards),
+ public_key: hex::encode(&public_key),
+ signature: String::new(),
+ committed_key_count: key_count,
+ commitment_pin: Some(pin.clone()),
+ quote_hash: String::new(),
+ commitment: Some(BrowserCommitmentArtifact {
+ encoded: hex::encode(encoded),
+ root: hex::encode(commitment.root),
+ key_count,
+ sender_peer_id: peer_id.clone(),
+ sender_public_key: hex::encode(&public_key),
+ signature: hex::encode(&commitment.signature),
+ }),
+ };
+ let payload = canonical_quote_bytes("e, price, &hex::encode(rewards), Some(&pin))
+ .expect("quote payload");
+ let signature = ml_dsa_65()
+ .sign(&secret_key, &payload)
+ .expect("quote signature")
+ .to_bytes();
+ quote.signature = hex::encode(&signature);
+ quote.quote_hash = hex::encode(payment_quote_hash(&payload, &public_key, &signature));
+ (quote, hex::encode(content), peer_id)
+ }
+
+ #[test]
+ fn payment_hash_matches_evmlib_vector() {
+ assert_eq!(
+ hex::encode(payment_quote_hash(&[0, 1], &[2], &[3])),
+ "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f"
+ );
+ }
+
+ #[test]
+ fn verifies_baseline_quote_and_rejects_tampering() {
+ let (quote, content, peer_id) = baseline_quote();
+ let verified =
+ verify_storage_quote(quote.clone(), &content, &peer_id).expect("valid quote");
+ assert_eq!(verified.amount, (PRICE_BASELINE_WEI * 3).to_string());
+ let mut tampered = quote;
+ tampered.price = (PRICE_BASELINE_WEI + 1).to_string();
+ assert!(verify_storage_quote(tampered, &content, &peer_id).is_err());
+ }
+
+ #[test]
+ fn verifies_bound_commitment_and_exact_native_sidecar() {
+ let (quote, content, peer_id) = bound_quote();
+ let verified =
+ verify_storage_quote(quote.clone(), &content, &peer_id).expect("valid bound quote");
+ assert_eq!(
+ storage_payment_total(&[verified]).expect("payment total"),
+ (calculate_price(23) * PAYMENT_MULTIPLIER).to_string()
+ );
+
+ let mut tampered = quote;
+ tampered.commitment.as_mut().expect("commitment").root = hex::encode([0x99; 32]);
+ let error = verify_storage_quote(tampered, &content, &peer_id)
+ .expect_err("sidecar mismatch must fail");
+ assert!(error.to_string().contains("sidecar differs"));
+ }
+}
diff --git a/ant-core/src/browser/protocol.rs b/ant-core/src/browser/protocol.rs
new file mode 100644
index 00000000..5daf8acc
--- /dev/null
+++ b/ant-core/src/browser/protocol.rs
@@ -0,0 +1,569 @@
+//! Browser-facing WebRTC Direct wire profile.
+
+use super::crypto::verify_ml_dsa_65;
+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+use base64::Engine as _;
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+use std::net::{Ipv4Addr, Ipv6Addr};
+use std::str::FromStr as _;
+
+/// Current browser request/response protocol version.
+pub const BROWSER_PROTOCOL_VERSION: u16 = 3;
+/// Protocol name authenticated by the node HELLO response.
+pub const BROWSER_PROTOCOL_NAME: &str = "autonomi.web.poc.v3";
+/// Ordered WebRTC DataChannel label used by Autonomi nodes.
+pub const WEBRTC_DIRECT_DATA_CHANNEL: &str = "autonomi.web.v3";
+/// Maximum content carried by one browser protocol frame.
+pub const MAX_BROWSER_RECORD_BYTES: usize = 4 * 1024 * 1024;
+/// Maximum JSON header carried by one browser protocol frame.
+pub const MAX_BROWSER_HEADER_BYTES: usize = 64 * 1024;
+/// Maximum complete browser response frame.
+pub const MAX_BROWSER_RESPONSE_BYTES: usize =
+ 4 + MAX_BROWSER_HEADER_BYTES + MAX_BROWSER_RECORD_BYTES;
+/// Maximum accepted WebRTC Direct multiaddress length.
+pub const MAX_WEBRTC_DIRECT_MULTIADDR_LENGTH: usize = 2048;
+/// DataChannel message size shared with the native WebRTC Direct listener.
+pub const WEBRTC_WRITE_CHUNK_BYTES: usize = 16 * 1024;
+
+const HELLO_DOMAIN: &[u8] = b"autonomi-webrtc-direct-hello-v1\0";
+const SHA2_256_MULTIHASH_CODE: u8 = 0x12;
+const SHA2_256_MULTIHASH_LENGTH: u8 = 32;
+
+/// Errors produced by browser address, framing, and identity validation.
+#[derive(Debug, thiserror::Error)]
+pub enum BrowserProtocolError {
+ /// An address or hexadecimal identifier is malformed.
+ #[error("invalid browser endpoint: {0}")]
+ Endpoint(String),
+ /// A request or response frame is malformed or exceeds a bound.
+ #[error("invalid browser frame: {0}")]
+ Frame(String),
+ /// A node identity response failed authentication.
+ #[error("invalid node HELLO: {0}")]
+ Identity(String),
+}
+
+/// Manifest-compatible wrapper around a WebRTC Direct multiaddress.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BrowserEndpoint {
+ /// `/ip4|ip6/.../udp/.../webrtc-direct/certhash/.../p2p/...` address.
+ pub multiaddr: String,
+}
+
+/// Parsed, certificate-pinned direct endpoint.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WebRtcDirectEndpoint {
+ /// Canonical input multiaddress.
+ pub multiaddr: String,
+ /// `ip4` or `ip6`.
+ #[serde(rename = "hostProtocol")]
+ pub host_protocol: String,
+ /// Literal IP address.
+ pub host: String,
+ /// UDP listener port.
+ pub port: u16,
+ /// Lowercase 32-byte ANT peer ID.
+ #[serde(rename = "peerId")]
+ pub peer_id: String,
+ /// SHA-256 DTLS certificate digest.
+ #[serde(rename = "certificateHash")]
+ pub certificate_hash: [u8; 32],
+}
+
+/// Endpoint accepted from either a raw multiaddress or manifest object.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+pub enum BrowserEndpointInput {
+ /// Raw multiaddress string.
+ Multiaddr(String),
+ /// Manifest endpoint object.
+ Structured(BrowserEndpoint),
+}
+
+impl BrowserEndpointInput {
+ /// Return the contained multiaddress.
+ #[must_use]
+ pub fn multiaddr(&self) -> &str {
+ match self {
+ Self::Multiaddr(value) => value,
+ Self::Structured(value) => &value.multiaddr,
+ }
+ }
+}
+
+/// Parse and validate a signaling-free WebRTC Direct multiaddress.
+pub fn parse_webrtc_direct_multiaddr(
+ multiaddr: &str,
+) -> Result {
+ let multiaddr = multiaddr.trim();
+ if multiaddr.is_empty()
+ || multiaddr.len() > MAX_WEBRTC_DIRECT_MULTIADDR_LENGTH
+ || !multiaddr.starts_with('/')
+ {
+ return Err(BrowserProtocolError::Endpoint(
+ "invalid WebRtcDirect multiaddress length or prefix".to_string(),
+ ));
+ }
+ let segments = multiaddr.split('/').collect::>();
+ if segments.len() != 10 {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRtcDirect multiaddress is incomplete".to_string(),
+ ));
+ }
+
+ let host_protocol = segments[1];
+ let host = segments[2];
+ match host_protocol {
+ "ip4" => {
+ Ipv4Addr::from_str(host).map_err(|error| {
+ BrowserProtocolError::Endpoint(format!("invalid IPv4 address {host}: {error}"))
+ })?;
+ }
+ "ip6" => {
+ Ipv6Addr::from_str(host).map_err(|error| {
+ BrowserProtocolError::Endpoint(format!("invalid IPv6 address {host}: {error}"))
+ })?;
+ }
+ _ => {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRTC Direct multiaddresses must use a literal IP address".to_string(),
+ ));
+ }
+ }
+ if segments[3] != "udp" {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRtcDirect multiaddress must use UDP".to_string(),
+ ));
+ }
+ let port = segments[4].parse::().map_err(|error| {
+ BrowserProtocolError::Endpoint(format!(
+ "WebRtcDirect multiaddress has an invalid UDP port: {error}"
+ ))
+ })?;
+ if port == 0 {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRtcDirect multiaddress has an invalid UDP port".to_string(),
+ ));
+ }
+ if segments[5] != "webrtc-direct" {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRTC Direct multiaddress must contain /webrtc-direct".to_string(),
+ ));
+ }
+ if segments[6] != "certhash" || segments[7].is_empty() {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRTC Direct multiaddress must contain exactly one certhash".to_string(),
+ ));
+ }
+ let certificate_hash = decode_certificate_multihash(segments[7])?;
+ if segments[8] != "p2p" {
+ return Err(BrowserProtocolError::Endpoint(
+ "WebRtcDirect multiaddress must end with /p2p/".to_string(),
+ ));
+ }
+ let peer_id = normalize_hex(segments[9], 32).map_err(BrowserProtocolError::Endpoint)?;
+
+ Ok(WebRtcDirectEndpoint {
+ multiaddr: multiaddr.to_string(),
+ host_protocol: host_protocol.to_string(),
+ host: host.to_string(),
+ port,
+ peer_id,
+ certificate_hash,
+ })
+}
+
+fn decode_certificate_multihash(value: &str) -> Result<[u8; 32], BrowserProtocolError> {
+ let encoded = value.strip_prefix('u').ok_or_else(|| {
+ BrowserProtocolError::Endpoint(
+ "certificate multihash must use base64url multibase (`u`)".to_string(),
+ )
+ })?;
+ let decoded = URL_SAFE_NO_PAD.decode(encoded).map_err(|error| {
+ BrowserProtocolError::Endpoint(format!(
+ "certificate multihash is not valid unpadded base64url: {error}"
+ ))
+ })?;
+ if decoded.len() != 34
+ || decoded[0] != SHA2_256_MULTIHASH_CODE
+ || decoded[1] != SHA2_256_MULTIHASH_LENGTH
+ {
+ return Err(BrowserProtocolError::Endpoint(
+ "certificate multihash must contain a 32-byte SHA-256 digest".to_string(),
+ ));
+ }
+ decoded[2..].try_into().map_err(|_| {
+ BrowserProtocolError::Endpoint(
+ "certificate multihash must contain a 32-byte SHA-256 digest".to_string(),
+ )
+ })
+}
+
+/// Normalize a fixed-width hexadecimal wire field.
+pub fn normalize_hex(value: &str, bytes: usize) -> Result {
+ let normalized = value
+ .trim()
+ .strip_prefix("0x")
+ .or_else(|| value.trim().strip_prefix("0X"))
+ .unwrap_or(value.trim())
+ .replace(':', "");
+ if normalized.len() != bytes.saturating_mul(2)
+ || !normalized.bytes().all(|byte| byte.is_ascii_hexdigit())
+ {
+ return Err(format!("expected {bytes} hexadecimal bytes"));
+ }
+ Ok(normalized.to_ascii_lowercase())
+}
+
+/// Decode a fixed-width hexadecimal wire field.
+pub fn decode_hex(value: &str, bytes: usize) -> Result, String> {
+ let normalized = normalize_hex(value, bytes)?;
+ hex::decode(normalized).map_err(|error| error.to_string())
+}
+
+/// A decoded response frame retaining its JSON header and binary content.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct BrowserResponseFrame {
+ /// Untrusted JSON response header after structural validation.
+ pub header: Value,
+ /// Binary response body.
+ #[serde(with = "serde_bytes")]
+ pub content: Vec,
+}
+
+/// Parse one complete length-prefixed browser response.
+pub fn parse_response_frame(frame: &[u8]) -> Result {
+ let (header, content_offset, frame_length) = parse_response_header(frame)?;
+ if frame.len() != frame_length {
+ return Err(BrowserProtocolError::Frame(format!(
+ "response length mismatch: declared {} content bytes",
+ header["content_length"]
+ )));
+ }
+ Ok(BrowserResponseFrame {
+ header,
+ content: frame[content_offset..].to_vec(),
+ })
+}
+
+/// Determine a frame's complete length once its JSON header is available.
+pub fn response_frame_length(frame: &[u8]) -> Result
+
+
Stream video directly
+
+ Opens the public file as a seekable byte-range source. Playback fetches,
+ verifies, and decrypts only the encrypted chunks needed by the video element.
+ The public DataMap address from the download field above is used.
+
+
+
+
+
+
+
+
Protocol log
diff --git a/web/src/main.js b/web/src/main.js
index c2b0c844..6e1b416b 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -37,18 +37,24 @@ const elements = {
downloadFile: document.querySelector("#download-file"),
downloadState: document.querySelector("#download-state"),
downloadLink: document.querySelector("#download-link"),
+ streamFile: document.querySelector("#stream-file"),
+ streamState: document.querySelector("#stream-state"),
+ streamVideo: document.querySelector("#stream-video"),
log: document.querySelector("#log"),
};
-const manifestOverride = new URLSearchParams(window.location.search).get(
- "manifest",
-);
+const pageParameters = new URLSearchParams(window.location.search);
+const manifestOverride = pageParameters.get("manifest");
if (manifestOverride) elements.manifestUrl.value = manifestOverride;
+const endpointOverride = pageParameters.get("endpoint");
+if (endpointOverride) elements.endpointMultiaddr.value = endpointOverride;
let client;
let networkClient;
let browserManifest;
let downloadObjectUrl;
+const videoReaders = new Map();
+let activeVideoSession;
function timestamp() {
return new Date().toLocaleTimeString();
@@ -76,6 +82,103 @@ function endpointFromForm() {
return elements.endpointMultiaddr.value.trim();
}
+function useEndpointAsBootstrap(multiaddr, hello) {
+ const endpointWasInManifest = browserManifest?.endpoints.some(
+ (endpoint) => endpoint.multiaddr === multiaddr,
+ );
+ const files = endpointWasInManifest ? browserManifest.files : [];
+ if (!endpointWasInManifest) {
+ stopVideoStream();
+ elements.fileAddress.value = "";
+ elements.publicFile.hidden = true;
+ }
+ networkClient?.close();
+ networkClient = new BrowserNetworkClient([{ multiaddr }]);
+ browserManifest = {
+ version: browserManifest?.version ?? 5,
+ network_id: endpointWasInManifest
+ ? browserManifest.network_id
+ : `manual-seed-${hello.peer_id}`,
+ created_at: endpointWasInManifest
+ ? browserManifest.created_at
+ : new Date().toISOString(),
+ endpoints: [{ multiaddr }],
+ payment: hello.payment,
+ files,
+ };
+ elements.manifestState.textContent = "Manual bootstrap · 1 direct node";
+ elements.manifestState.classList.add("connected");
+ log(`Using ${hello.peer_id} as the Rust network bootstrap seed`);
+}
+
+async function ensureVideoStreamWorker() {
+ if (!("serviceWorker" in navigator)) {
+ throw new Error("This browser does not support service workers");
+ }
+ await navigator.serviceWorker.register("/video-stream-sw.js", { scope: "/" });
+ await navigator.serviceWorker.ready;
+ if (navigator.serviceWorker.controller) return;
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(
+ () => reject(new Error("The video streaming service worker did not take control")),
+ 10_000,
+ );
+ navigator.serviceWorker.addEventListener(
+ "controllerchange",
+ () => {
+ clearTimeout(timeout);
+ resolve();
+ },
+ { once: true },
+ );
+ });
+}
+
+function stopVideoStream() {
+ elements.streamVideo.pause();
+ elements.streamVideo.removeAttribute("src");
+ elements.streamVideo.load();
+ elements.streamVideo.hidden = true;
+ if (!activeVideoSession) return;
+ videoReaders.get(activeVideoSession)?.close();
+ videoReaders.delete(activeVideoSession);
+ activeVideoSession = undefined;
+}
+
+function streamingUrl(sessionId, file) {
+ const url = new URL(`/__autonomi_stream/${sessionId}/video`, location.origin);
+ url.searchParams.set("size", String(file.size));
+ url.searchParams.set("type", file.content_type || "application/octet-stream");
+ url.searchParams.set("name", file.name);
+ return url.href;
+}
+
+navigator.serviceWorker?.addEventListener("message", async (event) => {
+ if (event.data?.type !== "autonomi-video-range") return;
+ const port = event.ports[0];
+ if (!port) return;
+ try {
+ const { sessionId, start, length } = event.data;
+ const reader = videoReaders.get(sessionId);
+ if (!reader) throw new Error("The requested video stream is no longer open");
+ if (
+ !Number.isSafeInteger(start) ||
+ !Number.isSafeInteger(length) ||
+ start < 0 ||
+ length < 0
+ ) {
+ throw new Error("The service worker requested an invalid video range");
+ }
+ const bytes = await reader.readRange(start, length);
+ const owned = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
+ ? bytes
+ : bytes.slice();
+ port.postMessage({ ok: true, bytes: owned.buffer }, [owned.buffer]);
+ } catch (error) {
+ port.postMessage({ ok: false, error: errorMessage(error) });
+ }
+});
+
async function loadManifest() {
elements.manifestState.classList.remove("connected");
elements.manifestState.textContent = "Loading…";
@@ -83,6 +186,7 @@ async function loadManifest() {
elements.manifestUrl.value.trim(),
);
browserManifest = manifest;
+ stopVideoStream();
networkClient?.close();
networkClient = new BrowserNetworkClient(manifest.endpoints);
@@ -113,9 +217,11 @@ async function loadManifest() {
async function connectedClient() {
if (client) return client;
- const next = new BrowserNodeClient(endpointFromForm());
+ const multiaddr = endpointFromForm();
+ const next = new BrowserNodeClient(multiaddr);
const hello = await next.hello();
client = next;
+ useEndpointAsBootstrap(multiaddr, hello);
elements.connectionState.textContent = `Connected · ${hello.peer_id.slice(0, 16)}…`;
elements.connectionState.classList.add("connected");
log("HELLO", hello);
@@ -276,6 +382,57 @@ elements.downloadFile.addEventListener("click", async () => {
}
});
+elements.streamFile.addEventListener("click", async () => {
+ elements.streamState.classList.remove("connected");
+ elements.streamState.textContent = "Opening stream…";
+ elements.streamFile.disabled = true;
+ try {
+ const address = elements.fileAddress.value.trim().toLowerCase();
+ hexToBytes(address, 32);
+ const published = browserManifest?.files.find(
+ (file) => file.address === address,
+ );
+ if (!published) {
+ throw new Error(
+ "That public file address is not described by the loaded testnet manifest",
+ );
+ }
+ if (!networkClient) throw new Error("Browser network client is not ready");
+ stopVideoStream();
+ await ensureVideoStreamWorker();
+ const reader = await networkClient.openPublicFile(published, (message) => {
+ elements.streamState.textContent = message;
+ log(message);
+ });
+ const sessionId = bytesToHex(crypto.getRandomValues(new Uint8Array(16)));
+ videoReaders.set(sessionId, reader);
+ activeVideoSession = sessionId;
+ elements.streamVideo.src = streamingUrl(sessionId, published);
+ elements.streamVideo.hidden = false;
+ elements.streamState.textContent = "Ready · press play to stream";
+ elements.streamState.classList.add("connected");
+ log(`Prepared random-access video stream for ${published.name}`, {
+ size: published.size,
+ content_type: published.content_type,
+ session_id: sessionId,
+ });
+ } catch (error) {
+ stopVideoStream();
+ elements.streamState.textContent = "Stream failed";
+ log(`Video stream failed: ${errorMessage(error)}`);
+ console.error(error);
+ } finally {
+ elements.streamFile.disabled = false;
+ }
+});
+
+elements.streamVideo.addEventListener("error", () => {
+ const mediaError = elements.streamVideo.error;
+ if (!mediaError || !activeVideoSession) return;
+ elements.streamState.textContent = `Playback error (${mediaError.code})`;
+ log(`Video element could not decode the selected file (media error ${mediaError.code})`);
+});
+
async function chooseSaveHandle(name) {
if (typeof window.showSaveFilePicker !== "function") return undefined;
return window.showSaveFilePicker({ suggestedName: name });
@@ -316,6 +473,7 @@ async function exposeSavedFile(file, content, saveHandle) {
window.addEventListener("beforeunload", () => {
if (downloadObjectUrl) URL.revokeObjectURL(downloadObjectUrl);
+ stopVideoStream();
client?.close();
networkClient?.close();
});
@@ -339,8 +497,13 @@ function hexToBytes(value, expectedLength) {
}
elements.randomTarget.click();
-log("Ready. Loading the local browser testnet manifest…");
-loadManifest().catch((error) => {
- elements.manifestState.textContent = "Not running";
- log(`Local manifest not available yet: ${errorMessage(error)}`);
-});
+if (endpointOverride) {
+ elements.manifestState.textContent = "Manual endpoint · manifest skipped";
+ log("Ready. Using the WebRTC Direct endpoint from the page URL.");
+} else {
+ log("Ready. Loading the local browser testnet manifest…");
+ loadManifest().catch((error) => {
+ elements.manifestState.textContent = "Not running";
+ log(`Local manifest not available yet: ${errorMessage(error)}`);
+ });
+}
diff --git a/web/src/style.css b/web/src/style.css
index 301225fb..6e322766 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -191,6 +191,19 @@ a {
font-weight: 700;
}
+video {
+ display: block;
+ width: 100%;
+ max-height: 70vh;
+ margin-top: 1rem;
+ border-radius: 10px;
+ background: #000;
+}
+
+video[hidden] {
+ display: none;
+}
+
@media (prefers-color-scheme: dark) {
:root {
color: #e8eee9;
From f2c039e065a09f94723fa6acb23f920439825181 Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Mon, 31 Aug 2026 11:44:14 +0200
Subject: [PATCH 11/31] refactor(client): share browser and native transfer
policy
---
Cargo.lock | 4 +
ant-core/src/browser/wasm_transport.rs | 297 ++++++++++++++++++-------
ant-core/src/client_engine.rs | 146 ++++++++++++
ant-core/src/data/client/batch.rs | 11 +-
ant-core/src/data/client/data.rs | 13 +-
ant-core/src/data/client/file.rs | 11 +-
ant-core/src/data/client/merkle.rs | 40 ++--
ant-core/src/lib.rs | 3 +
8 files changed, 411 insertions(+), 114 deletions(-)
create mode 100644 ant-core/src/client_engine.rs
diff --git a/Cargo.lock b/Cargo.lock
index 6624000a..2518c88a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5264,6 +5264,10 @@ dependencies = [
[[package]]
name = "saorsa-dht-lookup"
version = "0.1.0"
+dependencies = [
+ "futures-core",
+ "futures-util",
+]
[[package]]
name = "saorsa-pqc"
diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs
index 0b9861e0..ffba0c19 100644
--- a/ant-core/src/browser/wasm_transport.rs
+++ b/ant-core/src/browser/wasm_transport.rs
@@ -17,13 +17,13 @@ use futures_channel::{mpsc, oneshot};
use futures_util::{
future::{join_all, select, Either},
lock::Mutex,
- stream::{self, StreamExt as _},
+ stream::{self, FuturesUnordered, StreamExt as _},
};
use gloo_timers::future::TimeoutFuture;
use js_sys::{Array, ArrayBuffer, Promise, Uint8Array};
use saorsa_dht_lookup::{
- run_iterative_lookup, IterativeLookup, LookupConfig, LookupKey, LookupNode, LookupQuery,
- LookupQueryOutcome,
+ collect_after_first_with_grace, run_iterative_lookup, xor_distance, IterativeLookup,
+ LookupConfig, LookupKey, LookupNode, LookupQuery, LookupQueryOutcome,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -32,6 +32,7 @@ use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::ops::Deref;
use std::rc::Rc;
+use std::time::Duration;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{
@@ -43,10 +44,16 @@ const REQUEST_TIMEOUT_MS: u32 = 10_000;
const MAX_BUFFERED_AMOUNT: u32 = 2 * 1024 * 1024;
const ICE_CREDENTIAL_PREFIX: &str = "saorsa+webrtc+v1/";
const ICE_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
-const DEFAULT_MAX_POOLED_CLIENTS: usize = 10;
+const DEFAULT_MAX_POOLED_CLIENTS: usize = 32;
const DEFAULT_LOOKUP_K: usize = 20;
const DEFAULT_LOOKUP_ALPHA: usize = 3;
const DEFAULT_MAX_LOOKUP_ITERATIONS: usize = 20;
+const LOOKUP_GRACE_TIMEOUT_MS: u32 = 5_000;
+const ENDPOINT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30 * 60);
+const MAX_BROWSER_ROUTING_ENTRIES: usize = 256;
+const MAX_BROWSER_ENDPOINT_FAILURES: usize = 256;
+const DEFAULT_BROWSER_QUOTE_CONCURRENCY: usize = 4;
+const DEFAULT_BROWSER_STORE_CONCURRENCY: usize = 4;
const MAX_STORE_TARGETS: usize = 7;
const MAX_DOWNLOAD_CONCURRENCY: usize = 6;
const MAX_BROWSER_RANGE_BYTES: usize = 4 * 1024 * 1024;
@@ -473,10 +480,12 @@ impl BrowserNodeClientCore {
}
};
if response.header.get("request_id").and_then(Value::as_u64) != Some(request_id) {
- return Err(format!(
+ let error = format!(
"response ID {} does not match request {request_id}",
response.header.get("request_id").unwrap_or(&Value::Null)
- ));
+ );
+ self.close();
+ return Err(error);
}
if response.header.get("status").and_then(Value::as_str) == Some("error") {
return Err(response
@@ -634,6 +643,8 @@ impl BrowserNodeClientCore {
struct BrowserNetworkCore {
seeds: Vec,
pool: Rc,
+ routing: Rc>>,
+ failed_endpoints: Rc>>,
}
impl BrowserNetworkCore {
@@ -654,6 +665,13 @@ impl BrowserNetworkCore {
Ok(Self {
seeds,
pool: Rc::new(BrowserClientPool::new(DEFAULT_MAX_POOLED_CLIENTS)?),
+ routing: Rc::new(RefCell::new(HashMap::new())),
+ failed_endpoints: Rc::new(RefCell::new(
+ crate::client_engine::EndpointFailureCache::new(
+ ENDPOINT_FAILURE_COOLDOWN,
+ MAX_BROWSER_ENDPOINT_FAILURES,
+ ),
+ )),
})
}
@@ -695,12 +713,15 @@ impl BrowserNetworkCore {
}
}
});
- let seed_candidates = join_all(seed_futures)
- .await
- .into_iter()
- .flatten()
- .collect::>();
- if seed_candidates.is_empty() {
+ let mut initial_candidates = self.routing.borrow().values().cloned().collect::>();
+ if initial_candidates.is_empty() {
+ initial_candidates = join_all(seed_futures)
+ .await
+ .into_iter()
+ .flatten()
+ .collect::>();
+ }
+ if initial_candidates.is_empty() {
let detail = failures
.borrow()
.iter()
@@ -720,10 +741,24 @@ impl BrowserNetworkCore {
};
let mut lookup =
IterativeLookup::new(target_key, config).map_err(|error| error.to_string())?;
- let mut known_endpoints = HashMap::new();
- for candidate in seed_candidates {
+ let mut known_endpoints = self
+ .routing
+ .borrow()
+ .iter()
+ .filter_map(|(peer, candidate)| {
+ candidate
+ .wire
+ .webrtc_direct
+ .clone()
+ .map(|endpoint| (*peer, endpoint))
+ })
+ .collect::>();
+ for candidate in initial_candidates {
if let Some(endpoint) = candidate.wire.webrtc_direct.clone() {
known_endpoints.insert(candidate.peer_id, endpoint);
+ self.routing
+ .borrow_mut()
+ .insert(candidate.peer_id, candidate.clone());
let _ = lookup.add_candidate(candidate);
}
}
@@ -732,10 +767,21 @@ impl BrowserNetworkCore {
progress: progress.clone(),
failures: Rc::clone(&failures),
known_endpoints,
+ routing: Rc::clone(&self.routing),
+ failed_endpoints: Rc::clone(&self.failed_endpoints),
};
run_iterative_lookup(&mut lookup, &mut query)
.await
.map_err(|error| error.to_string())?;
+ let mut routes = self.routing.borrow_mut();
+ if routes.len() > MAX_BROWSER_ROUTING_ENTRIES {
+ let mut peers = routes.keys().copied().collect::>();
+ peers.sort_by_key(|peer| xor_distance(peer, &target_key));
+ for peer in peers.into_iter().skip(MAX_BROWSER_ROUTING_ENTRIES) {
+ routes.remove(&peer);
+ }
+ }
+ drop(routes);
let nodes = lookup
.results()
.into_iter()
@@ -765,9 +811,7 @@ impl BrowserNetworkCore {
progress.report(&format!("Requesting {address} from {}", node.peer_id));
let result = async {
let client = self.pool.client(endpoint).await?;
- if client.peer_id().is_none() {
- client.hello().await?;
- }
+ client.hello().await?;
client.get_chunk(&address).await
}
.await;
@@ -798,11 +842,26 @@ struct BrowserNetworkLookupQuery {
progress: ProgressReporter,
failures: Rc>>,
known_endpoints: HashMap,
+ routing: Rc>>,
+ failed_endpoints: Rc>>,
}
impl LookupQuery for BrowserNetworkLookupQuery {
type Error = String;
+ async fn is_candidate_eligible(
+ &mut self,
+ candidate: &BrowserLookupCandidate,
+ ) -> Result {
+ let Some(endpoint) = candidate.wire.webrtc_direct.as_ref() else {
+ return Ok(false);
+ };
+ Ok(!self
+ .failed_endpoints
+ .borrow_mut()
+ .is_suppressed(&candidate.peer_id, &endpoint.multiaddr))
+ }
+
async fn query_batch(
&mut self,
target: LookupKey,
@@ -811,60 +870,103 @@ impl LookupQuery for BrowserNetworkLookupQuery {
batch: Vec,
) -> Result>, Self::Error> {
let target = hex::encode(target);
- let futures = batch.into_iter().map(|candidate| {
- let pool = Rc::clone(&self.pool);
- let progress = self.progress.clone();
- let failures = Rc::clone(&self.failures);
- let target = target.clone();
- async move {
- let responder = candidate.peer_id;
- let peer_id = candidate.wire.peer_id.clone();
- let result = async {
- let endpoint = candidate.wire.webrtc_direct.as_ref().ok_or_else(|| {
- "lookup candidate has no WebRTC Direct endpoint".to_string()
- })?;
- let client = pool.client(endpoint).await?;
- if client.peer_id().is_none() {
+ let attempted = batch
+ .iter()
+ .filter_map(|candidate| {
+ candidate
+ .wire
+ .webrtc_direct
+ .as_ref()
+ .map(|endpoint| (candidate.peer_id, endpoint.multiaddr.clone()))
+ })
+ .collect::>();
+ let futures: FuturesUnordered<_> = batch
+ .into_iter()
+ .map(|candidate| {
+ let pool = Rc::clone(&self.pool);
+ let progress = self.progress.clone();
+ let failures = Rc::clone(&self.failures);
+ let failed_endpoints = Rc::clone(&self.failed_endpoints);
+ let target = target.clone();
+ async move {
+ let responder = candidate.peer_id;
+ let peer_id = candidate.wire.peer_id.clone();
+ let failed_endpoint = candidate
+ .wire
+ .webrtc_direct
+ .as_ref()
+ .map(|endpoint| endpoint.multiaddr.clone());
+ let result = async {
+ let endpoint = candidate.wire.webrtc_direct.as_ref().ok_or_else(|| {
+ "lookup candidate has no WebRTC Direct endpoint".to_string()
+ })?;
+ let client = pool.client(endpoint).await?;
client.hello().await?;
+ client.find_node(&target, count).await
}
- client.find_node(&target, count).await
- }
- .await;
- match result {
- Ok(nodes) => {
- progress.report(&format!(
- "Iteration {iteration}: {peer_id} returned {} nodes",
- nodes.len()
- ));
- let candidates = nodes
- .into_iter()
- .filter_map(|wire| match BrowserLookupCandidate::parse(wire) {
- Ok(candidate) => Some(candidate),
- Err(error) => {
- progress.report(&format!(
- "Ignoring invalid candidate from {peer_id}: {error}"
- ));
- None
- }
- })
- .collect();
- LookupQueryOutcome::Succeeded {
- responder,
- candidates,
+ .await;
+ match result {
+ Ok(nodes) => {
+ failed_endpoints.borrow_mut().record_success(&responder);
+ progress.report(&format!(
+ "Iteration {iteration}: {peer_id} returned {} nodes",
+ nodes.len()
+ ));
+ let candidates = nodes
+ .into_iter()
+ .filter_map(|wire| match BrowserLookupCandidate::parse(wire) {
+ Ok(candidate) => Some(candidate),
+ Err(error) => {
+ progress.report(&format!(
+ "Ignoring invalid candidate from {peer_id}: {error}"
+ ));
+ None
+ }
+ })
+ .collect();
+ LookupQueryOutcome::Succeeded {
+ responder,
+ candidates,
+ }
+ }
+ Err(error) => {
+ if let Some(endpoint) = failed_endpoint {
+ failed_endpoints
+ .borrow_mut()
+ .record_failure(responder, endpoint);
+ }
+ progress.report(&format!("Query {peer_id} failed: {error}"));
+ failures.borrow_mut().push(BrowserLookupFailure {
+ peer_id,
+ message: error,
+ });
+ LookupQueryOutcome::Failed { responder }
}
- }
- Err(error) => {
- progress.report(&format!("Query {peer_id} failed: {error}"));
- failures.borrow_mut().push(BrowserLookupFailure {
- peer_id,
- message: error,
- });
- LookupQueryOutcome::Failed { responder }
}
}
+ })
+ .collect();
+ let mut outcomes =
+ collect_after_first_with_grace(futures, || TimeoutFuture::new(LOOKUP_GRACE_TIMEOUT_MS))
+ .await;
+ let responded = outcomes
+ .iter()
+ .map(|outcome| *outcome.responder())
+ .collect::>();
+ for (peer, endpoint) in attempted {
+ if !responded.contains(&peer) {
+ self.failed_endpoints
+ .borrow_mut()
+ .record_failure(peer, endpoint);
+ let peer_id = hex::encode(peer);
+ let message = "did not respond before the lookup grace period".to_string();
+ self.progress
+ .report(&format!("Query {peer_id} failed: {message}"));
+ self.failures
+ .borrow_mut()
+ .push(BrowserLookupFailure { peer_id, message });
}
- });
- let mut outcomes = join_all(futures).await;
+ }
for outcome in &mut outcomes {
if let LookupQueryOutcome::Succeeded { candidates, .. } = outcome {
candidates.retain_mut(|candidate| {
@@ -873,7 +975,14 @@ impl LookupQuery for BrowserNetworkLookupQuery {
} else if let Some(endpoint) = self.known_endpoints.get(&candidate.peer_id) {
candidate.wire.webrtc_direct = Some(endpoint.clone());
}
- candidate.wire.webrtc_direct.is_some()
+ if candidate.wire.webrtc_direct.is_some() {
+ self.routing
+ .borrow_mut()
+ .insert(candidate.peer_id, candidate.clone());
+ true
+ } else {
+ false
+ }
});
}
}
@@ -1515,18 +1624,40 @@ impl BrowserNetworkClient {
content.len()
));
let encrypted = super::encrypt_public_file(content).map_err(|error| error.to_string())?;
+ let mut records = encrypted.records.iter().cloned().enumerate();
let mut prepared = Vec::with_capacity(encrypted.records.len());
- for (index, record) in encrypted.records.iter().cloned().enumerate() {
+ if let Some((index, record)) = records.next() {
progress.report(&format!(
"Preparing record {}/{}",
index + 1,
encrypted.records.len()
));
- prepared.push(
+ prepared.push((
+ index,
self.prepare_record(record, &payment_network, progress)
.await?,
- );
+ ));
}
+ let record_count = encrypted.records.len();
+ let payment_network_ref = &payment_network;
+ let remaining = records.map(|(index, record)| async move {
+ progress.report(&format!("Preparing record {}/{}", index + 1, record_count));
+ self.prepare_record(record, payment_network_ref, progress)
+ .await
+ .map(|prepared| (index, prepared))
+ });
+ let remaining =
+ crate::client_engine::bounded_unordered(remaining, DEFAULT_BROWSER_QUOTE_CONCURRENCY)
+ .collect::>()
+ .await;
+ for result in remaining {
+ prepared.push(result?);
+ }
+ prepared.sort_by_key(|(index, _)| *index);
+ let prepared = prepared
+ .into_iter()
+ .map(|(_, record)| record)
+ .collect::>();
let verified_quotes = prepared
.iter()
.filter_map(|record| record.verified.clone())
@@ -1554,18 +1685,24 @@ impl BrowserNetworkClient {
*transaction_hash = super::protocol::normalize_hex(transaction_hash, 32)?;
}
+ let record_count = prepared.len();
+ let payment_network_ref = &payment_network;
+ let transaction_hash = payment.transaction_hash.as_deref();
+ let stores = prepared
+ .iter()
+ .enumerate()
+ .map(|(index, record)| async move {
+ progress.report(&format!("Storing record {}/{}", index + 1, record_count));
+ self.store_prepared(record, payment_network_ref, transaction_hash, progress)
+ .await
+ });
+ let stores =
+ crate::client_engine::bounded_unordered(stores, DEFAULT_BROWSER_STORE_CONCURRENCY)
+ .collect::>()
+ .await;
let mut replicas = usize::MAX;
- for (index, record) in prepared.iter().enumerate() {
- progress.report(&format!("Storing record {}/{}", index + 1, prepared.len()));
- let stored = self
- .store_prepared(
- record,
- &payment_network,
- payment.transaction_hash.as_deref(),
- progress,
- )
- .await?;
- replicas = replicas.min(stored);
+ for stored in stores {
+ replicas = replicas.min(stored?);
}
let descriptor = PublicFileDescriptor {
name: name.to_string(),
diff --git a/ant-core/src/client_engine.rs b/ant-core/src/client_engine.rs
new file mode 100644
index 00000000..6ffbfdad
--- /dev/null
+++ b/ant-core/src/client_engine.rs
@@ -0,0 +1,146 @@
+//! Runtime-neutral scheduling and session state shared by native and browser clients.
+
+use futures_util::{stream, Stream, StreamExt as _};
+#[cfg(any(feature = "browser-wasm", test))]
+use std::collections::HashMap;
+use std::future::Future;
+#[cfg(any(feature = "browser-wasm", test))]
+use std::hash::Hash;
+#[cfg(any(feature = "browser-wasm", test))]
+use std::time::{Duration, Instant};
+
+/// Run futures with a bounded rolling concurrency window.
+///
+/// This is the common scheduling primitive behind native upload waves and the
+/// browser upload pipeline. Callers retain responsibility for classifying
+/// results and adapting the next window's limit.
+pub(crate) fn bounded_unordered(
+ futures: I,
+ concurrency: usize,
+) -> impl Stream
+where
+ I: IntoIterator,
+ F: Future,
+{
+ stream::iter(futures).buffer_unordered(concurrency.max(1))
+}
+
+#[cfg(any(feature = "browser-wasm", test))]
+#[derive(Debug, Clone)]
+struct FailureRecord {
+ endpoint: String,
+ failed_at: Instant,
+}
+
+/// Runtime-neutral negative cache for transport endpoints.
+///
+/// Entries are keyed by authenticated peer identity and also retain the exact
+/// endpoint that failed. A peer is immediately eligible again when it
+/// republishes a different endpoint, while repeated use of the same dead
+/// address is suppressed for the configured cooldown.
+#[cfg(any(feature = "browser-wasm", test))]
+#[derive(Debug)]
+pub(crate) struct EndpointFailureCache {
+ cooldown: Duration,
+ max_entries: usize,
+ entries: HashMap,
+}
+
+#[cfg(any(feature = "browser-wasm", test))]
+impl EndpointFailureCache
+where
+ K: Clone + Eq + Hash,
+{
+ pub(crate) fn new(cooldown: Duration, max_entries: usize) -> Self {
+ Self {
+ cooldown,
+ max_entries: max_entries.max(1),
+ entries: HashMap::new(),
+ }
+ }
+
+ pub(crate) fn is_suppressed(&mut self, peer: &K, endpoint: &str) -> bool {
+ let Some(record) = self.entries.get(peer) else {
+ return false;
+ };
+ if record.endpoint != endpoint || record.failed_at.elapsed() >= self.cooldown {
+ self.entries.remove(peer);
+ return false;
+ }
+ true
+ }
+
+ pub(crate) fn record_failure(&mut self, peer: K, endpoint: String) {
+ if !self.entries.contains_key(&peer) && self.entries.len() >= self.max_entries {
+ let oldest = self
+ .entries
+ .iter()
+ .max_by_key(|(_, record)| record.failed_at.elapsed())
+ .map(|(peer, _)| peer.clone());
+ if let Some(oldest) = oldest {
+ self.entries.remove(&oldest);
+ }
+ }
+ self.entries.insert(
+ peer,
+ FailureRecord {
+ endpoint,
+ failed_at: Instant::now(),
+ },
+ );
+ }
+
+ pub(crate) fn record_success(&mut self, peer: &K) {
+ self.entries.remove(peer);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn changed_endpoint_bypasses_failure_cooldown() {
+ let mut cache = EndpointFailureCache::new(Duration::from_secs(60), 8);
+ cache.record_failure(7_u8, "old".to_string());
+ assert!(cache.is_suppressed(&7, "old"));
+ assert!(!cache.is_suppressed(&7, "new"));
+ assert!(!cache.is_suppressed(&7, "old"));
+ }
+
+ #[test]
+ fn success_clears_failure() {
+ let mut cache = EndpointFailureCache::new(Duration::from_secs(60), 8);
+ cache.record_failure(7_u8, "endpoint".to_string());
+ cache.record_success(&7);
+ assert!(!cache.is_suppressed(&7, "endpoint"));
+ }
+
+ #[test]
+ fn failure_cache_evicts_oldest_entry_at_capacity() {
+ let mut cache = EndpointFailureCache::new(Duration::from_secs(60), 1);
+ cache.record_failure(7_u8, "first".to_string());
+ cache.record_failure(8_u8, "second".to_string());
+
+ assert!(!cache.is_suppressed(&7, "first"));
+ assert!(cache.is_suppressed(&8, "second"));
+ }
+
+ #[test]
+ fn bounded_scheduler_keeps_all_outputs() {
+ let outputs = futures::executor::block_on(async {
+ bounded_unordered(
+ [
+ futures_util::future::ready(1_u8),
+ futures_util::future::ready(2_u8),
+ ],
+ 0,
+ )
+ .collect::>()
+ .await
+ });
+ assert_eq!(outputs.len(), 2);
+ assert!(outputs.contains(&1));
+ assert!(outputs.contains(&2));
+ }
+}
diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs
index 9a62b4c4..a739fb4c 100644
--- a/ant-core/src/data/client/batch.rs
+++ b/ant-core/src/data/client/batch.rs
@@ -20,7 +20,7 @@ use ant_protocol::payment::{
use ant_protocol::transport::{MultiAddr, PeerId};
use ant_protocol::{compute_address, XorName, CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK};
use bytes::Bytes;
-use futures::stream::{self, FuturesUnordered, StreamExt};
+use futures::stream::{FuturesUnordered, StreamExt};
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
@@ -785,8 +785,8 @@ impl Client {
// See PERF-RESULTS.md — measured ~30% slowdown when
// cap > batch size on quoting workloads (live mainnet).
let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
- let mut quote_stream = stream::iter(chunks_with_addr)
- .map(|(content, address)| {
+ let mut quote_stream = crate::client_engine::bounded_unordered(
+ chunks_with_addr.into_iter().map(|(content, address)| {
let limiter = quote_limiter.clone();
async move {
let result = observe_op(
@@ -797,8 +797,9 @@ impl Client {
.await;
(address, result)
}
- })
- .buffer_unordered(quote_concurrency);
+ }),
+ quote_concurrency,
+ );
let mut prepared = Vec::with_capacity(chunk_count);
let mut already_stored = Vec::new();
diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs
index ba5fbf9a..5cd38d60 100644
--- a/ant-core/src/data/client/data.rs
+++ b/ant-core/src/data/client/data.rs
@@ -310,8 +310,8 @@ impl Client {
let quote_limiter = self.controller().quote.clone();
let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
let results: Vec<([u8; 32], Result
>)> =
- futures::stream::iter(chunks_with_addr)
- .map(|(content, address)| {
+ crate::client_engine::bounded_unordered(
+ chunks_with_addr.into_iter().map(|(content, address)| {
let limiter = quote_limiter.clone();
async move {
let result = observe_op(
@@ -322,10 +322,11 @@ impl Client {
.await;
(address, result)
}
- })
- .buffer_unordered(quote_concurrency)
- .collect()
- .await;
+ }),
+ quote_concurrency,
+ )
+ .collect()
+ .await;
let mut prepared_chunks = Vec::with_capacity(results.len());
let mut already_stored_addresses = Vec::new();
diff --git a/ant-core/src/data/client/file.rs b/ant-core/src/data/client/file.rs
index 42bc60dd..ac0c127b 100644
--- a/ant-core/src/data/client/file.rs
+++ b/ant-core/src/data/client/file.rs
@@ -29,7 +29,7 @@ use ant_protocol::transport::{MultiAddr, PeerId};
use ant_protocol::{compute_address, XorName as ChunkAddress, DATA_TYPE_CHUNK};
use bytes::Bytes;
use fs2::FileExt;
-use futures::stream::{self, StreamExt};
+use futures::stream::StreamExt;
use self_encryption::{
get_root_data_map_parallel, stream_decrypt_batch_size, stream_encrypt,
streaming_decrypt_with_batch_size, DataMap,
@@ -1687,8 +1687,8 @@ impl Client {
// a progress bar through the slow quote phase.
let quote_limiter = self.controller().quote.clone();
let quote_concurrency = quote_limiter.current().min(chunk_count.max(1));
- let mut quote_stream = stream::iter(chunks_with_addr)
- .map(|(content, address)| {
+ let mut quote_stream = crate::client_engine::bounded_unordered(
+ chunks_with_addr.into_iter().map(|(content, address)| {
let limiter = quote_limiter.clone();
async move {
let result = observe_op(
@@ -1699,8 +1699,9 @@ impl Client {
.await;
(address, result)
}
- })
- .buffer_unordered(quote_concurrency);
+ }),
+ quote_concurrency,
+ );
let mut prepared_chunks = Vec::with_capacity(chunk_count);
let mut already_stored = Vec::new();
diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs
index 2973c7ea..2ded76b5 100644
--- a/ant-core/src/data/client/merkle.rs
+++ b/ant-core/src/data/client/merkle.rs
@@ -26,7 +26,7 @@ use ant_protocol::{
MerkleCandidateQuoteRequest, MerkleCandidateQuoteResponse,
};
use bytes::Bytes;
-use futures::stream::{self, FuturesUnordered, StreamExt};
+use futures::stream::{FuturesUnordered, StreamExt};
use rand::Rng;
use std::collections::{HashMap, VecDeque};
use std::time::Duration;
@@ -545,23 +545,27 @@ impl Client {
let quote_limiter = self.controller().quote.clone();
let quote_concurrency = quote_limiter.current().min(total_chunks.max(1));
- let mut check_stream = stream::iter(chunks.into_iter().enumerate())
- .map(|(index, (address, data_size))| {
- let limiter = quote_limiter.clone();
- async move {
- let result = observe_op(
- &limiter,
- || async move {
- self.chunk_already_stored_for_merkle(&address, data_type, data_size)
- .await
- },
- classify_error,
- )
- .await;
- (index, address, data_size, result)
- }
- })
- .buffer_unordered(quote_concurrency);
+ let mut check_stream = crate::client_engine::bounded_unordered(
+ chunks
+ .into_iter()
+ .enumerate()
+ .map(|(index, (address, data_size))| {
+ let limiter = quote_limiter.clone();
+ async move {
+ let result = observe_op(
+ &limiter,
+ || async move {
+ self.chunk_already_stored_for_merkle(&address, data_type, data_size)
+ .await
+ },
+ classify_error,
+ )
+ .await;
+ (index, address, data_size, result)
+ }
+ }),
+ quote_concurrency,
+ );
let mut already_stored: Vec<(usize, [u8; 32])> = Vec::new();
let mut to_upload: Vec<(usize, [u8; 32], u64)> = Vec::new();
diff --git a/ant-core/src/lib.rs b/ant-core/src/lib.rs
index 9404d66b..e028a561 100644
--- a/ant-core/src/lib.rs
+++ b/ant-core/src/lib.rs
@@ -1,6 +1,9 @@
/// Cross-platform Autonomi client logic and browser WASM bindings.
pub mod browser;
+#[cfg(any(feature = "native", feature = "browser-wasm"))]
+mod client_engine;
+
#[cfg(feature = "native")]
pub mod config;
#[cfg(feature = "native")]
From e7db6b89fb1ed4daa8380c66d77214d14dc35cce Mon Sep 17 00:00:00 2001
From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com>
Date: Mon, 31 Aug 2026 12:53:46 +0200
Subject: [PATCH 12/31] feat(browser): share native upload scheduling
---
Cargo.lock | 1 +
ant-core/Cargo.toml | 2 +
ant-core/src/browser/wasm_transport.rs | 240 ++++++++++++++++++-------
ant-core/src/client_engine.rs | 215 +++++++++++++++++++++-
ant-core/src/data/client/adaptive.rs | 20 ++-
ant-core/src/data/client/batch.rs | 45 ++---
ant-core/src/data/client/chunk.rs | 102 ++++-------
ant-core/src/data/client/mod.rs | 4 +-
web/README.md | 7 +
9 files changed, 472 insertions(+), 164 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 2518c88a..d71fe1bd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -888,6 +888,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+ "web-time",
"windows-sys 0.61.2",
"xor_name",
"zip",
diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml
index 79480fcc..6afd8082 100644
--- a/ant-core/Cargo.toml
+++ b/ant-core/Cargo.toml
@@ -26,6 +26,7 @@ self_encryption = "0.36"
saorsa-dht-lookup = { version = "0.1.0", path = "../../saorsa-core-web-support/crates/saorsa-dht-lookup" }
thiserror = "2"
url = "2"
+web-time = "1.1"
# Node management
async-stream = { version = "0.3", optional = true }
@@ -156,6 +157,7 @@ browser-wasm = [
"dep:gloo-timers",
"dep:js-sys",
"dep:serde-wasm-bindgen",
+ "dep:tracing",
"dep:wasm-bindgen",
"dep:wasm-bindgen-futures",
"dep:web-sys",
diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs
index ffba0c19..bb79e308 100644
--- a/ant-core/src/browser/wasm_transport.rs
+++ b/ant-core/src/browser/wasm_transport.rs
@@ -13,6 +13,10 @@ use super::protocol::{
BrowserResponseFrame, WebRtcDirectEndpoint, MAX_BROWSER_RESPONSE_BYTES,
WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES,
};
+use crate::client_engine::adaptive::{
+ observe_op, AdaptiveConfig, AdaptiveController, ChannelStart, Outcome,
+};
+use ant_protocol::{CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE};
use futures_channel::{mpsc, oneshot};
use futures_util::{
future::{join_all, select, Either},
@@ -53,8 +57,6 @@ const ENDPOINT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30 * 60);
const MAX_BROWSER_ROUTING_ENTRIES: usize = 256;
const MAX_BROWSER_ENDPOINT_FAILURES: usize = 256;
const DEFAULT_BROWSER_QUOTE_CONCURRENCY: usize = 4;
-const DEFAULT_BROWSER_STORE_CONCURRENCY: usize = 4;
-const MAX_STORE_TARGETS: usize = 7;
const MAX_DOWNLOAD_CONCURRENCY: usize = 6;
const MAX_BROWSER_RANGE_BYTES: usize = 4 * 1024 * 1024;
const MAX_RANGE_CACHE_BYTES: usize = 32 * 1024 * 1024;
@@ -1293,6 +1295,7 @@ fn required_range_records(
#[wasm_bindgen(js_name = BrowserNetworkClient)]
pub struct BrowserNetworkClient {
inner: Rc,
+ controller: AdaptiveController,
}
#[wasm_bindgen(js_class = BrowserNetworkClient)]
@@ -1312,6 +1315,7 @@ impl BrowserNetworkClient {
BrowserNetworkCore::new(endpoints).map_err(|error| JsValue::from_str(&error))?;
Ok(Self {
inner: Rc::new(inner),
+ controller: AdaptiveController::new(ChannelStart::default(), AdaptiveConfig::default()),
})
}
@@ -1685,25 +1689,14 @@ impl BrowserNetworkClient {
*transaction_hash = super::protocol::normalize_hex(transaction_hash, 32)?;
}
- let record_count = prepared.len();
- let payment_network_ref = &payment_network;
- let transaction_hash = payment.transaction_hash.as_deref();
- let stores = prepared
- .iter()
- .enumerate()
- .map(|(index, record)| async move {
- progress.report(&format!("Storing record {}/{}", index + 1, record_count));
- self.store_prepared(record, payment_network_ref, transaction_hash, progress)
- .await
- });
- let stores =
- crate::client_engine::bounded_unordered(stores, DEFAULT_BROWSER_STORE_CONCURRENCY)
- .collect::>()
- .await;
- let mut replicas = usize::MAX;
- for stored in stores {
- replicas = replicas.min(stored?);
- }
+ let replicas = self
+ .store_prepared_records(
+ &prepared,
+ &payment_network,
+ payment.transaction_hash.as_deref(),
+ progress,
+ )
+ .await?;
let descriptor = PublicFileDescriptor {
name: name.to_string(),
address: encrypted.address,
@@ -1716,7 +1709,7 @@ impl BrowserNetworkClient {
blake3: encrypted.blake3,
data_map_size: encrypted.data_map_size,
chunks: encrypted.chunks,
- replicas: if replicas == usize::MAX { 0 } else { replicas },
+ replicas,
};
Ok(BrowserUploadResult {
file: descriptor,
@@ -1743,7 +1736,7 @@ impl BrowserNetworkClient {
endpoint,
})
})
- .take(MAX_STORE_TARGETS)
+ .take(CLOSE_GROUP_SIZE)
.collect::>();
if targets.is_empty() {
return Err(
@@ -1806,7 +1799,107 @@ impl BrowserNetworkClient {
))
}
- async fn store_prepared(
+ /// Store every paid record with the same adaptive, byte-bounded retry
+ /// rounds used by the native client.
+ async fn store_prepared_records(
+ &self,
+ prepared: &[PreparedRecord],
+ payment_network: &BrowserPaymentNetwork,
+ transaction_hash: Option<&str>,
+ progress: &ProgressReporter,
+ ) -> Result {
+ let record_count = prepared.len();
+ let max_record_bytes = prepared
+ .iter()
+ .map(|record| record.record.content.len())
+ .max()
+ .unwrap_or(0);
+ let byte_bound = crate::client_engine::store_byte_bound(max_record_bytes);
+ let mut to_retry = prepared.iter().enumerate().collect::>();
+ let mut replicas = usize::MAX;
+
+ for attempt in 0..=crate::client_engine::STORE_MAX_RETRIES {
+ if attempt > 0 {
+ let delay = crate::client_engine::store_retry_delay(attempt);
+ progress.report(&format!(
+ "Retrying {} record(s), attempt {attempt}/{}",
+ to_retry.len(),
+ crate::client_engine::STORE_MAX_RETRIES
+ ));
+ TimeoutFuture::new(u32::try_from(delay.as_millis()).unwrap_or(u32::MAX)).await;
+ }
+
+ let op_limiter = self.controller.store.clone();
+ let cap_limiter = op_limiter.clone();
+ let results = crate::client_engine::rolling_unordered(
+ to_retry,
+ |(index, record)| {
+ let limiter = op_limiter.clone();
+ async move {
+ progress.report(&format!(
+ "Storing record {}/{} (attempt {}/{})",
+ index + 1,
+ record_count,
+ attempt + 1,
+ crate::client_engine::STORE_MAX_RETRIES + 1
+ ));
+ let result = observe_op(
+ &limiter,
+ || {
+ self.store_prepared_once(
+ record,
+ payment_network,
+ transaction_hash,
+ progress,
+ )
+ },
+ |error| classify_browser_store_error(error),
+ )
+ .await;
+ ((index, record), result)
+ }
+ },
+ || cap_limiter.current().min(byte_bound),
+ )
+ .await;
+
+ let mut failed = Vec::new();
+ for ((index, record), result) in results {
+ match result {
+ Ok(stored) => replicas = replicas.min(stored),
+ Err(error) => failed.push((index, record, error)),
+ }
+ }
+ if failed.is_empty() {
+ return Ok(if replicas == usize::MAX { 0 } else { replicas });
+ }
+ if attempt == crate::client_engine::STORE_MAX_RETRIES {
+ let failed_count = failed.len();
+ let details = failed
+ .into_iter()
+ .map(|(index, _, error)| {
+ format!("record {}/{}: {error}", index + 1, record_count)
+ })
+ .collect::>()
+ .join("; ");
+ return Err(format!(
+ "{} paid record(s) failed after {} attempts: {details}",
+ failed_count,
+ crate::client_engine::STORE_MAX_RETRIES + 1
+ ));
+ }
+ to_retry = failed
+ .into_iter()
+ .map(|(index, record, _)| (index, record))
+ .collect();
+ }
+
+ Err("record store retry loop ended unexpectedly".to_string())
+ }
+
+ /// Store one record to a close-group majority, advancing through the rest
+ /// of the ordered K=7 target set only when an initial target fails.
+ async fn store_prepared_once(
&self,
prepared: &PreparedRecord,
payment_network: &BrowserPaymentNetwork,
@@ -1823,46 +1916,71 @@ impl BrowserNetworkClient {
.verified
.as_ref()
.ok_or_else(|| "paid record has no verified quote".to_string())?;
- let attempts = prepared.targets.iter().cloned().map(|target| {
- let pool = Rc::clone(&self.inner.pool);
- let record = prepared.record.clone();
- let quote = verified.quote.clone();
- let payment_network = payment_network.clone();
- let transaction_hash = transaction_hash.clone();
- let progress = progress.clone();
- async move {
- let client = pool.client(&target.endpoint).await?;
- let hello = client.hello().await?;
- assert_upload_node(&hello, &payment_network)?;
- let (_, already_stored) = client
- .put_chunk(&record.address, &record.content, quote, &transaction_hash)
- .await?;
- progress.report(&format!(
- "{} {} on {}",
- if already_stored {
- "Confirmed"
- } else {
- "Stored"
- },
- record.address,
- target.peer_id
- ));
- Ok::<(), String>(())
- }
- });
- let attempts = join_all(attempts).await;
- let stored = attempts.iter().filter(|attempt| attempt.is_ok()).count();
- if stored == 0 {
- let failures = attempts
- .into_iter()
- .filter_map(Result::err)
- .collect::>()
- .join("; ");
+ let outcome = crate::client_engine::quorum_with_fallback(
+ prepared.targets.iter().cloned(),
+ CLOSE_GROUP_MAJORITY,
+ |target| {
+ let pool = Rc::clone(&self.inner.pool);
+ let record = prepared.record.clone();
+ let quote = verified.quote.clone();
+ let payment_network = payment_network.clone();
+ let transaction_hash = transaction_hash.clone();
+ let progress = progress.clone();
+ async move {
+ let client = pool.client(&target.endpoint).await?;
+ let hello = client.hello().await?;
+ assert_upload_node(&hello, &payment_network)?;
+ let (_, already_stored) = client
+ .put_chunk(&record.address, &record.content, quote, &transaction_hash)
+ .await?;
+ progress.report(&format!(
+ "{} {} on {}",
+ if already_stored {
+ "Confirmed"
+ } else {
+ "Stored"
+ },
+ record.address,
+ target.peer_id
+ ));
+ Ok::<(), String>(())
+ }
+ },
+ )
+ .await;
+ let failures = outcome
+ .failures
+ .into_iter()
+ .map(|(target, error)| {
+ progress.report(&format!("Store target {} failed: {error}", target.peer_id));
+ format!("{}: {error}", target.peer_id)
+ })
+ .collect::>();
+ if !outcome.reached {
return Err(format!(
- "paid chunk was rejected by every closest node: {failures}"
+ "stored on {} peers, need {CLOSE_GROUP_MAJORITY}; failures: {}",
+ outcome.successes,
+ failures.join("; ")
));
}
- Ok(stored)
+ Ok(outcome.successes)
+ }
+}
+
+fn classify_browser_store_error(error: &str) -> Outcome {
+ let error = error.to_ascii_lowercase();
+ if error.contains("timed out") || error.contains("timeout") {
+ Outcome::Timeout
+ } else if error.contains("webrtc")
+ || error.contains("datachannel")
+ || error.contains("ice")
+ || error.contains("connect")
+ || error.contains("closed")
+ || error.contains("invalid state")
+ {
+ Outcome::NetworkError
+ } else {
+ Outcome::ApplicationError
}
}
diff --git a/ant-core/src/client_engine.rs b/ant-core/src/client_engine.rs
index 6ffbfdad..9df23a98 100644
--- a/ant-core/src/client_engine.rs
+++ b/ant-core/src/client_engine.rs
@@ -1,13 +1,159 @@
//! Runtime-neutral scheduling and session state shared by native and browser clients.
-use futures_util::{stream, Stream, StreamExt as _};
+use futures_util::{stream, stream::FuturesUnordered, Stream, StreamExt as _};
#[cfg(any(feature = "browser-wasm", test))]
use std::collections::HashMap;
use std::future::Future;
#[cfg(any(feature = "browser-wasm", test))]
use std::hash::Hash;
+use std::time::Duration;
#[cfg(any(feature = "browser-wasm", test))]
-use std::time::{Duration, Instant};
+use std::time::Instant;
+
+#[cfg_attr(
+ all(feature = "browser-wasm", not(feature = "native")),
+ allow(dead_code)
+)]
+#[path = "data/client/adaptive.rs"]
+pub(crate) mod adaptive;
+
+/// Maximum combined source-record bytes scheduled for concurrent storage.
+///
+/// A record is sent to several close-group peers, so its actual wire footprint
+/// is larger than its source body. Keeping the shared budget expressed in
+/// source bytes lets native QUIC and browser WebRTC use the same conservative
+/// scheduling policy without coupling it to either transport.
+pub(crate) const STORE_INFLIGHT_BYTE_BUDGET: usize = 64 * 1024 * 1024;
+
+/// Number of whole-record store retries after the first attempt.
+pub(crate) const STORE_MAX_RETRIES: u32 = 3;
+
+/// Initial delay for exponential whole-record store retries.
+pub(crate) const STORE_RETRY_BASE_DELAY_MS: u64 = 500;
+
+/// Outcome of a quorum operation over an ordered target set.
+#[derive(Debug)]
+pub(crate) struct QuorumOutcome {
+ pub(crate) successes: usize,
+ pub(crate) failures: Vec<(T, E)>,
+ pub(crate) reached: bool,
+}
+
+/// Run an operation against the first `required` targets concurrently, using
+/// later targets one-for-one as fallbacks when an attempt fails.
+///
+/// The function returns as soon as quorum is reached and drops any remaining
+/// in-flight work. This is the transport-neutral close-group delivery policy
+/// shared by native QUIC and browser WebRTC uploads.
+pub(crate) async fn quorum_with_fallback(
+ targets: impl IntoIterator,
+ required: usize,
+ operation: F,
+) -> QuorumOutcome
+where
+ T: Clone,
+ F: Fn(T) -> Fut,
+ Fut: Future
The wallet key is used only by this page to sign the EVM transactions. It
- is cleared from the form immediately and is never sent to a node or stored
- in the manifest. Use the disposable funded key printed by ant-devnet.
+ is never sent to a node or stored in the manifest. This demo keeps it in
+ the form for repeat uploads, so use the disposable funded key printed by
+ ant-devnet.