diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44bce86..23ef448 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,14 +66,10 @@ jobs:
# Do not contend for the host package manager during a repository job.
run: npx playwright install chromium
- # A plain static server rather than the dev toolchain, so the audit tests
- # exactly the bytes that will be deployed and does not depend on a dev
- # server starting inside a fixed window.
- - name: Serve the built site
- run: npm run serve:dist &
-
+ # One process owns a loopback ephemeral-port preview and its audit child.
+ # Readiness checks the per-run identity and exact build, with bounded waits.
- name: Accessibility and horizontal overflow, every page, both themes
- run: npx wait-on http://localhost:4328/docs-wallet/ && npm run check:a11y
+ run: npm run check:a11y
- name: Secret scan (full history)
uses: trufflesecurity/trufflehog@v3.90.5
diff --git a/llms.txt b/llms.txt
index ef6f37d..048d156 100644
--- a/llms.txt
+++ b/llms.txt
@@ -67,7 +67,7 @@
- [What this release authorizes](https://bitcoinuniverseio.github.io/docs-wallet/assets/support-state): The generated answer to which protocol operations Universe Wallet 1.7.5.8 authorizes, and why the number is what it is.
- [What a digital artifact is](https://bitcoinuniverseio.github.io/docs-wallet/assets/what-a-digital-artifact-is): How an inscription, a token balance, and a rare satoshi actually exist on Bitcoin, and why that makes them fragile in an ordinary wallet.
- [Why an action is unavailable](https://bitcoinuniverseio.github.io/docs-wallet/assets/why-unavailable): What the unavailable screen means, what it is not, and the list of things that will not change it.
-- [Zcash market listings](https://bitcoinuniverseio.github.io/docs-wallet/assets/zcash-market-listings): What a Zerdinal listing signature commits you to, why the approval can be blocked outright, and why cancelling a listing is only advisory.
+- [Zcash market listings](https://bitcoinuniverseio.github.io/docs-wallet/assets/zcash-market-listings): Review Zerdinal listings and full token lots with exact total prices, clear quantities, and a separate purchase approval.
## Safety
diff --git a/package.json b/package.json
index b67622a..3975351 100644
--- a/package.json
+++ b/package.json
@@ -17,10 +17,11 @@
"check:manifest": "node scripts/check-manifest.mjs",
"check:diagrams": "node scripts/check-diagrams.mjs",
"check:markdown": "markdownlint-cli2 \"src/content/**/*.{md,mdx}\" \"*.md\"",
- "check:a11y": "node scripts/check-accessibility.mjs",
+ "check:a11y": "node scripts/run-accessibility.mjs",
+ "check:preview": "node --test scripts/preview-lifecycle.test.mjs",
"capability:pull": "node scripts/sync-capability.mjs --pull",
"capability:write": "node scripts/sync-capability.mjs",
- "test": "npm run check:copy && npm run check:public-safety && npm run check:capability && npm run check:manifest && npm run check:schema && npm run check:diagrams && npm run check:llms && npm run check:data && npm run check:markdown",
+ "test": "npm run check:preview && npm run check:copy && npm run check:public-safety && npm run check:capability && npm run check:manifest && npm run check:schema && npm run check:diagrams && npm run check:llms && npm run check:data && npm run check:markdown",
"check:llms": "node scripts/generate-llms.mjs --check",
"check:schema": "node scripts/validate-manifest-schema.mjs",
"llms": "node scripts/generate-llms.mjs",
diff --git a/scripts/check-accessibility.mjs b/scripts/check-accessibility.mjs
index a4d84b1..4743825 100644
--- a/scripts/check-accessibility.mjs
+++ b/scripts/check-accessibility.mjs
@@ -5,7 +5,7 @@
// the source intends. Fails on any axe violation at serious or critical impact,
// and on any page that scrolls horizontally at 320px.
//
-// Run: npx astro preview --port 4323 & node scripts/check-accessibility.mjs
+// Run: npm run check:a11y (owns and verifies its built preview).
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
diff --git a/scripts/preview-lifecycle.test.mjs b/scripts/preview-lifecycle.test.mjs
new file mode 100644
index 0000000..8f8b537
--- /dev/null
+++ b/scripts/preview-lifecycle.test.mjs
@@ -0,0 +1,119 @@
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+import { BASE, buildDigest, IDENTITY_PATH, startDistServer } from './serve-dist.mjs';
+import { runAudit, verifyPreview, withBuiltPreview } from './run-accessibility.mjs';
+
+async function fixture(t, text = 'built docs') {
+ const directory = await mkdtemp(join(tmpdir(), 'docs-wallet-preview-'));
+ const root = join(directory, 'dist');
+ await mkdir(root);
+ await writeFile(join(root, 'index.html'), text);
+ await writeFile(join(root, 'sitemap-0.xml'), '');
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ return { directory, root };
+}
+
+test('simultaneous previews own separate loopback ports and exact built identities', async (t) => {
+ const a = await fixture(t, 'build A');
+ const b = await fixture(t, 'build B');
+ const [first, second] = await Promise.all([
+ startDistServer({ root: a.root }), startDistServer({ root: b.root }),
+ ]);
+ t.after(() => Promise.all([first.close(), second.close()]));
+ assert.notEqual(first.origin, second.origin);
+ assert.match(first.origin, /^http:\/\/127\.0\.0\.1:/);
+ assert.notEqual(first.identity.owner, second.identity.owner);
+ assert.notEqual(first.identity.buildDigest, second.identity.buildDigest);
+ assert.equal(first.identity.buildDigest, await buildDigest(a.root));
+ await Promise.all([verifyPreview(first.origin, first.identity), verifyPreview(second.origin, second.identity)]);
+ assert.equal(await (await fetch(first.origin + BASE + '/')).text(), 'build A');
+ const response = await fetch(second.origin + BASE + '/');
+ assert.equal(response.headers.get('access-control-allow-origin'), '*');
+ assert.equal(await response.text(), 'build B');
+ await assert.rejects(verifyPreview(first.origin, { ...first.identity, owner: second.identity.owner }), /identity mismatch/);
+ await assert.rejects(verifyPreview(first.origin, { ...first.identity, buildDigest: second.identity.buildDigest }), /identity mismatch/);
+ await first.close();
+ await assert.rejects(fetch(first.origin + BASE + '/'));
+ assert.equal(await (await fetch(second.origin + BASE + '/')).text(), 'build B');
+});
+
+test('occupied listeners are never borrowed or killed, and readiness is bounded', async (t) => {
+ const { root } = await fixture(t);
+ const existing = await startDistServer({ root });
+ t.after(() => existing.close());
+ await assert.rejects(startDistServer({ root, port: Number(new URL(existing.origin).port) }), { code: 'EADDRINUSE' });
+ await verifyPreview(existing.origin, existing.identity);
+ const stalled = createServer(() => {});
+ await new Promise((ready) => stalled.listen(0, '127.0.0.1', ready));
+ t.after(async () => {
+ stalled.closeAllConnections();
+ await new Promise((closed) => stalled.close(closed));
+ });
+ await assert.rejects(
+ verifyPreview('http://127.0.0.1:' + stalled.address().port, existing.identity, 25),
+ { name: 'TimeoutError' },
+ );
+});
+
+test('failed callbacks and changed build bytes close only the owned preview', async (t) => {
+ const { root } = await fixture(t);
+ const survivor = await startDistServer({ root });
+ t.after(() => survivor.close());
+ let failedOrigin;
+ await assert.rejects(withBuiltPreview(async ({ origin }) => {
+ failedOrigin = origin;
+ throw new Error('gate failed');
+ }, { root }), /gate failed/);
+ await assert.rejects(fetch(failedOrigin + IDENTITY_PATH));
+ await verifyPreview(survivor.origin, survivor.identity);
+ await assert.rejects(withBuiltPreview(async () => {
+ await writeFile(join(root, 'index.html'), 'different build');
+ }, { root }), /changed during the audit/);
+ let ran = false;
+ await assert.rejects(withBuiltPreview(() => { ran = true; }, { root: join(root, 'missing') }));
+ assert.equal(ran, false);
+});
+
+test('audit children receive their owned origin and nonzero gates propagate', async (t) => {
+ const { root, directory } = await fixture(t, 'expected build');
+ const script = join(directory, 'failing-audit.mjs');
+ const observed = join(directory, 'observed.txt');
+ await writeFile(script,
+ "import { writeFile } from 'node:fs/promises';\n" +
+ "const response = await fetch(process.env.PREVIEW_ORIGIN + '/docs-wallet/');\n" +
+ "await writeFile(" + JSON.stringify(observed) + ", await response.text());\n" +
+ "process.exitCode = 42;\n");
+ let origin;
+ await assert.rejects(withBuiltPreview(async (preview) => {
+ origin = preview.origin;
+ await runAudit({
+ origin, script, env: { ...process.env, PREVIEW_ORIGIN: 'http://127.0.0.1:1' },
+ stdio: 'ignore',
+ });
+ }, { root }), /exited with code 42/);
+ assert.equal(await readFile(observed, 'utf8'), 'expected build');
+ await assert.rejects(fetch(origin + IDENTITY_PATH));
+});
+
+test('timed out and cancelled children terminate without closing other previews', async (t) => {
+ const { root, directory } = await fixture(t);
+ const script = join(directory, 'stalled-audit.mjs');
+ await writeFile(script, "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);\n");
+ const survivor = await startDistServer({ root });
+ t.after(() => survivor.close());
+ await assert.rejects(withBuiltPreview(({ origin }) =>
+ runAudit({ origin, script, timeoutMs: 250, stdio: 'ignore' }), { root }),
+ /audit timed out/);
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 250);
+ try {
+ await assert.rejects(withBuiltPreview(({ origin }) =>
+ runAudit({ origin, script, signal: controller.signal, stdio: 'ignore' }), { root }),
+ /audit cancelled/);
+ } finally { clearTimeout(timer); }
+ await verifyPreview(survivor.origin, survivor.identity);
+});
diff --git a/scripts/run-accessibility.mjs b/scripts/run-accessibility.mjs
new file mode 100644
index 0000000..ae7806e
--- /dev/null
+++ b/scripts/run-accessibility.mjs
@@ -0,0 +1,84 @@
+// Run the unchanged browser gates against one owned, identified static build.
+import { spawn } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { buildDigest, DIST_ROOT, IDENTITY_PATH, startDistServer } from './serve-dist.mjs';
+
+const AUDIT_SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), 'check-accessibility.mjs');
+
+export async function verifyPreview(origin, identity, timeoutMs = 10_000) {
+ if (!/^http:\/\/127\.0\.0\.1:[1-9][0-9]*$/.test(origin)) throw new Error('Preview must use owned loopback origin');
+ const response = await fetch(origin + IDENTITY_PATH, { signal: AbortSignal.timeout(timeoutMs) });
+ if (!response.ok) throw new Error('Preview identity request failed: ' + response.status);
+ const actual = await response.json();
+ if (actual.owner !== identity.owner || actual.buildDigest !== identity.buildDigest) {
+ throw new Error('Preview ownership or built-document identity mismatch');
+ }
+}
+
+export async function withBuiltPreview(run, { root = DIST_ROOT, readinessTimeoutMs = 10_000 } = {}) {
+ const expectedDigest = await buildDigest(root);
+ const preview = await startDistServer({ root, port: 0, owner: randomUUID() });
+ try {
+ await verifyPreview(preview.origin, { ...preview.identity, buildDigest: expectedDigest }, readinessTimeoutMs);
+ process.stdout.write('Auditing built docs ' + expectedDigest + ' at ' + preview.origin + '\n');
+ const result = await run(preview);
+ if (await buildDigest(root) !== expectedDigest) throw new Error('Built documents changed during the audit');
+ return result;
+ } finally {
+ await preview.close();
+ }
+}
+
+export async function runAudit({
+ origin, script = AUDIT_SCRIPT, signal, timeoutMs = 20 * 60_000,
+ env = process.env, stdio = 'inherit',
+}) {
+ if (signal?.aborted) throw new Error('Accessibility audit cancelled');
+ const child = spawn(process.execPath, [script], {
+ env: { ...env, PREVIEW_ORIGIN: origin },
+ stdio,
+ shell: false,
+ });
+ let failure;
+ let forcedStop;
+ const stop = (message) => {
+ if (failure) return;
+ failure = new Error(message);
+ child.kill('SIGTERM');
+ // The handle belongs to this invocation; never inspect or kill by port.
+ forcedStop = setTimeout(() => child.kill('SIGKILL'), 2_000);
+ };
+ const onAbort = () => stop('Accessibility audit cancelled');
+ signal?.addEventListener('abort', onAbort, { once: true });
+ const timer = setTimeout(() => stop('Accessibility audit timed out'), timeoutMs);
+ try {
+ const code = await new Promise((resolveExit, reject) => {
+ child.once('error', reject);
+ child.once('close', resolveExit);
+ });
+ if (failure) throw failure;
+ if (code !== 0) throw new Error('Accessibility audit exited with code ' + code);
+ } finally {
+ clearTimeout(timer);
+ clearTimeout(forcedStop);
+ signal?.removeEventListener('abort', onAbort);
+ }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const controller = new AbortController();
+ const onSignal = () => controller.abort();
+ process.once('SIGINT', onSignal);
+ process.once('SIGTERM', onSignal);
+ try {
+ await withBuiltPreview(({ origin }) => runAudit({ origin, signal: controller.signal }));
+ } catch (error) {
+ process.stderr.write(error.message + '\n');
+ process.exitCode = 1;
+ } finally {
+ process.removeListener('SIGINT', onSignal);
+ process.removeListener('SIGTERM', onSignal);
+ }
+}
diff --git a/scripts/serve-dist.mjs b/scripts/serve-dist.mjs
index 4b7a301..2e5ffc3 100644
--- a/scripts/serve-dist.mjs
+++ b/scripts/serve-dist.mjs
@@ -1,76 +1,119 @@
-// Minimal static server for dist/, mounted at the site's base path.
-// Used by the accessibility audit so it does not depend on the dev toolchain
-// starting within a fixed window on a loaded machine.
+// Static preview of the built site. Each audit owns a loopback listener.
+import { createHash, randomUUID } from 'node:crypto';
import { createServer } from 'node:http';
-import { readFile, stat } from 'node:fs/promises';
-import { join, extname, resolve, dirname } from 'node:path';
+import { readFile, readdir, realpath, stat } from 'node:fs/promises';
+import { join, extname, resolve, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
-const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
-const BASE = '/docs-wallet';
-const PORT = Number(process.env.PORT ?? 4328);
-
+export const DIST_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
+export const BASE = '/docs-wallet';
+export const IDENTITY_PATH = BASE + '/__preview_identity';
const TYPES = {
- '.html': 'text/html; charset=utf-8',
- '.css': 'text/css; charset=utf-8',
- '.js': 'text/javascript; charset=utf-8',
- '.mjs': 'text/javascript; charset=utf-8',
- '.json': 'application/json; charset=utf-8',
- '.svg': 'image/svg+xml',
- '.woff2': 'font/woff2',
- '.xml': 'application/xml; charset=utf-8',
- '.txt': 'text/plain; charset=utf-8',
- '.wasm': 'application/wasm',
- '.png': 'image/png',
- '.webp': 'image/webp',
- '.avif': 'image/avif',
+ '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml',
+ '.woff2': 'font/woff2', '.xml': 'application/xml; charset=utf-8',
+ '.txt': 'text/plain; charset=utf-8', '.wasm': 'application/wasm',
+ '.png': 'image/png', '.webp': 'image/webp', '.avif': 'image/avif',
'.webmanifest': 'application/manifest+json',
- '.pf_meta': 'application/octet-stream',
- '.pf_fragment': 'application/octet-stream',
+ '.pf_meta': 'application/octet-stream', '.pf_fragment': 'application/octet-stream',
'.pf_index': 'application/octet-stream',
};
-async function resolveFile(pathname) {
- let p = pathname.startsWith(BASE) ? pathname.slice(BASE.length) : pathname;
- if (p === '' || p === '/') p = '/index.html';
- let file = join(root, decodeURIComponent(p));
- try {
- const s = await stat(file);
- if (s.isDirectory()) file = join(file, 'index.html');
- return file;
- } catch {
- for (const candidate of [`${file}.html`, join(file, 'index.html')]) {
- try {
- await stat(candidate);
- return candidate;
- } catch {
- /* keep looking */
- }
+export async function buildDigest(root = DIST_ROOT) {
+ const hash = createHash('sha256');
+ for (const required of ['index.html', 'sitemap-0.xml']) {
+ if (!(await stat(join(root, required))).isFile()) throw new Error('Missing built document: ' + required);
+ }
+ async function visit(directory, prefix = '') {
+ const entries = (await readdir(directory, { withFileTypes: true }))
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
+ for (const entry of entries) {
+ const name = prefix + entry.name;
+ if (entry.isDirectory()) await visit(join(directory, entry.name), name + '/');
+ else if (entry.isFile()) {
+ const bytes = await readFile(join(directory, entry.name));
+ hash.update(name + '\0' + bytes.length + '\0').update(bytes).update('\0');
+ } else throw new Error('Unsupported built document entry: ' + name);
}
- return null;
}
+ await visit(root);
+ return hash.digest('hex');
}
-createServer(async (req, res) => {
- const { pathname } = new URL(req.url, 'http://localhost');
- const file = await resolveFile(pathname);
- if (!file) {
- const notFound = join(root, '404.html');
- try {
- res.writeHead(404, { 'content-type': TYPES['.html'] });
- res.end(await readFile(notFound));
- } catch {
- res.writeHead(404).end('not found');
+export async function startDistServer({ root = DIST_ROOT, port = 0, owner = randomUUID() } = {}) {
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid preview port');
+ root = await realpath(root);
+ const identity = { owner, buildDigest: await buildDigest(root) };
+
+ async function resolveFile(pathname) {
+ const decoded = decodeURIComponent(pathname);
+ let path = decoded === BASE || decoded.startsWith(BASE + '/') ? decoded.slice(BASE.length) : decoded;
+ if (path === '' || path === '/') path = '/index.html';
+ const file = resolve(root, '.' + path);
+ if (file !== root && !file.startsWith(root + sep)) return null;
+ for (const candidate of [file, file + '.html', join(file, 'index.html')]) {
+ try {
+ const actual = await realpath(candidate);
+ if (!actual.startsWith(root + sep)) continue;
+ if ((await stat(actual)).isFile()) return actual;
+ } catch { /* try the next static route */ }
}
- return;
+ return null;
}
- // GitHub Pages serves every asset with ACAO:*, which the sandboxed
- // simulator frame (opaque origin) depends on for its module scripts.
+
+ const server = createServer((req, res) => {
+ void (async () => {
+ const { pathname } = new URL(req.url, 'http://127.0.0.1');
+ if (pathname === IDENTITY_PATH) {
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
+ res.end(JSON.stringify(identity));
+ return;
+ }
+ const file = await resolveFile(pathname);
+ if (!file) {
+ const notFound = await readFile(join(root, '404.html')).catch(() => Buffer.from('not found'));
+ res.writeHead(404, { 'content-type': TYPES['.html'] }).end(notFound);
+ return;
+ }
+ const bytes = await readFile(file);
+ // The sandboxed simulator has an opaque origin and needs Pages' CORS policy.
res.writeHead(200, {
'content-type': TYPES[extname(file)] ?? 'application/octet-stream',
'access-control-allow-origin': '*',
});
- res.end(await readFile(file));
-}).listen(PORT, () => {
- process.stdout.write(`serving dist at http://localhost:${PORT}${BASE}/\n`);
-});
+ res.end(bytes);
+ })().catch(() => {
+ if (!res.headersSent) res.writeHead(500);
+ res.end('preview request failed');
+ });
+ });
+
+ await new Promise((resolveReady, reject) => {
+ server.once('error', reject);
+ server.listen(port, '127.0.0.1', () => {
+ server.off('error', reject);
+ resolveReady();
+ });
+ });
+ const origin = 'http://127.0.0.1:' + server.address().port;
+ return {
+ origin, identity,
+ async close() {
+ if (!server.listening) return;
+ server.closeAllConnections();
+ await new Promise((resolveClosed, reject) => server.close((error) => error ? reject(error) : resolveClosed()));
+ },
+ };
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const preview = await startDistServer({ port: Number(process.env.PORT ?? 0) });
+ process.stdout.write('serving dist at ' + preview.origin + BASE + '/\n');
+ const stop = () => void preview.close().catch((error) => {
+ process.stderr.write(error.message + '\n');
+ process.exitCode = 1;
+ });
+ process.once('SIGINT', stop);
+ process.once('SIGTERM', stop);
+}
diff --git a/src/content/docs/assets/zcash-market-listings.mdx b/src/content/docs/assets/zcash-market-listings.mdx
index bc954f9..cd02a9b 100644
--- a/src/content/docs/assets/zcash-market-listings.mdx
+++ b/src/content/docs/assets/zcash-market-listings.mdx
@@ -1,88 +1,96 @@
---
title: Zcash market listings
-description: What a Zerdinal listing signature commits you to, why the approval can be blocked outright, and why cancelling a listing is only advisory.
+description: Review Zerdinal listings and full token lots with exact total prices, clear quantities, and a separate purchase approval.
sourceRepo: bitcoinuniverseio/wallet
-sourcePath: backend/shared/utils/zcash-tx.ts, frontend/ui/pages/Approval/components/ZcashSignMarketListing.tsx
+sourcePath: backend/shared/utils/zcash-market-settlement.ts, frontend/ui/pages/Approval/components/ZcashSignMarketSettlement/index.tsx
chain: [zcash]
lifecycle: not-released
-lastVerified: 2026-09-01
+lastVerified: 2026-09-17
---
import { Aside } from '@astrojs/starlight/components';
-import snapshot from '../../../../capability-snapshot.json';
-
-Universe Wallet implements listing signatures for the Universe market's signed-order protocol, ZMarket
-Orders v1: a fixed-price offer to sell a Zerdinal inscription.
-
-