Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ant-core/browser-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ rejects reconnection. Temporary node data is removed on shutdown.
The suite uses local funds only and verifies that the RPC belongs to the local
Anvil devnet before invoking the wallet.

The pointer recovery test pays once on the local chain, then loses the page
after the payment confirms and before the write stores the state. It rebuilds
the paid state from the `onPrepared` checkpoint and the transaction
`onSubmission` kept, stores it without paying again, and checks that a write
refused before the wallet says "payment was not submitted". It also builds a
manifest in the page and writes the `.ant` file and link to its test output
directory, for decoding with `ant manifest show`.

## Large-file disk streaming

`large-file.mjs` is an opt-in check, not run in CI, that a file larger than
Expand Down
83 changes: 68 additions & 15 deletions ant-core/browser-tests/harness.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import init, { BrowserNetworkClient, encryptPublicFile, pointerAddress } from "../wasm-tests/pkg/ant_core.js";
import init, {
BrowserNetworkClient, createFileLink, createManifest, decodeManifest, encryptPublicFile, parseManifestLink,
pointerAddress, recoverPointerPayment,
} from "../wasm-tests/pkg/ant_core.js";
import { Contract, JsonRpcProvider } from "ethers";
await init();
globalThis.runIntegration = async ({ endpoint, payment, rpcUrl }) => {
Expand Down Expand Up @@ -50,17 +53,17 @@ globalThis.runIntegration = async ({ endpoint, payment, rpcUrl }) => {
matches: downloaded.content.length === content.length && downloaded.content.every((value, index) => value === content[index]),
hello: hello.type, records: result.records, replicas: result.file.replicas };
};
// Pointers (ADR-0016) through real nodes: each state paid on the local chain,
// stored on the close group, and read back by a client that wrote nothing.
globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => {
// A wallet paying on the local chain, reporting each broadcast and counting
// the payments it is asked for.
const chainWallet = async (payment, rpcUrl) => {
const provider = new JsonRpcProvider(rpcUrl);
const signer = await provider.getSigner(0);
const token = new Contract(payment.payment_token_address, ["function approve(address,uint256) returns (bool)"], signer);
const vault = new Contract(payment.payment_vault_address,
["function payForQuotes((address rewardsAddress,uint256 amount,bytes32 quoteHash)[] payments)"], signer);
let payments = 0;
const pay = async (_, quotes, submitted) => {
payments++;
const wallet = { payments: 0, destroy: () => provider.destroy() };
wallet.pay = async (_, quotes, submitted) => {
wallet.payments++;
const totalAmount = quotes.reduce((sum, quote) => sum + BigInt(quote.amount), 0n);
await (await token.approve(payment.payment_vault_address, totalAmount)).wait();
const transaction = await vault.payForQuotes(quotes.map(quote => ({
Expand All @@ -71,14 +74,21 @@ globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => {
await transaction.wait();
return receipt;
};
return wallet;
};
const chunk = n => n.toString(16).padStart(2, "0").repeat(32);
// A node admits 16 requests a second from one browser session (ADR-0015)
// and closes the session past that. A paid pointer write asks each node about
// eight things, and this wallet pays instantly on a local chain, so writes are
// spaced as a real wallet's confirmation would space them.
const paced = async write => { await new Promise(resolve => setTimeout(resolve, 1_500)); return write(); };
// Pointers (ADR-0016) through real nodes: each state paid on the local chain,
// stored on the close group, and read back by a client that wrote nothing.
globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => {
const wallet = await chainWallet(payment, rpcUrl);
const pay = wallet.pay;
const seed = crypto.getRandomValues(new Uint8Array(32));
const inner = crypto.getRandomValues(new Uint8Array(32));
const chunk = n => n.toString(16).padStart(2, "0").repeat(32);
// A node admits 16 requests a second from one browser session (ADR-0015)
// and closes the session past that. A paid pointer write asks each node about
// eight things, and this wallet pays instantly on a local chain, so writes are
// spaced as a real wallet's confirmation would space them.
const paced = async write => { await new Promise(resolve => setTimeout(resolve, 1_500)); return write(); };
const writer = new BrowserNetworkClient([endpoint]);
const created = await writer.createPointer(seed, chunk(1), "chunk", payment, pay);
const updated = await paced(() => writer.updatePointer(seed, chunk(2), "chunk", payment, pay));
Expand All @@ -98,10 +108,53 @@ globalThis.runPointerIntegration = async ({ endpoint, payment, rpcUrl }) => {
const checker = new BrowserNetworkClient([endpoint]);
const finality = await checker.pointerFinality(created.pointer.address);
checker.close(); checker.free();
provider.destroy();
return { payments, address: created.pointer.address, expectedAddress: pointerAddress(seed),
wallet.destroy();
return { payments: wallet.payments, address: created.pointer.address, expectedAddress: pointerAddress(seed),
createdCounter: created.pointer.counter, updatedCounter: updated.pointer.counter,
readCounter: read?.counter, readKind: read?.kind, readTarget: read?.target, endAddress: end.pointer.address,
resolved, absent, transferredCounter: transferred.pointer.counter,
finalityStatus: finality.status, finalityTransferredTo: finality.majority?.transferredTo };
};
// A page lost once its wallet's payment confirmed, before the write stored the
// state: rebuilt from the checkpoint and the broadcast the page kept, stored
// without paying again. Then a manifest built in the page.
globalThis.runPointerRecoveryIntegration = async ({ endpoint, payment, rpcUrl }) => {
const wallet = await chainWallet(payment, rpcUrl);
const seed = crypto.getRandomValues(new Uint8Array(32));
const address = pointerAddress(seed);
let checkpoint, submission, lostError;
const lost = async (...args) => {
await wallet.pay(...args);
throw new Error("page lost after the payment confirmed");
};
const writer = new BrowserNetworkClient([endpoint]);
try {
await writer.createPointer(seed, chunk(5), "chunk", payment, lost, {
onPrepared: value => { checkpoint = value; },
onSubmission: value => { submission = value; },
});
} catch (error) { lostError = String(error); }
writer.close(); writer.free();
const recovered = recoverPointerPayment(checkpoint, submission);
const storer = new BrowserNetworkClient([endpoint]);
const beforeRecovery = await storer.getPointer(address);
const stored = await paced(() => storer.storePaidPointer(recovered.record, recovered.proof, payment));
let existsError;
try { await paced(() => storer.createPointer(seed, chunk(6), "chunk", payment, wallet.pay)); }
catch (error) { existsError = String(error); }
storer.close(); storer.free();
const reader = new BrowserNetworkClient([endpoint]);
const read = await reader.getPointer(address);
reader.close(); reader.free();
wallet.destroy();
const file = encryptPublicFile(new TextEncoder().encode("a manifest entry"));
const created = createManifest({
name: "recovered", torrent: { infoHashV1: "ab".repeat(20) },
entries: [{ kind: "public", path: "docs/readme.txt", size: 16, address: createFileLink(file.address) }],
});
const decoded = decodeManifest(created.bytes);
return { payments: wallet.payments, lostError, submission, beforeRecovery, stored, read, existsError,
expectedAddress: address, fileAddress: file.address, manifestEntry: decoded.entries[0],
manifestBytes: Array.from(created.bytes), link: created.link,
linkMatches: JSON.stringify(parseManifestLink(created.link).manifest) === JSON.stringify(decoded) };
};
25 changes: 25 additions & 0 deletions ant-core/browser-tests/webrtc.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { writeFile } from "node:fs/promises";
test("real Chromium authenticates, resumes one paid upload, and downloads by address", async ({ page, request }) => {
const manifest = await (await request.get("http://127.0.0.1:35000/api/browser-manifest.json")).json();
const info = await (await request.get("http://127.0.0.1:35000/api/info")).json();
Expand Down Expand Up @@ -35,3 +36,27 @@ test("real Chromium creates, updates, reads, resolves and hands over pointers on
expect(result.finalityTransferredTo).toBe(result.endAddress);
expect(errors).toEqual([]);
});
test("real Chromium recovers a pointer payment its page lost, paying once, and builds a manifest", async ({ page, request }, testInfo) => {
const manifest = await (await request.get("http://127.0.0.1:35000/api/browser-manifest.json")).json();
const info = await (await request.get("http://127.0.0.1:35000/api/info")).json();
const errors = []; page.on("pageerror", error => errors.push(error.message));
await page.goto("/");
await page.waitForFunction(() => typeof globalThis.runPointerRecoveryIntegration === "function");
const result = await page.evaluate(options => globalThis.runPointerRecoveryIntegration(options), {
endpoint: manifest.endpoints[0], payment: manifest.payment, rpcUrl: info.evm.rpc_url,
});
expect(result.payments).toBe(1);
expect(result.lostError).toContain("page lost after the payment confirmed");
expect(result.lostError).not.toContain("payment was not submitted");
expect(result.submission.transactionHash).toMatch(/^0x[0-9a-f]{64}$/);
expect(result.beforeRecovery).toBeNull();
expect(result.stored.address).toBe(result.expectedAddress); expect(result.stored.counter).toBe("0");
expect(result.read.counter).toBe("0"); expect(result.read.target).toBe("05".repeat(32));
expect(result.existsError).toMatch(/already exists[\s\S]*payment was not submitted$/);
expect(result.manifestEntry).toMatchObject({ name: "docs/readme.txt", kind: "public", address: result.fileAddress, size: 16 });
expect(result.linkMatches).toBe(true);
// Kept for decoding with the native CLI: `ant manifest show <file>`.
await writeFile(testInfo.outputPath("browser-created.ant"), Buffer.from(result.manifestBytes));
await writeFile(testInfo.outputPath("browser-created-link.txt"), result.link);
expect(errors).toEqual([]);
});
81 changes: 72 additions & 9 deletions ant-core/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! host adapter. Higher-level browser integration is owned by the companion
//! `ant-client-browser-sdk` project.

pub mod file_manifest;
pub mod manifest;
pub mod payment;
#[cfg(any(all(target_arch = "wasm32", feature = "browser-wasm"), test))]
Expand Down Expand Up @@ -271,12 +272,14 @@ fn browser_chunk_info(info: &ChunkInfo) -> BrowserChunkInfo {

#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
mod wasm {
use super::file_manifest::{struct_fields, ManifestInput, TorrentInput, TORRENT_FIELD};
use super::manifest::parse_browser_manifest;
use super::payment::{payment_quote_hash, verify_storage_quote, BrowserQuoteArtifact};
use super::protocol::{
ice_password_from_sdp, parse_response_frame, parse_webrtc_direct_multiaddr,
server_answer_sdp, v2_server_ice_credential, BrowserEndpointInput,
};
use super::wasm_transport::reject_unknown_keys;
use super::{
chunk_infos, content_address, decrypt_public_file, encrypt_public_file, verify_record,
BrowserLinkView, BrowserManifestView, BrowserRecord, BrowserRecordInfo, BrowserStagedFile,
Expand Down Expand Up @@ -666,6 +669,37 @@ mod wasm {
serde_wasm_bindgen::to_value(&view).map_err(|error| JsValue::from_str(&error.to_string()))
}

/// Build canonical `.ant` bytes and a compact share link without network I/O.
#[wasm_bindgen(js_name = createManifest)]
pub fn create_manifest_wasm(input: JsValue) -> Result<JsValue, JsValue> {
reject_unknown_keys(&input, struct_fields::<ManifestInput>())
.map_err(|e| JsValue::from_str(&e))?;
if input.is_object() {
let torrent = js_sys::Reflect::get(&input, &JsValue::from_str(TORRENT_FIELD))?;
reject_unknown_keys(&torrent, struct_fields::<TorrentInput>())
.map_err(|e| JsValue::from_str(&e))?;
}
let input: ManifestInput = serde_wasm_bindgen::from_value(input)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
let result = input
.create()
.map_err(|error| JsValue::from_str(&error.to_string()))?;
serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string()))
}

/// Format a public file link using the shared link grammar.
#[wasm_bindgen(js_name = createFileLink)]
pub fn create_file_link_wasm(address: &str) -> Result<String, JsValue> {
match crate::data::manifest::parse_link(address)
.map_err(|error| JsValue::from_str(&error.to_string()))?
{
crate::data::manifest::Link::File(address) => {
Ok(crate::data::manifest::file_link(&address))
}
_ => Err(JsValue::from_str("expected a public file address")),
}
}

/// Validate and normalize browser bootstrap and public-file metadata.
#[wasm_bindgen(js_name = parseBrowserManifest)]
pub fn parse_browser_manifest_wasm(value: JsValue) -> Result<JsValue, JsValue> {
Expand Down Expand Up @@ -1080,7 +1114,7 @@ pub enum BrowserLinkView {
/// A manifest shaped for JavaScript: every entry carries its effective
/// name, kind and address, and embedded entries carry the DataMap bytes a
/// private download takes.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserManifestView {
/// Suggested root directory name.
Expand All @@ -1093,7 +1127,7 @@ pub struct BrowserManifestView {
}

/// A manifest's torrent reference shaped for JavaScript.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserTorrentView {
/// Hex SHA-1 info hash of a v1 torrent.
Expand All @@ -1105,7 +1139,7 @@ pub struct BrowserTorrentView {
}

/// One manifest entry shaped for JavaScript.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserManifestEntryView {
/// Effective name: the path, or the hex content address.
Expand All @@ -1120,35 +1154,64 @@ pub struct BrowserManifestEntryView {
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
/// `embedded` or `public`.
pub kind: &'static str,
pub kind: String,
/// Hex content address.
pub address: String,
/// Canonical DataMap bytes for an embedded entry.
#[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")]
#[serde(default, skip_serializing_if = "Option::is_none", with = "serde_bytes")]
pub data_map: Option<Vec<u8>>,
}

impl BrowserManifestView {
pub fn from_manifest(
manifest: &crate::data::manifest::Manifest,
) -> Result<Self, crate::data::manifest::ManifestError> {
Self::addressed_from(manifest, manifest)
}

/// [`Self::from_manifest`] for a caller holding the manifest's
/// [`link_form`](crate::data::manifest::Manifest::link_form) too. Its
/// DataMaps are already shrunk, so addresses come from it without
/// self-encrypting any embedded DataMap again.
pub(crate) fn from_manifest_and_link(
manifest: &crate::data::manifest::Manifest,
link: &crate::data::manifest::Manifest,
) -> Result<Self, crate::data::manifest::ManifestError> {
if link.entries.len() != manifest.entries.len() {
return Err(crate::data::manifest::ManifestError::Encode(
"link form does not match its manifest".into(),
));
}
Self::addressed_from(manifest, link)
}

/// The view of `manifest`, taking each entry's address from the entry
/// at the same position in `addresses`, a manifest with the same
/// content addresses in the same order.
fn addressed_from(
manifest: &crate::data::manifest::Manifest,
addresses: &crate::data::manifest::Manifest,
) -> Result<Self, crate::data::manifest::ManifestError> {
let mut entries = Vec::with_capacity(manifest.entries.len());
for entry in &manifest.entries {
for (entry, addressed) in manifest.entries.iter().zip(&addresses.entries) {
let data_map = match &entry.source {
crate::data::manifest::ContentRef::Embedded { data_map } => Some(
rmp_serde::to_vec(data_map)
.map_err(|e| crate::data::manifest::ManifestError::Encode(e.to_string()))?,
),
crate::data::manifest::ContentRef::Public { .. } => None,
};
// An embedded entry's address comes from self-encrypting its
// DataMap; derive it once for both the name and the address.
let address = addressed.source.content_address()?;
entries.push(BrowserManifestEntryView {
name: entry.effective_name()?,
name: entry.effective_name_with(&address),
path: entry.path.clone(),
size: entry.size,
known_size: entry.known_size(),
sha256: entry.sha256.map(hex::encode),
kind: entry.source.kind(),
address: hex::encode(entry.source.content_address()?),
kind: entry.source.kind().to_owned(),
address: hex::encode(address),
data_map,
});
}
Expand Down
Loading
Loading