Add delegate recursion and reinscription UI

This commit is contained in:
recovery committed 2026-10-05 03:57:01 +00:00
1 parent 9f27897967
commit 5d1a1ae866
10 files changed
+327 -31

No files matched your search

+21 -8
View File
@@ -45,15 +45,28 @@ The optional `event-counter` reducer derives counts and ordered ids from only th
The **LOCAL / IN-BROWSER** API explorer calls `LocalOrdAPI.request(path)` and displays/copies its `{status, contentType, body}` response. No HTTP request is made. Fly/the static server cannot read browser state. Available paths:
- `/content/:id`: original MIME and exact `Uint8Array` body. The explorer renders bytes plus an escaped text preview, never executes content.
- `/r/inscription/:id`: decoded event, including JSON metadata and presence flag.
- `/content/:id`: terminal delegate MIME and exact `Uint8Array` body; `inscriptionId` remains the requested id and `contentId` identifies the terminal source. The explorer renders bytes plus an escaped text preview, never executes content.
- `/r/inscription/:id`: decoded event, including JSON metadata, delegate/delegatedBy, synthetic sat, current location, reinscription position and ordered same-sat inscription ids. Metadata and identity always belong to the requested inscription.
- `/r/metadata/:id`: JSON string of CBOR hex, or `null` when absent.
- `/r/parents/:id`, `/r/children/:id`: `{ids}` for immediate lineage (all local results, no pagination).
- `/r/tx/:txid`: `{txid, hex, height}`; `/r/blockheight`: local height.
- `/r/blockhash/:height`, `/r/blockinfo/:height-or-hash`: local hash or synthetic block info with ordered transaction ids (timestamps in milliseconds).
- `/local/sat/:number`: synthetic sat location and ordered inscription stack; `/local/utxo/:txid:vout`: unspent value, half-open sat ranges and inscription ids.
- `/local/metadata-index`: stats and ordered events; `/local/protocols`: names and counts; `/local/protocol?name=...`: matching ordered events. URL-encode protocol names.
Unknown paths/resources return 404; non-local paths return 400. This deliberately partial ord-like API is not wire-compatible with all ord schemas. Metadata encoding follows the [ord metadata convention](https://docs.ordinals.com/inscriptions/metadata.html); path naming and CBOR-hex metadata responses follow the [ord endpoint reference](https://docs.ordinals.com/inscriptions/recursion.html). This API boundary prepares for later recursion; previews do not resolve recursive resources today.
Unknown paths/resources return 404; non-local paths return 400. This deliberately partial ord-like API is not wire-compatible with all ord schemas. Metadata encoding follows the [ord metadata convention](https://docs.ordinals.com/inscriptions/metadata.html); path naming and CBOR-hex metadata responses follow the [ord endpoint reference](https://docs.ordinals.com/inscriptions/recursion.html). HTML/SVG previews use the restricted local bridge described below.
## Delegates, recursion and synthetic sats
The optional delegate is ord tag 11, using the same reversed txid/minimal little-endian index as parent tag 3. Existing and syntactically valid future ids are accepted. `/content/:id` follows chains, returns 404 for an absent target and deterministic 508 for loops. Cards/details link both directions. Delegation changes only served bytes/type, never the requesting inscription's metadata, lineage, sat or self-reference.
**Use recursive demo** fills a usable HTML template. Inline HTML/SVG scripts execute as an async worker program inside a sandboxed iframe. Use `await fetch('/r/blockheight')`, `fetch('/content/'+id)` and standard Response methods. `ord.id` and `ord.contentPath` refer to the requested inscription, including delegated previews; `ord.render(text)` writes escaped text to the preview. The MessageChannel bridge permits only the documented `/content` and `/r` read paths (no query strings, URLs, local administrative endpoints or mutations). CSP also blocks direct XHR, WebSocket and import requests. Workers cannot navigate or access any DOM; the iframe runs only the trusted renderer. General browser DOM scripts and automatic URL rewriting are deliberately unsupported. Scripts never receive wallet secrets or signing capabilities.
Sat identity is **synthetic-local-fifo**, not a Bitcoin ordinal number. Height `h` issues `[ (h-1)*5,000,000,000, h*5,000,000,000 )`. Ordered input ranges flow into ordered outputs; leftover fee ranges retire instead of being paid to a miner. This replaceable range adapter is replayed from raw transactions, not saved UI state. New parent reveals put the parent's input and output first, preserving its exact sat offset; the new inscription uses a pointer to its own output. Legacy envelopes still decode and their sat positions follow their actual serialized FIFO flow.
Select **Reinscribe an owned sat** to spend and return its current output while appending an inscription to that same sat. The original body and id remain, stack positions start at zero and ordering survives mining/replay/spends. A new 1,000-sat output is also created by the fixed commit/reveal template; reinscription targets the carried sat, not that new output. Parent and reinscription selection are mutually exclusive. The reinscription target determines its pointer.
Advanced pointer tag 2 is an unsigned 64-bit zero-based position across all reveal outputs. Encoding is little-endian with trailing zero bytes omitted (zero is empty); a pointer beyond total output value falls back to the first output sat. There is **one inscription per reveal**. Multiple inscriptions on one sat come from separate reveals. Actual Bitcoin subsidy schedules, miner fee recycling, multiple envelopes/reveal, arbitrary DOM recursion, native tag 7 and regtest/public backends are deferred.
## Mining at your pace
@@ -65,7 +78,7 @@ A tab must stay open; background throttling/suspension can delay mining. Reopeni
**Local lab → regtest (Super Regtest) → signet/testnet → mainnet** is a future selector progression, not a list of implemented modes. Chain, wallet/signing, raw metadata index, trusted reducer and API adapters stay separate. A later network adapter must explicitly supply chain data and wallet capabilities; a hosted server cannot implicitly access browser storage. Regtest is not implemented. Public networks and mainnet need separate security and signing designs; this v0 has no production keys/funds, external broadcast or hidden signing.
Recursion, reinscriptions, delegates, pointers/sat tracking and native tag-7 compatibility remain future passes. Metadata supports the JSON data model encoded in CBOR, not every CBOR feature (no byte strings, tags, indefinite collections or arbitrary map keys). The narrow local envelope validator accepts the app's encoding, not arbitrary external ord transactions.
Native tag-7 compatibility, full ord indexing and public-network adapters remain future passes. Metadata supports the JSON data model encoded in CBOR, not every CBOR feature (no byte strings, tags, indefinite collections or arbitrary map keys). The narrow local envelope validator accepts the app's encoding, not arbitrary external ord transactions.
## Architecture
@@ -82,9 +95,9 @@ Recursion, reinscriptions, delegates, pointers/sat tracking and native tag-7 com
## Exact v0 limitations
- Blocks are custom JSON hashed with SHA-256, not Bitcoin block headers. No proof of work, difficulty, merkle tree, reorgs, peers, chain synchronization, coinbase maturity, halving or full consensus. Fixed 50-BTC rewards; fixed 500-sat fees per non-coinbase transaction are burned.
- Only this app’s single-input Taproot key-path and narrow `ord` MIME script-path templates, plus explicit two-input child reveals, are accepted. No general Script interpreter, legacy/SegWit-v0, timelocks, arbitrary transactions, fee estimation, transfers or replace-by-fee. Regtest-format addresses do not make the chain compatible with Bitcoin Core. **Never fund these addresses elsewhere.**
- One inscription per reveal, 1,000-sat postage, 16 KiB body cap and 520-byte script pushes. Parent tag 3 encodes reversed txid bytes plus a minimal little-endian index; validation requires the referenced parent input, its signature, and preservation in the same wallet. Only one parent per child. Parent identity and lineage survive relocated outputs and cannot be edited. Other collectible spends are rejected, including untagged parent spends (which create no provenance). This is an intentionally simplified local ord-like model: no sat-offset/ordinal tracking, full ord indexing or Bitcoin consensus compatibility. No BRC-20 execution, recursive resource resolution, general transfers or external Ord compatibility guarantee.
- Text/JSON previews use escaped text; JSON is not schema-validated. HTML/SVG use an inert, restrictive element/attribute allowlist in sandboxed blob frames with no scripts, navigation or network resources. Rendering is intentionally lossy (no linked images, fonts, scripts or embedded frames). Raster previews use local blobs; MIME declarations do not guarantee valid image data. Body bytes are preserved exactly even when previews cannot render them.
- Only this app’s single-input Taproot key-path and narrow `ord` MIME script-path templates, plus explicit two-input child/reinscription reveals, are accepted. No general Script interpreter, legacy/SegWit-v0, timelocks, arbitrary transactions, fee estimation, transfer UI or replace-by-fee. Regtest-format addresses do not make the chain compatible with Bitcoin Core. **Never fund these addresses elsewhere.**
- One inscription per reveal, 1,000-sat postage, 16 KiB body cap and 520-byte script pushes. Parent tag 3 encodes reversed txid bytes plus a minimal little-endian index; validation requires the referenced parent input, its signature, and preservation in the same wallet. Only one parent per child. Parent identity and lineage survive relocated outputs and cannot be edited. Signed key-path spends carry sat identities and stacks through FIFO ranges. Inscribed sats cannot be discarded into fees. No full ord indexing, BRC-20 execution, transfer UI or external Ord compatibility guarantee.
- Text/JSON previews use escaped text; JSON is not schema-validated. HTML/SVG markup uses a restrictive element/attribute allowlist in sandboxed frames. Inscription scripts run only in an isolated worker, with local fetch and text-only ord.render; no DOM, navigation, external imports or network requests. Rendering is intentionally lossy (no linked images, fonts or embedded frames). Reviews stay inert until explicit signing. Raster previews use local blobs; MIME declarations do not guarantee valid image data. Body bytes are preserved exactly even when previews cannot render them.
- One active wallet per origin. Test secret and chain are **unencrypted** in browser storage and JSON backups. No mnemonic, password, hardware wallet, key derivation, custody service or hardened security audit. Tagged JSON is an accidental-import guard, not proof a key has never been used elsewhere. Never put production secrets in these files.
- No persistent server database, volume or shared chain. Browser storage quotas apply; large histories replay synchronously and eventually become slow. A maximum of 1,000 pending transactions (500 inscriptions) per block; complete lab imports capped at 5 MB. Built for small experiments.
- Mining signs nothing; only **Sign & inscribe** signs the two displayed operations. Restoring state verifies signatures but does not create new ones. Exporting backups exposes the fake wallet key in the downloaded file.
@@ -92,7 +105,7 @@ Recursion, reinscriptions, delegates, pointers/sat tracking and native tag-7 com
## Verification
Verified for this pass: **19 adapter tests passed** (`npm test`), **22 desktop/mobile browser tests passed** (`npm run test:e2e`), and `npm run build` passed. Chromium, missing runtime libraries and fonts were staged under `/tmp`; no system packages or deployment were changed. Run:
Verified for this pass: **30 adapter tests passed** (`npm test`), **26 desktop/mobile browser tests passed** (`npm run test:e2e`), and `npm run build` passed. Chromium, missing runtime libraries and fonts were staged under `/tmp`; no system packages or deployment were changed. Run:
```sh
npm test
+3 -3
View File
@@ -23,8 +23,8 @@
<div class="wallet-footer"><label class="file-button" for="load-wallet">↑ Load test wallet or lab backup</label><input id="load-wallet" type="file" accept=".json,application/json"><p class="muted">Saved on this device. A wallet backup contains its test key; a lab backup also preserves the chain.</p></div>
</article>
<article class="panel"><div class="panel-title"><span class="step">02</span><h2>Make some blocks</h2></div><p>Skip the faucet queue. Every block gives you <strong>50 fake BTC</strong>, ready to use immediately.</p><div class="chain-stats"><div><span class="label">BLOCK HEIGHT</span><strong id="height">0</strong></div><div><span class="label">PENDING TXS</span><strong id="pending">0</strong></div></div><button id="mine" class="primary" disabled>Mine now <span>+</span></button><label for="miner-interval">Auto miner</label><select id="miner-interval"><option value="0">OFF</option><option value="10">10s</option><option value="30">30s</option><option value="60">60s</option></select><p id="miner-status" aria-live="polite">Auto mining OFF</p><p class="muted">Keep a lab tab open; browser throttling may delay blocks. Mining also confirms your pending inscriptions.</p></article>
<article class="panel inscribe"><div class="panel-title"><span class="step">03</span><h2>Leave a little something</h2><span class="tag">INSCRIPTION LAB</span></div><label for="mime-type">Content type</label><select id="mime-type"></select><label for="body-file">Or upload a local file (16 KiB maximum)</label><input id="body-file" type="file"><label for="parent">Parent inscription (optional, immutable)</label><select id="parent"><option value="">No parent</option></select><label for="payload">What will you put on your chain?</label><textarea id="payload" placeholder="Hello, little Bitcoin universe…" rows="3" disabled></textarea><label for="metadata">JSON metadata (optional, independent of body)</label><textarea id="metadata" rows="3" placeholder='{"protocol":"my-experiment","note":"hello"}'></textarea><p id="metadata-count" class="muted">0 / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only</p><div class="compose-footer"><span id="byte-count" class="muted">0 / 16384 bytes</span><button id="review" disabled>Review inscription ↗</button></div>
<div id="review-panel" hidden><h3>Review before signing</h3><div id="review-preview"></div><pre id="review-text"></pre><p id="review-parent"></p><pre id="review-metadata"></pre><p>Two transactions: commit + reveal. <strong>1,000 fake sats</strong> in fees; <strong>1,000 fake sats</strong> reserved in your collectible. It returns to your current wallet.</p><p class="muted">The next button signs both transactions locally with this test wallet and submits them to your private chain.</p><button id="sign" class="primary">Sign & inscribe</button> <button id="cancel-review" class="quiet">Cancel</button></div>
<article class="panel inscribe"><div class="panel-title"><span class="step">03</span><h2>Leave a little something</h2><span class="tag">INSCRIPTION LAB</span></div><label for="mime-type">Content type</label><select id="mime-type"></select><label for="body-file">Or upload a local file (16 KiB maximum)</label><input id="body-file" type="file"><label for="parent">Parent inscription (optional, immutable)</label><select id="parent"><option value="">No parent</option></select><label for="delegate">Delegate inscription ID (optional, future IDs allowed)</label><input id="delegate" placeholder="64 hex characters + i0"><label for="reinscribe">Reinscribe an owned sat (optional)</label><select id="reinscribe"><option value="">New sat</option></select><label for="pointer">Advanced pointer (zero-based output sat position)</label><input id="pointer" inputmode="numeric" placeholder="Default first sat"><button id="recursive-demo" class="quiet">Use recursive demo</button><p class="muted">Recursive HTML/SVG scripts use local fetch and ord.render(text) in an isolated worker; ord.id is the requested inscription. No DOM or network access.</p><label for="payload">What will you put on your chain?</label><textarea id="payload" placeholder="Hello, little Bitcoin universe…" rows="3" disabled></textarea><label for="metadata">JSON metadata (optional, independent of body)</label><textarea id="metadata" rows="3" placeholder='{"protocol":"my-experiment","note":"hello"}'></textarea><p id="metadata-count" class="muted">0 / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only</p><div class="compose-footer"><span id="byte-count" class="muted">0 / 16384 bytes</span><button id="review" disabled>Review inscription ↗</button></div>
<div id="review-panel" hidden><h3>Review before signing</h3><div id="review-preview"></div><pre id="review-text"></pre><p id="review-parent"></p><pre id="review-metadata"></pre><p>Two transactions: commit + reveal. <strong>1,000 fake sats</strong> in fees; <strong>1,000 fake sats</strong> in a new wallet output. Any selected parent or reinscription output is also returned in full; reinscription appends to its existing sat stack.</p><p class="muted">The next button signs both transactions locally with this test wallet and submits them to your private chain.</p><button id="sign" class="primary">Sign & inscribe</button> <button id="cancel-review" class="quiet">Cancel</button></div>
</article>
</section>
<section class="collection-section"><div class="section-title"><div><p class="eyebrow">04 / THE THINGS YOU MADE</p><h2>Your inscriptions <span id="collection-count">0</span></h2></div><span class="muted">Owned by this wallet</span></div><div id="collectibles" class="collectibles"><p class="empty">Your first inscription belongs here.<br><span>Fund your wallet, write some text, and make it yours.</span></p></div></section>
@@ -53,7 +53,7 @@
<button id="api-try">Try</button> <button id="api-copy" class="quiet">Copy response</button>
<pre id="api-response" aria-live="polite">Choose a path and press Try.</pre>
</section>
<details class="about"><summary>Under the hood & attribution</summary><p>Real serialized Bitcoin transactions, Schnorr signatures and Taproot commit/reveal scripts, on a deliberately simplified local UTXO chain. No proof of work, Bitcoin consensus, peer network, public broadcast, or production custody. Collectibles can only be spent by explicit child reveals that preserve the parent in the same wallet.</p><p>Built from and inspired by <a href="https://github.com/supertestnet/testnet_generator">Supertestnet’s testnet_generator</a>, <a href="https://github.com/supertestnet/inscriptions-online">inscriptions-online</a>, and <a href="https://github.com/supertestnet/vanilla-js-browser-wallet">vanilla-js-browser-wallet</a>. <a href="https://github.com/supertestnet/node_faker">node_faker</a> was evaluated for future RPC compatibility.</p><p>Transaction library: <a href="https://github.com/cmdruid/tapscript">tapscript by Christopher Scott</a> (<a href="/licenses/tapscript.txt">CC-BY-1.0</a>). <a href="/licenses/supertestnet-CC0.txt">Supertestnet CC0 license</a> · <a href="/licenses/noble-curves.txt">noble-curves license</a> · <a href="/licenses/noble-hashes.txt">noble-hashes license</a>.</p></details>
<details class="about"><summary>Under the hood & attribution</summary><p>Real serialized Bitcoin transactions, Schnorr signatures and Taproot commit/reveal scripts, on a deliberately simplified local UTXO chain. No proof of work, Bitcoin consensus, peer network, public broadcast, or production custody. Synthetic local sats follow FIFO input-to-output ranges. Child creation and reinscription preserve the owned sat. Fees retire the input tail; this is not Bitcoin consensus.</p><p>Built from and inspired by <a href="https://github.com/supertestnet/testnet_generator">Supertestnet’s testnet_generator</a>, <a href="https://github.com/supertestnet/inscriptions-online">inscriptions-online</a>, and <a href="https://github.com/supertestnet/vanilla-js-browser-wallet">vanilla-js-browser-wallet</a>. <a href="https://github.com/supertestnet/node_faker">node_faker</a> was evaluated for future RPC compatibility.</p><p>Transaction library: <a href="https://github.com/cmdruid/tapscript">tapscript by Christopher Scott</a> (<a href="/licenses/tapscript.txt">CC-BY-1.0</a>). <a href="/licenses/supertestnet-CC0.txt">Supertestnet CC0 license</a> · <a href="/licenses/noble-curves.txt">noble-curves license</a> · <a href="/licenses/noble-hashes.txt">noble-hashes license</a>.</p></details>
<footer><span>SMALL CHAIN. BIG POSSIBILITIES.</span><button id="reset" class="quiet danger">Reset local lab</button></footer>
</main><script type="module" src="/src/main.js"></script>
</body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-local-ord-preview'; worker-src blob:; style-src 'unsafe-inline'; connect-src 'none'; img-src data:; form-action 'none'; base-uri 'none'"><div id="markup"></div><pre id="recursive-output"></pre><script nonce="local-ord-preview" src="/recursive-preview.js"></script>
+16
View File
@@ -0,0 +1,16 @@
// Trusted renderer only. Inscription JavaScript runs exclusively in the worker.
addEventListener('message', function start(event) {
if (event.source !== parent || event.data?.type !== 'local-ord' || !event.ports[0]) return;
removeEventListener('message', start);
const port = event.ports[0];
document.getElementById('markup').innerHTML = event.data.markup;
const url = URL.createObjectURL(new Blob([event.data.worker], { type: 'text/javascript' }));
const worker = new Worker(url);
worker.onmessage = ({ data }) => {
if (data.type === 'render') document.getElementById('recursive-output').textContent = String(data.text).slice(0, 65536);
else if (data.type === 'fetch') port.postMessage(data);
};
worker.onerror = () => { document.getElementById('recursive-output').textContent = 'Recursive script failed.'; };
port.onmessage = ({ data }) => worker.postMessage(data);
addEventListener('pagehide', () => { worker.terminate(); port.close(); URL.revokeObjectURL(url); }, { once: true });
});
+6 -2
View File
@@ -5,10 +5,14 @@ import { resolve, extname, sep } from 'node:path';
const root = resolve('dist');
const types = { '.html':'text/html; charset=utf-8', '.js':'text/javascript; charset=utf-8', '.css':'text/css; charset=utf-8', '.svg':'image/svg+xml', '.txt':'text/plain; charset=utf-8', '.json':'application/json' };
const security = {
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; frame-src blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
'X-Content-Type-Options':'nosniff', 'Referrer-Policy':'no-referrer', 'X-Frame-Options':'DENY',
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
};
const previewSecurity = {
'Content-Security-Policy': "default-src 'none'; script-src 'nonce-local-ord-preview'; worker-src blob:; style-src 'unsafe-inline'; connect-src 'none'; img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
'X-Frame-Options': 'SAMEORIGIN',
};
http.createServer(async (req,res) => {
const send = (status, body, headers={}) => { res.writeHead(status, {...security,...headers}); res.end(req.method==='HEAD' ? undefined : body); };
if (!['GET','HEAD'].includes(req.method)) return send(405,'Method not allowed',{'Allow':'GET, HEAD'});
@@ -18,6 +22,6 @@ http.createServer(async (req,res) => {
const target=resolve(root, '.'+(path==='/' ? '/index.html' : path));
if (!target.startsWith(root+sep)) return send(403,'Forbidden');
const data=await readFile(target);
send(200,data,{'Content-Type':types[extname(target)]??'application/octet-stream','Cache-Control':path.startsWith('/assets/')?'public, max-age=31536000, immutable':'no-cache'});
send(200,data,{...(path === '/recursive-preview.html' ? previewSecurity : {}), 'Content-Type':types[extname(target)]??'application/octet-stream','Cache-Control':path.startsWith('/assets/')?'public, max-age=31536000, immutable':'no-cache'});
} catch { send(404,'Not found'); }
}).listen(Number(process.env.PORT || 8080),'0.0.0.0',()=>console.log(`Super Testnet static server listening on ${process.env.PORT || 8080}`));
+29 -9
View File
@@ -1,3 +1,4 @@
import { RECURSIVE_DEMO } from './recursion.js';
import './style.css';
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
@@ -57,10 +58,18 @@ const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileByte
function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
function resetComposer() {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
$('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
$('delegate').value = ''; $('pointer').value = ''; $('reinscribe').value = ''; $('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
}
function lineage(item, chain) {
const p = el('p', undefined, 'tx-links');
if (item.delegate) p.append(link(`Delegate ${short(item.delegate)}`, `#inscription/${item.delegate}`));
for (const record of chain.transactions.values()) if (record.inscription?.delegate === item.id)
p.append(link(`Delegated by ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
if (item.sat !== undefined) {
p.append(el('span', `Synthetic sat ${item.sat} · stack position ${item.reinscriptionPosition} · ${JSON.stringify(chain.satLocation(item.sat))}`));
for (const record of chain.transactions.values()) if (record.inscription?.sat === item.sat)
p.append(link(`Sat history #${record.inscription.reinscriptionPosition}`, `#inscription/${record.inscription.id}`));
}
if (item.parent) p.append(link(`Parent ${short(item.parent)}`, `#inscription/${item.parent}`));
for (const record of chain.transactions.values()) if (record.inscription?.parent === item.id)
p.append(link(`Child ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
@@ -163,7 +172,7 @@ function render() {
$('miner-interval').value = String(lab?.miner?.interval ?? 0);
$('miner-status').textContent = minerStatus(lab);
$('payload').disabled = disabled || !wallet || !!fileBytes || fileLoading || $('mime-type').value.startsWith('image/') && $('mime-type').value !== 'image/svg+xml';
for (const id of ['mime-type', 'body-file', 'parent', 'metadata']) $(id).disabled = disabled || !wallet || fileLoading;
for (const id of ['mime-type', 'body-file', 'parent', 'metadata', 'delegate', 'pointer', 'reinscribe', 'recursive-demo']) $(id).disabled = disabled || !wallet || fileLoading;
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || fileLoading || !composerBody().bytes.length;
$('sign').disabled = disabled;
$('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
@@ -180,6 +189,10 @@ function render() {
$('parent').replaceChildren(new Option('No parent', ''));
items.forEach(item => $('parent').append(new Option(short(item.id), item.id)));
$('parent').value = selectedParent;
const selectedSat = $('reinscribe').value;
$('reinscribe').replaceChildren(new Option('New sat', ''));
items.forEach(item => $('reinscribe').append(new Option(`Sat ${item.sat}: ${short(item.id)}`, item.id)));
$('reinscribe').value = selectedSat;
$('collection-count').textContent = items.length;
clearPreviews($('collectibles'));
$('collectibles').replaceChildren();
@@ -190,7 +203,7 @@ function render() {
}
for (const item of items.toReversed()) {
const card = el('article', undefined, 'collectible');
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item));
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item, new LocalOrdAPI(chain)));
card.append(link(`Inscription ${short(item.id)}`, `#inscription/${item.id}`));
const trail = el('p', undefined, 'tx-links');
trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
@@ -231,7 +244,12 @@ setInterval(() => {
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
$('parent').onchange = closeReview;
$('parent').onchange = $('reinscribe').onchange = closeReview;
$('delegate').oninput = $('pointer').oninput = closeReview;
$('recursive-demo').onclick = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('mime-type').value = 'text/html';
$('payload').value = RECURSIVE_DEMO; closeReview(); updateCount(); render();
};
$('mime-type').onchange = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; closeReview(); updateCount(); render();
};
@@ -253,12 +271,14 @@ $('body-file').onchange = async event => {
$('review').onclick = () => {
try {
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
const options = { delegate: $('delegate').value.trim() || null, reinscribe: $('reinscribe').value || null, ...($('pointer').value.trim() ? { pointer: $('pointer').value.trim() } : {}) };
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata, options);
closeReview();
reviewed = { payload, parent, metadata, address: lab.wallet.address };
reviewed = { payload, parent, metadata, options, address: lab.wallet.address };
$('review-text').textContent = ['text/plain', 'application/json'].includes(payload.contentType) ? new TextDecoder().decode(payload.bytes) : `${payload.contentType} · ${payload.bytes.length} bytes`;
if (!['text/plain', 'application/json'].includes(payload.contentType)) $('review-preview').append(preview(payload));
$('review-parent').textContent = parent ? `Parent: ${parent}. Its output will be spent and its full value returned to this wallet. This relationship is immutable.` : 'No parent.';
$('review-parent').textContent += ` Delegate: ${options.delegate ?? 'none'}. Reinscribe: ${options.reinscribe ?? 'none'}. Pointer: ${options.pointer ?? 'automatic'}.`;
$('review-metadata').textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${JSON.stringify(metadata, null, 2)}`;
$('review-panel').hidden = false;
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
@@ -270,7 +290,7 @@ $('sign').onclick = () => {
if (!consent) return;
mutate(next => {
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata);
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata, consent.options);
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.', resetComposer);
};
$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
@@ -336,7 +356,7 @@ function inspect(scroll = true) {
if (record.inscription) {
add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
add('Body size', `${record.inscription.bytes.length} bytes`);
pane.append(preview(record.inscription), metadataView(record.inscription), lineage(record.inscription, chain));
pane.append(preview(record.inscription, new LocalOrdAPI(chain)), metadataView(record.inscription), lineage(record.inscription, chain));
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
}
pane.append(el('h3', 'Inputs → outputs'));
@@ -350,7 +370,7 @@ function inspect(scroll = true) {
tx.vout.forEach((out, i) => add(`Output ${i}`, `${Number(out.value).toLocaleString()} sats`));
add('Fee', record.coinbase ? '0 (local reward)' : '500 fake sats');
pane.append(el('h3', 'Raw Bitcoin transaction'), el('pre', rpc.call('getrawtransaction', [txid])));
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin[0].witness, null, 2))); }
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin.map(input => input.witness), null, 2))); }
} else throw new Error('Unknown explorer view.');
} catch (error) { pane.append(el('p', error.message)); }
if (scroll) { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); pane.focus({ preventScroll: true }); }
+17 -2
View File
@@ -1,8 +1,14 @@
import { recursivePayload, connectRecursion } from './recursion.js';
// Inert parsing only; inscription markup is never attached to the parent DOM.
// The deliberately small allowlist also removes navigation (including meta refresh).
const tags = new Set('p div span h1 h2 h3 h4 h5 h6 br hr pre code b strong i em u s ul ol li table thead tbody tr td th blockquote svg g path rect circle ellipse line polyline polygon text tspan defs lineargradient radialgradient stop'.split(' '));
const attrs = new Set('style title width height viewbox fill stroke stroke-width d x y x1 y1 x2 y2 cx cy r rx ry points transform opacity offset stop-color font-size text-anchor'.split(' '));
export function preview(item) {
export function preview(item, api = null) {
if (api && item.id) {
const response = api.request(`/content/${item.id}`);
if (response.status !== 200) { const error = document.createElement('pre'); error.textContent = `${response.status}: ${response.body.error}`; return error; }
item = { ...item, bytes: response.body, contentType: response.contentType };
}
const bytes = new Uint8Array(item.bytes);
const type = item.contentType.split(';')[0];
const text = new TextDecoder().decode(bytes);
@@ -13,6 +19,7 @@ export function preview(item) {
if (type === 'text/html' || type === 'image/svg+xml') {
const inert = document.implementation.createHTMLDocument('');
const template = inert.createElement('template'); template.innerHTML = text;
const scripts = [...template.content.querySelectorAll('script')].filter(s => !s.src).map(s => s.textContent);
const clean = parent => {
for (const child of [...parent.children]) {
if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
@@ -21,6 +28,14 @@ export function preview(item) {
}
};
clean(template.content);
if (api && item.id && scripts.length) {
node = document.createElement('iframe'); node.setAttribute('sandbox', 'allow-scripts');
node.title = `${type} recursive preview`; node.referrerPolicy = 'no-referrer';
const payload = recursivePayload(template.innerHTML, scripts, item.id);
node.onload = () => { node.cleanup?.(); node.cleanup = connectRecursion(node, api, payload); };
node.src = '/recursive-preview.html'; node.dataset.previewUrl = ''; node.className = 'inscription-preview';
return node;
}
const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; form-action 'none'; base-uri 'none'";
blob = new Blob([`<!doctype html><meta http-equiv="Content-Security-Policy" content="${csp}">`, template.innerHTML], { type: 'text/html' });
node = document.createElement('iframe'); node.setAttribute('sandbox', ''); node.title = `${type} safe preview`;
@@ -32,5 +47,5 @@ export function preview(item) {
node.className = 'inscription-preview'; return node;
}
export function clearPreviews(root) {
root.querySelectorAll('[data-preview-url]').forEach(node => URL.revokeObjectURL(node.dataset.previewUrl));
root.querySelectorAll('[data-preview-url]').forEach(node => { node.cleanup?.(); URL.revokeObjectURL(node.dataset.previewUrl); });
}
+44
View File
@@ -0,0 +1,44 @@
// Deliberately exact paths: no URLs, query strings, escapes, traversal or local
// mutation endpoints. Both sides validate; the parent is the authority.
export function recursivePath(path) {
return typeof path === 'string' && /^(?:\/content\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/(?:inscription|metadata|parents|children)\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/tx\/[0-9a-f]{64}|\/r\/blockheight|\/r\/blockhash\/(?:0|[1-9][0-9]*)|\/r\/blockinfo\/(?:0|[1-9][0-9]*|[0-9a-f]{64}))$/.test(path);
}
export const RECURSIVE_DEMO = `<h2>Recursive local inscription</h2><p>Live data from this browser's chain:</p>
<script>
const height = await (await fetch('/r/blockheight')).json();
const me = await (await fetch('/r/inscription/' + ord.id)).json();
const metadata = await (await fetch('/r/metadata/' + ord.id)).json();
ord.render(JSON.stringify({ id: ord.id, height, sat: me.sat, metadata }, null, 2));
</script>`;
// This function is serialized into a worker, never executed in the parent DOM.
function workerBootstrap(id, allowed) {
let next = 0; const pending = new Map();
self.onmessage = ({ data }) => {
const resolve = pending.get(data.token); if (!resolve) return;
pending.delete(data.token); resolve(data);
};
self.fetch = async (path, options = {}) => {
if (!allowed(path) || (options.method && options.method !== 'GET')) throw new TypeError('Only whitelisted local GET paths are allowed.');
const token = ++next;
const response = await new Promise(resolve => { pending.set(token, resolve); postMessage({ type: 'fetch', token, path }); });
if (response.error) throw new TypeError(response.error);
const body = response.body instanceof Uint8Array ? response.body : JSON.stringify(response.body);
return new Response(body, { status: response.status, headers: { 'Content-Type': response.contentType } });
};
self.ord = Object.freeze({ id, contentPath: '/content/' + id, render: text => postMessage({ type: 'render', text: String(text).slice(0, 65536) }) });
}
export function recursivePayload(markup, scripts, id) {
const worker = `const recursivePath = ${recursivePath.toString()}; (${workerBootstrap.toString()})(${JSON.stringify(id)}, recursivePath); (async () => {${scripts.join('\n;\n')}\n})().catch(e => ord.render(e.message));`;
return { markup, worker };
}
export function connectRecursion(frame, api, payload) {
const channel = new MessageChannel();
channel.port1.onmessage = ({data}) => {
if (!data || data.type !== 'fetch') return;
const result = recursivePath(data.path) ? api.request(data.path) : { error: 'Blocked local path.' };
channel.port1.postMessage({ ...result, token: data.token });
};
frame.contentWindow.postMessage({ type: 'local-ord', ...payload }, '*', [channel.port2]);
return () => channel.port1.close();
}
+130 -5
View File
@@ -74,13 +74,16 @@ test('tampering with reveal payload, control block or value is rejected', () =>
assert.deepEqual(candidate.snapshot(),before);
}
});
test('collectibles are protected and subsequent inscriptions use change', () => {
test('ordinary signed spends preserve inscriptions and subsequent inscriptions use change', () => {
const {wallet,chain,ins}=setup(); const first=ins.inscribe('first'); ins.inscribe('second');
assert.equal(ins.list().length,2);
const record=chain.transactions.get(first.slice(0,-2));
const spend=Tx.create({vin:[{txid:record.txid,vout:0,sequence:0xffffffff,prevout:record.tx.vout[0]}],vout:[{value:500,scriptPubKey:wallet.script}]});
spend.vin[0].witness=[wallet.sign(spend)];
assert.throws(()=>chain.submitPackage([Tx.encode(spend).hex]),/protects inscription/);
const sat = record.inscription.sat;
chain.submitPackage([Tx.encode(spend).hex]);
assert.equal(ins.list().find(i=>i.id===first).sat,sat);
assert.equal(chain.satLocation(sat).txid,Tx.util.getTxid(spend));
chain.mine(wallet.script); assert.equal(ins.list().length,2);
});
test('corrupted persistence is rejected rather than silently reset', () => {
@@ -222,13 +225,14 @@ test('metadata repeated tag 5 chunks reassemble from witness; body, parent and m
const metadata = { protocol: 'example', text: '🌱'.repeat(300), arbitrary: [1,true,null] };
const id = ins.inscribe({ contentType: 'image/png', bytes: new Uint8Array(MAX_BYTES).fill(128) }, parent, metadata);
const record = chain.transactions.get(id.slice(0,-2));
const parts = Script.decode(record.tx.vin[0].witness[1]);
const chunks=[]; let i=9;
const witness = record.tx.vin.find(i => i.witness.length === 3).witness;
const parts = Script.decode(witness[1]);
const chunks=[]; let i=11;
while (parts[i] === '05') { chunks.push(parts[i+1]); i+=2; }
assert.ok(chunks.length > 1);
assert.ok(chunks.every(chunk => chunk.length / 2 <= 520));
assert.deepEqual(decodeMetadata(Buffer.from(chunks.join(''),'hex')),metadata);
assert.deepEqual(readEnvelope(record.tx.vin[0].witness).metadata, metadata);
assert.deepEqual(readEnvelope(witness).metadata, metadata);
assert.equal(ins.list().find(x=>x.id===id).parent,parent);
assert.deepEqual(decodeLab(encodeLab({wallet,chain})).chain.transactions.get(record.txid).inscription.metadata,metadata);
const max = 'x'.repeat(MAX_METADATA_BYTES - 3);
@@ -328,3 +332,124 @@ test('two auto miners re-read deadline under shared lock; manual mining uses ide
await assert.rejects(mutateLab(lab=>mineLab(lab,false),{...io,save:()=>{throw new Error('quota');}}),/quota/);
assert.equal(decodeLab(stored).chain.blocks.length,3);
});
import { encodePointer, decodePointer } from '../src/adapters/inscription.js';
import { resolveContent } from '../src/adapters/local-ord-api.js';
import { recursivePath } from '../src/recursion.js';
test('delegate tag 11 uses parent serialization and accepts future ids before signing', () => {
const {ins,chain,wallet} = setup();
const delegate = '0123456789abcdef'.repeat(4) + 'i256';
const id = ins.inscribe('fallback', null, {protocol:'delegate'}, {delegate});
const witness = chain.transactions.get(id.slice(0,-2)).tx.vin[0].witness;
assert.ok(witness[1].includes('010b22' + serializeParent(delegate)));
assert.equal(readEnvelope(witness).delegate, delegate);
assert.equal(new LocalOrdAPI(chain).request(`/content/${id}`).status,404);
wallet.sign = () => { throw new Error('must not sign'); };
assert.throws(() => ins.inscribe('bad',null,undefined,{delegate:'invalid'}), /Invalid parent/);
});
test('delegates resolve A to B to C content/type while identity and metadata stay A', () => {
const {ins,chain} = setup();
const c = ins.inscribe({contentType:'text/html',bytes:new TextEncoder().encode('<p>terminal</p>')});
const b = ins.inscribe('b',null,undefined,{delegate:c});
const a = ins.inscribe('a',null,{identity:'a'},{delegate:b});
const api = new LocalOrdAPI(chain), response = api.request(`/content/${a}`);
assert.equal(response.inscriptionId,a); assert.equal(response.contentId,c);
assert.equal(response.contentType,'text/html'); assert.equal(new TextDecoder().decode(response.body),'<p>terminal</p>');
assert.deepEqual(api.request(`/r/inscription/${a}`).body.metadata,{identity:'a'});
assert.deepEqual(api.request(`/r/inscription/${b}`).body.delegatedBy,[a]);
});
test('forward delegate becomes available and loops have deterministic 508 responses', () => {
// Resolution graph fixture: a future transaction id cannot be precomputed from
// a mutually hash-dependent cycle of actual Bitcoin transactions.
const index = { events: [{id:'a',delegate:'b'}] };
const read = () => ({bytes:Uint8Array.of(42),contentType:'image/png'});
assert.equal(resolveContent(index,'a',read).status,404);
index.events.push({id:'b',delegate:null});
assert.deepEqual(resolveContent(index,'a',read).body,Uint8Array.of(42));
index.events[1].delegate='a';
assert.deepEqual(resolveContent(index,'a',read),{status:508,contentType:'application/json',body:{error:'Delegate loop detected.'}});
index.events[0].delegate='a'; assert.equal(resolveContent(index,'a',read).status,508);
});
test('FIFO sats, parent preserving spends, same-sat reinscription stack and raw rebuild', () => {
const {ins,chain,wallet} = setup();
const a = ins.inscribe('original',null,{protocol:'stack'});
const sat = ins.list().find(i=>i.id===a).sat;
for (let n=0;n<3;n++) ins.inscribe('child '+n,a);
assert.equal(ins.list().find(i=>i.id===a).sat,sat);
assert.equal(chain.satLocation(sat).offset,0);
const b = ins.inscribe('second',null,{protocol:'stack'},{reinscribe:a});
const c = ins.inscribe('third',null,undefined,{reinscribe:b});
ins.inscribe('stack child',a);
const api = new LocalOrdAPI(chain), stack = api.request(`/local/sat/${sat}`).body;
assert.deepEqual(stack.inscriptions,[a,b,c]);
for (const [n,id] of [a,b,c].entries()) {
const event=api.request(`/r/inscription/${id}`).body;
assert.equal(event.sat,sat); assert.equal(event.reinscriptionPosition,n);
assert.deepEqual(event.location,stack.location);
}
assert.equal(new TextDecoder().decode(api.request(`/content/${a}`).body),'original');
assert.deepEqual(api.request(`/local/utxo/${stack.location.txid}:${stack.location.vout}`).body.inscriptions,[a,b,c]);
chain.mine(wallet.script);
const restored = new LocalChain(chain.snapshot());
assert.deepEqual(new MetadataIndex(restored).events,new MetadataIndex(chain).events);
assert.equal(new InscriptionAdapter(restored,wallet).list().find(i=>i.id===b).sat,sat);
});
for (const [pointer, encoded] of [[0,''],[255,'ff'],[256,'0001'],[99999,'9f8601']]) {
test(`pointer ${pointer}: minimal LE encoding and output position/fallback`, () => {
assert.equal(encodePointer(pointer),encoded); assert.equal(decodePointer(encoded),String(pointer));
const {ins,chain} = setup(); const id=ins.inscribe('point',null,undefined,{pointer});
const record=chain.transactions.get(id.slice(0,-2));
assert.equal(readEnvelope(record.tx.vin[0].witness).pointer,String(pointer));
const event=new MetadataIndex(chain).events[0];
assert.equal(event.location.offset,pointer < POSTAGE ? pointer : 0);
assert.equal(event.sat,pointer < POSTAGE ? pointer : 0);
});
}
test('recursive path whitelist rejects all URL, traversal and mutation forms', () => {
const id='a'.repeat(64)+'i0';
for(const path of [`/content/${id}`,`/r/inscription/${id}`,`/r/metadata/${id}`,`/r/parents/${id}`,`/r/children/${id}`,`/r/tx/${'a'.repeat(64)}`,'/r/blockheight','/r/blockhash/2','/r/blockinfo/2',`/r/blockinfo/${'a'.repeat(64)}`]) assert.equal(recursivePath(path),true,path);
for(const path of ['https://example.com','//example.com','/\\example.com','/r/../local/protocols','/r/blockheight?x=1','/r/blockheight#x','/local/metadata-index','/content/%2f','/r/blockheight/','/r/blockheight\n']) assert.equal(recursivePath(path),false,path);
});
test('a real future delegate resolves after its independently funded reveal arrives', () => {
const {chain,ins} = setup(), futureWallet=TestWallet.create();
chain.mine(futureWallet.script);
const future = new LocalChain(chain.snapshot());
const target = new InscriptionAdapter(future,futureWallet).inscribe('arrived later');
const id = ins.inscribe('waiting',null,undefined,{delegate:target});
const api = new LocalOrdAPI(chain);
assert.equal(api.request(`/content/${id}`).status,404);
chain.submitPackage(future.mempool);
assert.equal(new TextDecoder().decode(api.request(`/content/${id}`).body),'arrived later');
});
test('multiple input ranges flow FIFO across outputs and protect inscribed fee tails atomically', () => {
const {chain,wallet,ins}=setup();
const first=ins.inscribe('offset',null,undefined,{pointer:700});
const coin=chain.coins(wallet.script).find(c=>c.inscription?.id===first);
const funding=chain.coins(wallet.script).find(c=>!c.inscription);
const spend=Tx.create({vin:[getVin(coin.txid,coin.vout,coin.value,coin.scriptPubKey),getVin(funding.txid,funding.vout,funding.value,funding.scriptPubKey)],vout:[getVout(500,wallet.script),getVout(funding.value,wallet.script)]});
spend.vin.forEach((input,i)=>{input.witness=[wallet.sign(spend,undefined,i)];});
chain.submitPackage([Tx.encode(spend).hex]);
const sat=chain.transactions.get(first.slice(0,-2)).inscription.sat;
assert.deepEqual(chain.satLocation(sat),{txid:Tx.util.getTxid(spend),vout:1,offset:200});
const moved=chain.coins(wallet.script).find(c=>c.inscription?.id===first);
assert.deepEqual(moved.ranges[0],[500,1000]);
assert.equal(moved.ranges[1][0],1500);
chain.mine(wallet.script);
const before=chain.snapshot();
// Put a late inscribed sat in the fee tail on a separate small output.
const late=ins.inscribe('late',null,undefined,{pointer:999});
const tail=chain.coins(wallet.script).find(c=>c.inscription?.id===late);
const burn=Tx.create({vin:[getVin(tail.txid,tail.vout,tail.value,tail.scriptPubKey)],vout:[getVout(500,wallet.script)]});
burn.vin[0].witness=[wallet.sign(burn)];
const after=chain.snapshot();
assert.throws(()=>chain.submitPackage([Tx.encode(burn).hex]),/fee loss/);
assert.deepEqual(chain.snapshot(),after); assert.ok(after.mempool.length>before.mempool.length);
});
+60 -2
View File
@@ -163,7 +163,7 @@ test('MIME upload, reset only after success, and parent/child navigation', async
test('HTML preview cannot execute, navigate or fetch remote resources', async ({ page }) => {
const remote = [];
page.on('request', r => { if (/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r.url()); });
page.on('request', r => { if (/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r); });
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#mime-type').selectOption('text/html');
await page.locator('#payload').fill('<meta http-equiv="refresh" content="0;url=https://example.com"><script>parent.unwanted=true</script><img src="https://example.com/a"><iframe src="https://example.com"></iframe><p style="background:url(https://example.com/b)" onclick="parent.unwanted=true">safe words</p><a href="https://example.com">link</a>');
@@ -172,7 +172,7 @@ test('HTML preview cannot execute, navigate or fetch remote resources', async ({
await page.locator('#sign').click();
await expect(page.frameLocator('.collectible iframe').locator('p')).toHaveText('safe words');
expect(await page.evaluate(() => window.unwanted)).toBeUndefined();
expect(remote).toEqual([]);
for (const request of remote) await expect.poll(() => request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i);
await expect(page).toHaveURL('http://127.0.0.1:4178/');
});
@@ -256,3 +256,61 @@ test('two tabs race one auto deadline under Web Locks', async ({page,context}) =
await page.locator('#miner-interval').selectOption('0');
await expect(other.locator('#miner-interval')).toHaveValue('0');
});
test('recursive local fetch, delegate identity, external blocking and worker DOM isolation', async ({page}) => {
const remote=[];
page.on('request',r=>{if(/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r);});
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#payload').fill('local recursive body'); await page.locator('#review').click(); await page.locator('#sign').click();
const content=await page.locator('#parent option').nth(1).getAttribute('value');
await page.locator('#mime-type').selectOption('text/html');
await page.locator('#metadata').fill('{"protocol":"recursive","name":"source"}');
await page.locator('#payload').fill(`<p>Isolated recursion</p><script>
const result={id:ord.id,content:await (await fetch('/content/${content}')).text(),height:await (await fetch('/r/blockheight')).json(),metadata:await (await fetch('/r/metadata/'+ord.id)).json()};
try{await fetch('https://example.com/leak');result.external='escaped'}catch{result.external='blocked'}
try{parent.document.body.innerHTML='escaped';result.dom='escaped'}catch{result.dom='blocked'}
try{await new Promise((ok,bad)=>{const x=new XMLHttpRequest();x.open('GET','https://example.com/xhr');x.onload=ok;x.onerror=bad;x.send()});result.xhr='escaped'}catch{result.xhr='blocked'}
try{importScripts('https://example.com/script.js');result.import='escaped'}catch{result.import='blocked'}
ord.render(JSON.stringify(result));
</script>`);
await page.locator('#review').click(); await page.locator('#sign').click();
const source=await page.locator('#parent option').nth(2).getAttribute('value');
const output=page.frameLocator('.collectible iframe').locator('#recursive-output');
await expect(output).toContainText('local recursive body');
await expect(output).toContainText('"height":1');
await expect(output).toContainText('"external":"blocked"');
await expect(output).toContainText('"dom":"blocked"');
await expect(output).toContainText('"xhr":"blocked"');
await expect(output).toContainText('"import":"blocked"');
await expect(output).toContainText(source);
await page.locator('#mime-type').selectOption('text/plain'); await page.locator('#payload').fill('delegating');
await page.locator('#delegate').fill(source); await page.locator('#metadata').fill('{"name":"requested"}');
await page.locator('#review').click(); await page.locator('#sign').click();
const delegated=await page.locator('#parent option').nth(3).getAttribute('value');
const delegatedOutput=page.frameLocator('.collectible:first-child iframe').locator('#recursive-output');
await expect(delegatedOutput).toContainText(delegated);
await expect(delegatedOutput).toContainText('a1646e616d6569726571756573746564');
await expect(page.locator('.collectible:first-child')).toContainText('Delegate');
await expect(page.locator('.collectible').nth(1)).toContainText('Delegated by');
// Chromium emits request events for CSP-denied XHR attempts; none reached the network.
for (const request of remote) expect(request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i);
await expect(page.locator('h1')).toContainText('Bitcoin universe');
});
test('recursive template and reinscription stack persist across mining and reload', async ({page}) => {
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#recursive-demo').click(); await page.locator('#review').click(); await page.locator('#sign').click();
const id=await page.locator('#parent option').nth(1).getAttribute('value');
await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText(id);
await page.locator('#reinscribe').selectOption(id); await page.locator('#mime-type').selectOption('text/plain');
await page.locator('#payload').fill('second on same sat'); await page.locator('#review').click();
await expect(page.locator('#review-parent')).toContainText(`Reinscribe: ${id}`);
await page.locator('#sign').click();
await expect(page.locator('.collectible')).toHaveCount(2);
await expect(page.locator('.collectible').first()).toContainText('stack position 1');
await expect(page.locator('.collectible').last()).toContainText('stack position 0');
await expect(page.locator('.collectible').first().getByRole('link',{name:/Sat history/})).toHaveCount(2);
await page.locator('#mine').click(); await page.reload();
await expect(page.locator('.collectible')).toHaveCount(2);
await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText('"height": 2');
});