From 5d1a1ae8668baebfdf01dce56870463f0100160c Mon Sep 17 00:00:00 2001 From: recovery Date: Mon, 5 Oct 2026 03:57:01 +0000 Subject: [PATCH] Add delegate recursion and reinscription UI --- README.md | 29 ++++++-- index.html | 6 +- public/recursive-preview.html | 1 + public/recursive-preview.js | 16 ++++ server.js | 8 +- src/main.js | 38 +++++++--- src/preview.js | 19 ++++- src/recursion.js | 44 +++++++++++ tests/adapters.test.js | 135 ++++++++++++++++++++++++++++++++-- tests/flow.spec.js | 62 +++++++++++++++- 10 files changed, 327 insertions(+), 31 deletions(-) create mode 100644 public/recursive-preview.html create mode 100644 public/recursive-preview.js create mode 100644 src/recursion.js diff --git a/README.md b/README.md index f9e8c85..a205daa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.html b/index.html index e7f35b5..acbd58e 100644 --- a/index.html +++ b/index.html @@ -23,8 +23,8 @@
02

Make some blocks

Skip the faucet queue. Every block gives you 50 fake BTC, ready to use immediately.

BLOCK HEIGHT0
PENDING TXS0

Auto mining OFF

Keep a lab tab open; browser throttling may delay blocks. Mining also confirms your pending inscriptions.

-
03

Leave a little something

INSCRIPTION LAB

0 / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only

- +
03

Leave a little something

INSCRIPTION LAB

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.

0 / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only

+

04 / THE THINGS YOU MADE

Your inscriptions 0

Owned by this wallet

Your first inscription belongs here.
Fund your wallet, write some text, and make it yours.

@@ -53,7 +53,7 @@
Choose a path and press Try.
-
Under the hood & attribution

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.

Built from and inspired by Supertestnet’s testnet_generator, inscriptions-online, and vanilla-js-browser-wallet. node_faker was evaluated for future RPC compatibility.

Transaction library: tapscript by Christopher Scott (CC-BY-1.0). Supertestnet CC0 license · noble-curves license · noble-hashes license.

+
Under the hood & attribution

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.

Built from and inspired by Supertestnet’s testnet_generator, inscriptions-online, and vanilla-js-browser-wallet. node_faker was evaluated for future RPC compatibility.

Transaction library: tapscript by Christopher Scott (CC-BY-1.0). Supertestnet CC0 license · noble-curves license · noble-hashes license.

SMALL CHAIN. BIG POSSIBILITIES.
\ No newline at end of file diff --git a/public/recursive-preview.html b/public/recursive-preview.html new file mode 100644 index 0000000..7f78e6a --- /dev/null +++ b/public/recursive-preview.html @@ -0,0 +1 @@ +

diff --git a/public/recursive-preview.js b/public/recursive-preview.js
new file mode 100644
index 0000000..6c0e98b
--- /dev/null
+++ b/public/recursive-preview.js
@@ -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 });
+});
diff --git a/server.js b/server.js
index aea95e3..a064d13 100644
--- a/server.js
+++ b/server.js
@@ -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}`));
diff --git a/src/main.js b/src/main.js
index 41ae5f1..b7b801d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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 }); }
diff --git a/src/preview.js b/src/preview.js
index 82eca03..3653f09 100644
--- a/src/preview.js
+++ b/src/preview.js
@@ -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([``, 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); });
 }
diff --git a/src/recursion.js b/src/recursion.js
new file mode 100644
index 0000000..6a0e148
--- /dev/null
+++ b/src/recursion.js
@@ -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 = `

Recursive local inscription

Live data from this browser's chain:

+`; + +// 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(); +} diff --git a/tests/adapters.test.js b/tests/adapters.test.js index 967b59e..c31022e 100644 --- a/tests/adapters.test.js +++ b/tests/adapters.test.js @@ -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('

terminal

')}); + 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),'

terminal

'); + 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); +}); diff --git a/tests/flow.spec.js b/tests/flow.spec.js index 72f9b1f..2c5f55b 100644 --- a/tests/flow.spec.js +++ b/tests/flow.spec.js @@ -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('

safe words

link'); @@ -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(`

Isolated recursion

`); + 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'); +});