Add CBOR metadata indexes, local API and coordinated auto mining

This commit is contained in:
Codex committed 2026-10-05 02:33:54 +00:00
1 parent 0405452ab2
commit aa39db3879
14 files changed
+599 -54

No files matched your search

+3 -1
View File
@@ -5,7 +5,7 @@ Reviewed 2026-10-04 against the exact upstream revisions below. Upstream reposit
| Upstream | Revision | License | Use in v0 |
| --- | --- | --- | --- |
| [supertestnet/testnet_generator](https://github.com/supertestnet/testnet_generator/tree/498be4cc775ef18e7ff17357d007918518f47b98) | `498be4cc775ef18e7ff17357d007918518f47b98` | CC0-1.0 | `getVin`/`getVout` object helpers from `index.html` adapted into `src/upstream/primitives.js`; converted to ES exports, explicit scripts, explicit final sequence. Inspiration for browser-owned UTXO chain, immediate 50-coin reward, button mining and integrated explorer. The upstream consensus/Script engine and relay system are **not** imported. |
| [supertestnet/inscriptions-online](https://github.com/supertestnet/inscriptions-online/tree/470b8bdf29ead319cff9dc119a005cbf58ee598c) | `470b8bdf29ead319cff9dc119a005cbf58ee598c` | CC0-1.0 | `index.html` ord envelope array adapted in `src/adapters/inscription.js`. Modern tapscript API, locally extended with MIME/byte payloads, explicit data-push encoding, 16 KiB cap, tag-3 parent serialization and parent-preserving child reveals. Single commit/reveal, same-wallet destination, no external broadcast or fee services. These extensions and the safe preview UI are original local adapter code. |
| [supertestnet/inscriptions-online](https://github.com/supertestnet/inscriptions-online/tree/470b8bdf29ead319cff9dc119a005cbf58ee598c) | `470b8bdf29ead319cff9dc119a005cbf58ee598c` | CC0-1.0 | `index.html` ord envelope array adapted in `src/adapters/inscription.js`. Modern tapscript API, locally extended with MIME/byte payloads, explicit data-push encoding, 16 KiB cap, tag-3 parent serialization, parent-preserving child reveals and repeated tag-5 CBOR metadata fields. Single commit/reveal, same-wallet destination, no external broadcast or fee services. These extensions and the safe preview UI are original local adapter code. |
| [supertestnet/vanilla-js-browser-wallet](https://github.com/supertestnet/vanilla-js-browser-wallet/tree/19ae5097f4645bf5b1103a39abc04155a8b072fc) | `19ae5097f4645bf5b1103a39abc04155a8b072fc` | CC0-1.0 | Conceptual inspiration for minimal vanilla-JS wallet and local signing. No copied implementation. |
| [supertestnet/node_faker](https://github.com/supertestnet/node_faker/tree/2fae6056a7e93322a00a4150468934542ddc937d) | `2fae6056a7e93322a00a4150468934542ddc937d` | CC0-1.0 | Evaluated, **not imported**: its bitcoind-like API obtains data through Electrum connections. That introduces an unnecessary external backend for the self-contained v0. `LocalRPC` is a new, intentionally partial in-process read facade, not a claim of node_faker compatibility. |
@@ -18,3 +18,5 @@ Runtime dependencies are pinned in `package.json` and `package-lock.json`:
- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt).
Vite and Playwright are build/test dependencies, not runtime network services. Their installed package notices remain in the locked dependency installation. No unlicensed upstream code was copied.
The bounded JSON/CBOR codec, byte-derived MetadataIndex, trusted event-counter reducer, local API facade and persisted auto-miner schedule are original adapter code. Metadata encoding and endpoint naming were checked against the [Ordinals metadata](https://docs.ordinals.com/inscriptions/metadata.html) and [endpoint](https://docs.ordinals.com/inscriptions/recursion.html) references; no ord source code was copied.
+39 -7
View File
@@ -1,6 +1,6 @@
# Super Testnet v0
A small browser-native Bitcoin playground: create a **test-only** wallet, mine fake BTC, sign a MIME inscription, see it in that wallet’s collectibles, and follow its transactions into a local block.
A small browser-local Bitcoin protocol lab: create a **test-only** wallet, mine fake BTC, sign a MIME inscription, see it in that wallet’s collectibles, and follow its transactions into a local block.
This is a private, single-browser simulation inspired by Supertestnet. It uses real Bitcoin transaction serialization, Schnorr signatures and Taproot commit/reveal envelopes. It is **not Bitcoin Core, public testnet, signet, regtest consensus, an Ord indexer, or production custody**. There is no mainnet mode and no external broadcast path.
@@ -27,9 +27,9 @@ Use localhost or HTTPS: secure-context Web Crypto and Web Locks are required. Ch
## Complete the loop
1. **Create test wallet**, or load a previously saved Super Testnet wallet JSON. Wallet creation never signs a transaction.
2. **Mine a block**: receive 50 fake BTC immediately, without coinbase maturity.
3. Choose text/plain, application/json, text/html, image/svg+xml, image/png, image/jpeg or image/webp. Edit text or upload a local file of up to **16,384 bytes (16 KiB)**. No remote fetching. Optionally select an owned inscription as its immutable parent. **Review inscription** shows the payload and costs without signing.
4. **Sign & inscribe** explicitly signs a commit and a reveal locally. Fees total 1,000 fake sats; 1,000 fake sats remain in the inscription output. Both transactions enter the local mempool atomically. A child reveal also signs the parent input and returns its full value to the same wallet. After successful persistence the content, file, parent, review and byte counter reset; failures preserve the composer.
2. **Mine now**: receive 50 fake BTC immediately, without coinbase maturity.
3. Choose text/plain, application/json, text/html, image/svg+xml, image/png, image/jpeg or image/webp. Edit text or upload a local file of up to **16,384 bytes (16 KiB)**. No remote fetching. Optionally select an owned inscription as its immutable parent. Add arbitrary JSON metadata independently of the body: it becomes real CBOR in repeated ord tag-5 fields, each at most 520 bytes. The app caps metadata at **2,048 CBOR bytes** (64 nesting levels); invalid JSON, non-finite numbers and oversized documents are rejected before signing. Blank metadata is omitted; JSON `null` is a present metadata value. **Review inscription** shows the payload and costs without signing.
4. **Sign & inscribe** explicitly signs a commit and a reveal locally. Fees total 1,000 fake sats; 1,000 fake sats remain in the inscription output. Both transactions enter the local mempool atomically. A child reveal also signs the parent input and returns its full value to the same wallet. After successful persistence the content, file, parent, metadata, review and byte counters reset; failures preserve the composer.
5. The inscription immediately appears as **pending** in this wallet’s collectibles. Mine again to confirm both transactions in one block.
6. Open the inscription, its reveal, its commit, or its block in the integrated explorer. Raw transaction hex and witnesses are visible. Reload to verify persistence.
@@ -37,11 +37,43 @@ Wallet ownership is derived from the unspent reveal output’s script, not a dis
**Save test wallet** exports its tagged test key, not chain history. **Save complete lab** exports both wallet and chain, including pending transactions, and is the portable recovery option. Loading a complete lab replaces local state after an explicit prompt. Clearing site data/resetting the lab destroys local state unless you have a backup. Explorer hash links work only with the matching local chain; they are not public explorer links. A different origin (including localhost vs Fly) has separate browser storage; use a complete lab backup to move it.
## Metadata, local indexes and API
Collectible cards and details show metadata decoded from inscription witnesses. **Metadata is data; indexers are interpretation.** `MetadataIndex` reconstructs every inscription event from confirmed block raw transactions in chain order, followed by raw mempool transactions. Events include id, content type, status, height/block hash, metadata, presence flag and parent. No UI metadata sidecar is persisted. A top-level string `metadata.protocol` is the default discovery convention; other fields and values have no built-in semantics. The Indexes surface shows counts, discovered names and the ordered feed for a selected protocol.
The optional `event-counter` reducer derives counts and ordered ids from only the selected protocol's events, displayed separately. Reducers are trusted application code registered above the base index, never downloaded or executed from inscriptions. Adding a reducer requires a source-code change; this is a seam for future metaprotocol experiments, not a sidechain consensus engine. Native ord tag 7 is a separate future compatibility feature and is not conflated with `metadata.protocol`.
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.
- `/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/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.
## Mining at your pace
The miner defaults **OFF**, with 10s / 30s / 60s choices and **Mine now**. Preference and next deadline persist in lab state/backups; old backups default OFF. Auto and manual mining use the same Web Locks → read latest lab → mutate → atomic save path. Auto mining rechecks the persisted deadline inside the lock, so tabs sharing the origin cannot mine the same scheduled block. Manual mining resets the next deadline. Each block clears pending commit/reveal packages naturally.
A tab must stay open; background throttling/suspension can delay mining. Reopening an overdue lab mines at most one block, then starts a fresh interval, without a catch-up burst. Auto mining signs nothing. A failed save publishes no partial state and the UI reports the error.
## Planned backend progression
**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.
## Architecture
- `src/adapters/wallet.js`: test-wallet creation/loading, regtest-format `bcrt1p` address and explicit signing primitive. Only tagged v0 test backups can be imported; there is no arbitrary production-key input.
- `src/adapters/chain.js`: instant 50-BTC coinbase, UTXO set, package validation, mempool, synthetic blocks and replayable snapshots. Validates transaction shape, values, spent inputs, default Schnorr signatures and the supported Taproot script/control block. Exposes `mine`, `submitPackage`, `coins`, `balance`, `snapshot`.
- `src/adapters/inscription.js`: MIME/byte envelope with optional tag 3, commit/reveal construction, confirmation/ownership view. `prepare` does not sign; `inscribe` is invoked only after the user’s explicit signing action.
- `src/adapters/inscription.js`: MIME/byte envelope with optional parent tag 3 and chunked CBOR metadata tag 5, commit/reveal construction, confirmation/ownership view. `prepare` does not sign; `inscribe` is invoked only after the user’s explicit signing action.
- `src/adapters/metadata.js`, `metadata-index.js`, `local-ord-api.js`: bounded JSON/CBOR codec, byte-derived raw index and trusted reducer boundary, in-process read API.
- `src/miner.js`: persisted schedule and deadline checks, invoked through `mutateLab` in storage for both mining modes.
- `src/upstream/primitives.js`: small attributed adaptations of Supertestnet’s input/output helpers. See [provenance](PROVENANCE.md).
- `LocalRPC`: in-process read facade for `getblockcount`, `getblockhash`, `getblock`, `getrawtransaction`. It is deliberately partial, not wire-compatible bitcoind JSON-RPC. A future Core or node_faker adapter can replace this boundary.
- `src/storage.js`: versioned `localStorage` snapshot; index/UTXO reconstruction with transaction validation on load. One atomic storage write per operation; failed validation/quota writes do not publish partial in-memory state. Web Locks serialize mutations across tabs; storage events refresh other tabs.
@@ -60,7 +92,7 @@ Wallet ownership is derived from the unspent reveal output’s script, not a dis
## Verification
Verified for this extension: `npm test` passed all 13 adapter tests, `npm run build` passed, and Playwright passed all 14 desktop/mobile tests against the production server. The minimal worker required Chromium, runtime libraries and fonts staged under `/tmp`; no system packages or deployment were changed. Run:
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:
```sh
npm test
@@ -70,7 +102,7 @@ npm run test:e2e
The adapter suite covers ownership, real signed commit/reveal construction, confirmation, RPC inspection, pending/confirmed restoration, malformed imports, MIME/byte round-trips and size limits, parent ownership/preservation, fraudulent tags, untagged parent spends, nested lineage, bad signatures, modified payload/control blocks, over-spending, atomic rollback, double-spending and collectible protection.
Playwright tests the **production build/server**, at desktop and Pixel 7 mobile sizes: full loop, review-before-signing, pending persistence, transaction/block navigation, complete backup/reset/restore, wallet imports, invalid input, two-tab concurrent mining, composer reset and failure preservation, non-text uploads, parent navigation, sandbox preview safety, no third-party requests and no horizontal overflow. Screenshots/traces are written to ignored `test-results/`.
Playwright tests the **production build/server**, at desktop and Pixel 7 mobile sizes: full loop, review-before-signing, pending persistence, transaction/block navigation, complete backup/reset/restore, wallet imports, invalid input, two-tab concurrent mining, composer reset and failure preservation, non-text uploads, parent navigation, sandbox preview safety, no third-party requests and no horizontal overflow. Additional tests cover CBOR vectors/chunking/caps, sidecar-independent metadata indexing, protocol grouping/reducer ordering, local API reads, miner persistence/countdown/manual reset and two-tab deadline races. Screenshots/traces are written to ignored `test-results/`.
## Independent Fly deployment
+17 -3
View File
@@ -22,9 +22,9 @@
<div id="active-wallet" hidden><p class="label">FAKE BTC BALANCE</p><div class="balance"><span id="balance">0</span><small>BTC</small></div><p id="reserved" class="muted"></p><label for="address">Local regtest-format address</label><input id="address" readonly><button id="export-wallet" class="quiet">↓ Save test wallet</button></div>
<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 a block <span>+</span></button><p class="muted">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><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><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"><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>
</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>
@@ -39,6 +39,20 @@
</div>
<article id="inspector" class="panel inspector" hidden tabindex="-1"></article>
</section>
<section class="panel" id="indexes"><h2>Indexes</h2>
<p>Metadata is data; indexers are interpretation. Discovery uses the top-level metadata.protocol string. Raw events remain uninterpreted.</p>
<p id="index-stats"></p><label for="protocol">Discovered protocol names</label><select id="protocol"></select>
<p id="protocol-stats"></p><h3>Ordered raw events</h3><p class="muted">Confirmed blocks in chain order, then mempool order.</p><div id="protocol-events"></div>
<label for="reducer">Optional trusted local indexer</label><select id="reducer"><option value="">None</option><option value="event-counter">Example: event-counter</option></select>
<h3>Derived state (separate from raw events)</h3><pre id="derived-state">No reducer selected.</pre>
</section>
<section class="panel" id="api-explorer"><h2>API explorer <span class="tag">LOCAL / IN-BROWSER</span></h2>
<p>LocalOrdAPI reads this browser’s chain and indexes. These paths are not server URLs; the static host cannot access your lab.</p>
<label for="api-preset">Preset path</label><select id="api-preset"></select>
<label for="api-path">Local API path</label><input id="api-path" value="/r/blockheight">
<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>
<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>
+1 -1
View File
@@ -75,7 +75,7 @@ export class LocalChain {
}
config = { pubkey: parsed.pubkey };
inscription = { id: `${txid}i0`, txid, commitTxid: input.txid, text: parsed.text,
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, height, value: POSTAGE };
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, metadata: parsed.metadata, height, value: POSTAGE };
} else assert(tx.vin.length === 1, 'v0 protects inscription outputs: only explicit child reveals can spend a parent.');
tx.vin.forEach((input, i) => {
let valid = false;
+25 -11
View File
@@ -1,3 +1,4 @@
import { encodeMetadata, decodeMetadata } from './metadata.js';
import { Script, Tap, Tx } from '@cmdcode/tapscript';
import { getVin, getVout } from '../upstream/primitives.js';
@@ -37,8 +38,9 @@ function parseParent(value) {
}
// Envelope adapted from inscriptions-online at 470b8bdf29ead319cff9dc119a005cbf58ee598c (CC0).
// Local extension: MIME bytes, 520-byte pushes, optional ord parent tag 3.
export function envelope(pubkey, value, parent = null) {
export function envelope(pubkey, value, parent = null, metadata = undefined) {
const { contentType, bytes } = body(value);
const metadataBytes = metadata === undefined ? undefined : encodeMetadata(metadata);
const chunks = [];
for (let i = 0; i < bytes.length; i += 520) chunks.push(hex(bytes.slice(i, i + 520)));
// tapscript's generic encoder normalizes one-byte data into numbers (and can
@@ -50,28 +52,40 @@ export function envelope(pubkey, value, parent = null) {
: '4d' + (size % 256).toString(16).padStart(2, '0') + Math.floor(size / 256).toString(16).padStart(2, '0')) + value;
};
return push(pubkey) + 'ac0063' + push(textHex('ord')) + push('01') + push(textHex(contentType)) +
(parent ? push('03') + push(serializeParent(parent)) : '') + '00' + chunks.map(push).join('') + '68';
(parent ? push('03') + push(serializeParent(parent)) : '') +
(metadataBytes === undefined ? '' : Array.from({ length: Math.ceil(metadataBytes.length / 520) }, (_, i) =>
push('05') + push(hex(metadataBytes.slice(i * 520, (i + 1) * 520)))).join('')) + '00' + chunks.map(push).join('') + '68';
}
export function readEnvelope(witness) {
if (!Array.isArray(witness) || witness.length !== 3) throw new Error('Unsupported script-path witness.');
const parts = Script.decode(witness[1]);
const parent = parts[7] === '03' ? parseParent(parts[8]) : null;
const payload = parts.slice(parent ? 10 : 8, -1);
let cursor = 7, parent = null;
if (parts[cursor] === '03') { parent = parseParent(parts[cursor + 1]); cursor += 2; }
const metadataChunks = [];
while (parts[cursor] === '05') {
const chunk = parts[cursor + 1];
if (typeof chunk !== 'string' || !/^(?:[0-9a-f]{2}){1,520}$/.test(chunk)) throw new Error('Invalid metadata push.');
metadataChunks.push(chunk); cursor += 2;
}
if (parts[cursor] !== 'OP_0') throw new Error('Unsupported ord tag or missing body separator.');
const metadata = metadataChunks.length ? decodeMetadata(unhex(metadataChunks.join(''))) : undefined;
const payload = parts.slice(cursor + 1, -1);
if (!payload.length || payload.some(p => typeof p !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(p))) throw new Error('Invalid body envelope.');
const contentType = new TextDecoder().decode(unhex(parts[6]));
const value = body({ contentType, bytes: unhex(payload.join('')) });
const expected = envelope(parts[0], value, parent);
const expected = envelope(parts[0], value, parent, metadata);
// Read original v0 text snapshots whose content-type tag used OP_1.
const legacy = !parent && contentType === MIME && value.bytes.length <= 280 &&
const legacy = metadata === undefined && !parent && contentType === MIME && value.bytes.length <= 280 &&
Script.encode([parts[0], 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', hex(value.bytes), 'OP_ENDIF'], false).hex;
if (expected !== witness[1] && legacy !== witness[1]) throw new Error('Unsupported ord envelope.');
if (!/^[0-9a-f]{64}$/.test(parts[0])) throw new Error('Invalid inscription signing key.');
return { ...value, parent, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
return { ...value, parent, metadata, metadataBytes: metadataChunks.length ? unhex(metadataChunks.join('')) : undefined, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
}
export class InscriptionAdapter {
constructor(chain, wallet) { this.chain = chain; this.wallet = wallet; }
prepare(value, parentId = null) {
prepare(value, parentId = null, metadata = undefined) {
const payload = body(value);
if (metadata !== undefined) encodeMetadata(metadata);
const coins = this.chain.coins(this.wallet.script);
const parent = parentId ? coins.find(c => c.inscription?.id === parentId) : null;
if (parentId && !parent) throw new Error('Parent must be an owned, unspent inscription.');
@@ -80,9 +94,9 @@ export class InscriptionAdapter {
return { ...payload, coin, parent, totalFee: FEE * 2, postage: POSTAGE };
}
// Called only by the explicit UI “Sign & inscribe” action.
inscribe(value, parentId = null) {
const { coin, parent, ...payload } = this.prepare(value, parentId);
const script = envelope(this.wallet.pubkey, payload, parentId);
inscribe(value, parentId = null, metadata = undefined) {
const { coin, parent, ...payload } = this.prepare(value, parentId, metadata);
const script = envelope(this.wallet.pubkey, payload, parentId, metadata);
const leaf = Tap.encodeScript(script);
const [tapkey, control] = Tap.getPubKey(this.wallet.pubkey, { target: leaf });
const commit = Tx.create({
+51
View File
@@ -0,0 +1,51 @@
import { Tx } from '@cmdcode/tapscript';
import { readEnvelope } from './inscription.js';
import { MetadataIndex } from './metadata-index.js';
// In-process request/response seam; these paths are NOT HTTP routes on the static host.
export class LocalOrdAPI {
constructor(chain, index = null) { this.chain = chain; this.index = index; }
request(path) {
const response = (body, contentType = 'application/json', status = 200) => ({ status, contentType, body });
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return response({ error: 'Use a local absolute path.' }, undefined, 400);
const url = new URL(path, 'https://local.invalid'), parts = url.pathname.split('/').filter(Boolean);
const [root, kind, key] = parts;
const chain = this.chain, index = this.index ?? new MetadataIndex(chain);
const blockInfo = block => ({ height: block.height, hash: block.hash, previous: block.previous,
time: block.time, txids: block.raws.map(raw => Tx.util.getTxid(raw)) });
if (url.pathname === '/local/metadata-index') return response({ ...index.stats, events: index.events });
if (url.pathname === '/local/protocols') return response(index.protocols.map(protocol => {
const events = index.forProtocol(protocol);
return { protocol, events: events.length, confirmed: events.filter(e => e.status === 'confirmed').length,
pending: events.filter(e => e.status === 'pending').length };
}));
if (url.pathname === '/local/protocol') return response(index.forProtocol(url.searchParams.get('name')));
if (url.pathname === '/r/blockheight') return response(chain.blocks.length);
if (root === 'r' && parts.length === 3) {
if (kind === 'blockhash' || kind === 'blockinfo') {
const block = chain.blocks.find(b => String(b.height) === key || kind === 'blockinfo' && b.hash === key);
if (block) return response(kind === 'blockhash' ? block.hash : blockInfo(block));
}
if (kind === 'tx') { const tx = chain.transactions.get(key); if (tx) return response({ txid: tx.txid, hex: tx.raw, height: tx.height }); }
}
const id = root === 'content' && parts.length === 2 ? kind : root === 'r' && parts.length === 3 ? key : null;
const event = index.events.find(e => e.id === id);
if (event) {
if (root === 'content') {
const record = chain.transactions.get(id.slice(0, -2));
const item = readEnvelope(Tx.decode(record.raw).vin[0].witness);
return response(item.bytes, item.contentType);
}
if (kind === 'inscription') return response(event);
// ord's metadata endpoint returns CBOR hex. Expose decoded JSON in inscription/index responses.
if (kind === 'metadata') {
const record = chain.transactions.get(id.slice(0, -2));
const item = readEnvelope(Tx.decode(record.raw).vin[0].witness);
return response(item.metadataBytes ? Array.from(item.metadataBytes, b => b.toString(16).padStart(2, '0')).join('') : null);
}
if (kind === 'parents') return response({ ids: event.parent ? [event.parent] : [] });
if (kind === 'children') return response({ ids: index.events.filter(e => e.parent === id).map(e => e.id) });
}
return response({ error: 'Not found in this local lab.' }, undefined, 404);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Tx } from '@cmdcode/tapscript';
import { readEnvelope } from './inscription.js';
// Rebuild solely from serialized transactions, never collectible/UI sidecars.
// Discovery is a convention, not native ord metaprotocol tag 7 support.
export class MetadataIndex {
constructor(chain) {
this.events = [];
const visit = (raw, height, block) => {
const tx = Tx.decode(raw);
if (tx.vin[0]?.witness?.length !== 3) return;
const item = readEnvelope(tx.vin[0].witness);
const metadata = item.metadata;
this.events.push({ id: `${Tx.util.getTxid(tx)}i0`, contentType: item.contentType,
status: height === null ? 'pending' : 'confirmed', height, block,
metadata: metadata ?? null, hasMetadata: metadata !== undefined, parent: item.parent,
protocol: metadata && !Array.isArray(metadata) && typeof metadata === 'object' &&
Object.hasOwn(metadata, 'protocol') && typeof metadata.protocol === 'string' ? metadata.protocol : null });
};
for (const block of chain.blocks) for (const raw of block.raws) visit(raw, block.height, block.hash);
for (const raw of chain.mempool) visit(raw, null, null);
}
forProtocol(name) { return this.events.filter(e => e.protocol === name); }
get protocols() { return [...new Set(this.events.filter(e => e.protocol !== null).map(e => e.protocol))].sort(); }
get stats() { return { events: this.events.length, withMetadata: this.events.filter(e => e.hasMetadata).length,
confirmed: this.events.filter(e => e.status === 'confirmed').length,
pending: this.events.filter(e => e.status === 'pending').length, protocols: this.protocols }; }
}
// Trusted, application-bundled reducers only. Adding a plugin requires a code change/review.
// No eval, dynamic imports, inscription scripts, or interpretation in MetadataIndex.
const reducers = Object.freeze({
'event-counter': events => events.reduce((state, event) => ({
count: state.count + 1, confirmed: state.confirmed + Number(event.status === 'confirmed'),
pending: state.pending + Number(event.status === 'pending'), ids: [...state.ids, event.id],
}), { count: 0, confirmed: 0, pending: 0, ids: [] }),
});
export function deriveProtocol(index, protocol, reducerName) {
if (!Object.hasOwn(reducers, reducerName)) throw new Error('Unknown trusted local reducer.');
return reducers[reducerName](structuredClone(index.forProtocol(protocol)));
}
+70
View File
@@ -0,0 +1,70 @@
// RFC 8949 CBOR for the JSON data model. No executable code or protocol semantics.
export const MAX_METADATA_BYTES = 2048;
const utf8 = new TextEncoder();
export function encodeMetadata(value) {
const out = [];
const head = (major, n) => {
if (n < 24) out.push(major * 32 + n);
else if (n <= 255) out.push(major * 32 + 24, n);
else if (n <= 65535) out.push(major * 32 + 25, n >>> 8, n & 255);
else if (n <= 0xffffffff) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); out.push(major * 32 + 26, ...b); }
else { out.push(major * 32 + 27); const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n)); out.push(...b); }
};
const write = (v, depth = 0) => {
if (depth > 64) throw new Error('Metadata nesting exceeds 64 levels.');
if (v === null) out.push(246);
else if (typeof v === 'boolean') out.push(v ? 245 : 244);
else if (typeof v === 'number' && Number.isFinite(v)) {
if (Number.isSafeInteger(v) && !Object.is(v, -0)) head(v >= 0 ? 0 : 1, v >= 0 ? v : -1 - v);
else { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); out.push(251, ...b); }
} else if (typeof v === 'string') { const b = utf8.encode(v); head(3, b.length); for (const byte of b) out.push(byte); }
else if (Array.isArray(v)) { head(4, v.length); v.forEach(x => write(x, depth + 1)); }
else if (typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype) {
const entries = Object.entries(v); head(5, entries.length);
for (const [k, x] of entries) { write(k, depth + 1); write(x, depth + 1); }
} else throw new Error('Metadata must contain only JSON values.');
if (out.length > MAX_METADATA_BYTES) throw new Error(`Metadata must be at most ${MAX_METADATA_BYTES} CBOR bytes.`);
};
write(value); return Uint8Array.from(out);
}
export function parseMetadataJSON(text) {
if (!text.trim()) return undefined;
let value;
try { value = JSON.parse(text); } catch { throw new Error('Invalid metadata JSON.'); }
encodeMetadata(value); return value;
}
export function decodeMetadata(bytes) {
bytes = new Uint8Array(bytes);
if (!bytes.length || bytes.length > MAX_METADATA_BYTES) throw new Error('Invalid CBOR metadata size.');
let offset = 0;
const take = n => { if (offset + n > bytes.length) throw new Error('Truncated CBOR metadata.'); const b = bytes.slice(offset, offset + n); offset += n; return b; };
const read = (depth = 0) => {
if (depth > 64) throw new Error('Metadata nesting exceeds 64 levels.');
const first = take(1)[0], major = first >> 5, ai = first & 31;
if (major === 7) {
if (ai === 20) return false; if (ai === 21) return true; if (ai === 22) return null;
if (ai === 27) { const n = new DataView(take(8).buffer).getFloat64(0); if (Number.isFinite(n)) return n; }
throw new Error('Unsupported CBOR value.');
}
let n = ai;
if (ai >= 24 && ai <= 27) { n = 0; for (const b of take(2 ** (ai - 24))) n = n * 256 + b; }
else if (ai >= 28) throw new Error('Unsupported CBOR length.');
if (!Number.isSafeInteger(n)) throw new Error('CBOR integer exceeds JSON precision.');
if (major === 0) return n;
if (major === 1) return -1 - n;
if (major === 3) return new TextDecoder('utf-8', { fatal: true }).decode(take(n));
if ((major === 4 || major === 5) && n > bytes.length) throw new Error('Invalid CBOR collection length.');
if (major === 4) return Array.from({ length: n }, () => read(depth + 1));
if (major === 5) {
const result = {};
for (let i = 0; i < n; i++) {
const key = read(depth + 1);
if (typeof key !== 'string' || Object.hasOwn(result, key)) throw new Error('Invalid CBOR JSON map key.');
Object.defineProperty(result, key, { value: read(depth + 1), enumerable: true, writable: true, configurable: true });
}
return result;
}
throw new Error('Unsupported CBOR JSON type.');
};
const value = read(); if (offset !== bytes.length) throw new Error('Trailing CBOR metadata.'); return value;
}
+80 -21
View File
@@ -1,9 +1,13 @@
import './style.css';
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
import { LocalOrdAPI } from './adapters/local-ord-api.js';
import { mineLab, setMiner, minerStatus } from './miner.js';
import { TestWallet } from './adapters/wallet.js';
import { LocalRPC } from './adapters/chain.js';
import { InscriptionAdapter, MAX_BYTES, MIME_TYPES } from './adapters/inscription.js';
import { preview, clearPreviews } from './preview.js';
import { STORAGE_KEY, readLab, saveLab, decodeLab, encodeLab } from './storage.js';
import { STORAGE_KEY, readLab, mutateLab, decodeLab, encodeLab } from './storage.js';
const $ = id => document.getElementById(id);
const btc = sats => (sats / 1e8).toFixed(8);
@@ -47,13 +51,13 @@ function download(name, contents) {
function closeReview() {
reviewed = null; $('review-panel').hidden = true;
clearPreviews($('review-preview')); $('review-preview').replaceChildren();
$('review-text').textContent = ''; $('review-parent').textContent = '';
$('review-metadata').textContent = ''; $('review-text').textContent = ''; $('review-parent').textContent = '';
}
const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileBytes ?? new TextEncoder().encode($('payload').value) });
function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
function resetComposer() {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
$('parent').value = ''; closeReview(); updateCount();
$('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
}
function lineage(item, chain) {
const p = el('p', undefined, 'tx-links');
@@ -64,6 +68,54 @@ function lineage(item, chain) {
}
function updateMetadataCount() {
try {
const value = parseMetadataJSON($('metadata').value);
$('metadata-count').textContent = `${value === undefined ? 0 : encodeMetadata(value).length} / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only`;
} catch (error) { $('metadata-count').textContent = `${error.message} Limit: 2048 CBOR bytes.`; }
}
function metadataView(item) {
const node = el('div', undefined, 'metadata-document');
if (item.metadata !== undefined) node.append(el('strong', 'Decoded metadata'), el('pre', JSON.stringify(item.metadata, null, 2)));
return node;
}
function renderIndexes(chain) {
if (!chain) return;
const index = new MetadataIndex(chain), selected = $('protocol').value;
const stats = index.stats;
$('index-stats').textContent = `Metadata index: ${stats.events} events · ${stats.withMetadata} with metadata · ${stats.confirmed} confirmed · ${stats.pending} pending · ${stats.protocols.length} protocols`;
$('protocol').replaceChildren(new Option('Select a protocol', ''));
index.protocols.forEach(name => $('protocol').append(new Option(JSON.stringify(name), JSON.stringify(name))));
if ([...$('protocol').options].some(o => o.value === selected)) $('protocol').value = selected;
const protocol = $('protocol').value ? JSON.parse($('protocol').value) : null;
const events = protocol === null ? [] : index.forProtocol(protocol);
$('protocol-stats').textContent = `${events.length} events · ${events.filter(e => e.status === 'confirmed').length} confirmed · ${events.filter(e => e.status === 'pending').length} pending`;
$('protocol-events').replaceChildren();
for (const event of events) {
const row = el('article'); row.append(link(short(event.id), `#inscription/${event.id}`), el('pre', JSON.stringify(event, null, 2)));
$('protocol-events').append(row);
}
$('derived-state').textContent = !$('reducer').value ? 'No reducer selected.' : protocol === null ? 'Select a protocol first.' : JSON.stringify(deriveProtocol(index, protocol, $('reducer').value), null, 2);
}
$('protocol').onchange = $('reducer').onchange = () => renderIndexes(lab.chain);
const apiPresets = ['/r/blockheight', '/r/blockhash/1', '/r/blockinfo/1', '/r/blockinfo/:hash', '/content/:id', '/r/inscription/:id', '/r/metadata/:id', '/r/parents/:id', '/r/children/:id', '/r/tx/:txid', '/local/metadata-index', '/local/protocols', '/local/protocol?name=example'];
apiPresets.forEach(path => $('api-preset').append(new Option(path, path)));
$('api-preset').onchange = () => {
const event = new MetadataIndex(lab.chain).events.at(-1);
$('api-path').value = $('api-preset').value.replace(':id', event?.id ?? ':id').replace(':txid', event?.id.slice(0, -2) ?? ':txid').replace(':hash', lab.chain.blocks.at(-1)?.hash ?? ':hash');
};
$('api-try').onclick = () => {
try {
const response = new LocalOrdAPI(lab.chain).request($('api-path').value);
const binary = response.body instanceof Uint8Array;
$('api-response').textContent = JSON.stringify({ ...response, ...(binary ? { body: Array.from(response.body), encoding: 'byte array', textPreview: new TextDecoder().decode(response.body) } : {}) }, null, 2);
} catch (error) { $('api-response').textContent = error.message; }
};
$('api-copy').onclick = async () => {
try { await navigator.clipboard.writeText($('api-response').textContent); message('Local API response copied.'); }
catch (error) { message(`Could not copy response: ${error.message}`, true); }
};
function renderMempool(chain) {
const pane = $('mempool-list');
pane.replaceChildren();
@@ -107,8 +159,11 @@ function render() {
$('wallet-state').textContent = wallet ? 'LOCAL / TEST ONLY' : 'NOT CREATED';
$('create-wallet').disabled = disabled; $('load-wallet').disabled = disabled;
$('mine').disabled = disabled || !wallet;
$('miner-interval').disabled = disabled || !wallet;
$('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']) $(id).disabled = disabled || !wallet || fileLoading;
for (const id of ['mime-type', 'body-file', 'parent', 'metadata']) $(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;
@@ -140,8 +195,9 @@ function render() {
const trail = el('p', undefined, 'tx-links');
trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
if (item.height) trail.append(link(`View block ${item.height} ↗`, `#block/${chain.blocks[item.height - 1].hash}`));
card.append(lineage(item, chain), trail); $('collectibles').append(card);
card.append(metadataView(item), lineage(item, chain), trail); $('collectibles').append(card);
}
renderIndexes(chain);
renderMempool(chain);
renderBlocks(chain);
inspect(false);
@@ -151,13 +207,8 @@ async function mutate(action, success, onSuccess = () => {}) {
if (busy || failed) return;
busy = true; render();
try {
if (!navigator.locks) throw new Error('This app needs a secure-context browser with Web Locks. Use localhost or HTTPS.');
await navigator.locks.request(STORAGE_KEY, async () => {
const candidate = readLab();
await action(candidate);
saveLab(candidate); lab = candidate;
});
closeReview(); onSuccess(); message(success);
const result = await mutateLab(action); lab = result.lab;
if (result.changed) { closeReview(); onSuccess(); message(success); }
} catch (error) { message(error.message, true); }
finally { busy = false; render(); }
}
@@ -167,10 +218,17 @@ $('create-wallet').onclick = () => mutate(next => {
if (next.wallet) throw new Error('A wallet already exists in this browser. Reload to use it.');
next.wallet = TestWallet.create();
}, 'Test wallet created. Next: mine a block to get 50 fake BTC.');
$('mine').onclick = () => mutate(next => {
if (!next.wallet) throw new Error('Create or load a wallet first.');
next.chain.mine(next.wallet.script);
}, 'Block mined. Pending transactions moved from the mempool into your newest block.');
const mine = automatic => mutate(next => mineLab(next, automatic), 'Block mined. Pending transactions moved from the mempool into your newest block.');
$('mine').onclick = () => mine(false);
$('miner-interval').onchange = () => {
const interval = Number($('miner-interval').value);
mutate(next => setMiner(next, interval), interval ? `Auto miner set to ${interval}s.` : 'Auto mining OFF');
};
setInterval(() => {
$('miner-status').textContent = minerStatus(lab);
if (!busy && !failed && lab?.wallet && lab.miner.interval && Date.now() >= lab.miner.nextAt) mine(true);
}, 250);
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
$('parent').onchange = closeReview;
@@ -194,13 +252,14 @@ $('body-file').onchange = async event => {
};
$('review').onclick = () => {
try {
const payload = composerBody(), parent = $('parent').value || null;
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent);
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
closeReview();
reviewed = { payload, parent, address: lab.wallet.address };
reviewed = { payload, parent, metadata, 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-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.');
} catch (error) { message(error.message, true); }
@@ -211,7 +270,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);
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata);
}, '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));
@@ -277,7 +336,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), lineage(record.inscription, chain));
pane.append(preview(record.inscription), metadataView(record.inscription), lineage(record.inscription, chain));
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
}
pane.append(el('h3', 'Inputs → outputs'));
+23
View File
@@ -0,0 +1,23 @@
export const MINER_INTERVALS = [0, 10, 30, 60];
export function minerPreference(value = { interval: 0, nextAt: null }) {
if (!value || !MINER_INTERVALS.includes(value.interval) ||
(value.interval === 0 ? value.nextAt !== null : !Number.isSafeInteger(value.nextAt) || value.nextAt <= 0)) throw new Error('Invalid miner preference.');
return { interval: value.interval, nextAt: value.nextAt };
}
export function setMiner(lab, interval, now = Date.now()) {
lab.miner = minerPreference({ interval, nextAt: interval ? now + interval * 1000 : null });
}
export function minerStatus(lab, now = Date.now()) {
if (!lab?.wallet) return 'Create or load a wallet to mine.';
if (!lab.miner?.interval) return 'Auto mining OFF';
return `Next block in ${Math.max(0, Math.ceil((lab.miner.nextAt - now) / 1000))}s · ${lab.miner.interval}s interval`;
}
// Called inside the shared mutation lock, after reading latest state. One overdue
// block at most; inactive/suspended tabs do not accumulate catch-up rewards.
export function mineLab(lab, automatic = false, now = Date.now()) {
if (!lab.wallet) { if (automatic) return false; throw new Error('Create or load a wallet first.'); }
if (automatic && (!lab.miner?.interval || now < lab.miner.nextAt)) return false;
lab.chain.mine(lab.wallet.script);
setMiner(lab, lab.miner?.interval ?? 0, now);
return true;
}
+15 -3
View File
@@ -1,18 +1,30 @@
import { minerPreference } from './miner.js';
import { LocalChain } from './adapters/chain.js';
import { TestWallet, NETWORK } from './adapters/wallet.js';
export const STORAGE_KEY = 'super-testnet:lab:v1';
export function decodeLab(raw) {
if (!raw) return { wallet: null, chain: new LocalChain() };
if (!raw) return { wallet: null, chain: new LocalChain(), miner: minerPreference() };
const data = JSON.parse(raw);
if (data.kind !== 'test-lab' || data.network !== NETWORK || data.version !== 1) throw new Error('Unsupported lab backup.');
return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain) };
return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain), miner: minerPreference(data.miner) };
}
export const encodeLab = lab => JSON.stringify({
kind: 'test-lab', network: NETWORK, version: 1,
wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(),
wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(), miner: minerPreference(lab.miner),
});
export const readLab = () => decodeLab(localStorage.getItem(STORAGE_KEY));
export function saveLab(lab) {
try { localStorage.setItem(STORAGE_KEY, encodeLab(lab)); }
catch { throw new Error('Browser storage is full or unavailable. No changes were saved. Export a lab backup before clearing storage.'); }
}
// Shared by manual mining, auto mining, and all other lab mutations.
export async function mutateLab(action, { locks = navigator.locks, read = readLab, save = saveLab } = {}) {
if (!locks) throw new Error('This app needs a secure-context browser with Web Locks. Use localhost or HTTPS.');
return locks.request(STORAGE_KEY, async () => {
const candidate = read();
const changed = await action(candidate);
if (changed !== false) save(candidate);
return { lab: candidate, changed: changed !== false };
});
}
+7
View File
@@ -1 +1,8 @@
:root{font-family:Arial,Helvetica,sans-serif;font-synthesis:none;--orange:#f7931a;--orange-2:#ffad42;--bg:#0f1113;--panel:#171a1e;--panel-2:#20242a;--text:#f3f5f7;--muted:#9ca4ad;--line:#30363d;--soft:#23272e;--danger:#ff9b8d;color:var(--text);background:var(--bg)}:root[data-theme="light"]{--bg:#f5f3ef;--panel:#fffdf9;--panel-2:#f0ece5;--text:#1a1a1a;--muted:#6f6a63;--line:#d8d1c8;--soft:#ece7df;--danger:#a54536}*{box-sizing:border-box}html{background:var(--bg);color-scheme:dark}html[data-theme="light"]{color-scheme:light}body{margin:0;background:var(--bg);color:var(--text);transition:background .18s,color .18s}a{color:var(--orange);text-underline-offset:4px}button,input,textarea{font:inherit}.file-button,button{cursor:pointer}button{border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--text);padding:12px 17px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}button:hover:not(:disabled){border-color:var(--orange);transform:translateY(-1px)}button:disabled{opacity:.4;cursor:not-allowed;transform:none}button.primary{background:var(--orange);color:#1c1207;border-color:var(--orange)}button.primary:hover:not(:disabled){background:var(--orange-2)}.primary span{float:right;margin-left:20px}.quiet{background:transparent;border-color:var(--line);font-size:13px}button:focus-visible,a:focus-visible,summary:focus-visible,.file-button:focus-visible{outline:3px solid var(--orange-2);outline-offset:4px}header{max-width:1240px;margin:auto;padding:24px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{text-decoration:none;color:var(--text);display:flex;align-items:center;gap:10px;font-size:15px;font-weight:800;letter-spacing:1px}.mark{display:inline-block;flex-shrink:0;background:var(--orange);width:34px;height:34px;border-radius:8px}.version{font:11px monospace;background:var(--soft);color:var(--orange);border-radius:4px;padding:3px 5px}.header-actions{display:flex;align-items:center;gap:14px}.network{font:11px monospace;color:var(--muted);letter-spacing:1px}.network i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--orange);margin-right:6px}.theme-toggle{padding:8px 10px;font:11px monospace}main{max-width:1176px;padding:0 0 10px;margin:auto}.hero{display:flex;justify-content:space-between;align-items:center;padding:53px 0 36px}.eyebrow{font:11px monospace;letter-spacing:1.8px;color:var(--orange);margin:0 0 19px}h1{font-size:clamp(42px,5.5vw,70px);font-weight:500;line-height:1.02;letter-spacing:-3px;margin:0 0 24px}.intro{color:var(--muted);font-size:16px;line-height:1.65}.orbit{width:255px;height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:36px;background:radial-gradient(ellipse,color-mix(in srgb,var(--orange) 16%,transparent),transparent 70%);margin-right:65px}.cube{display:grid;place-items:center;width:110px;height:110px;border:1px solid var(--orange);box-shadow:13px 13px 0 color-mix(in srgb,var(--orange) 18%,var(--panel)),14px 14px 0 color-mix(in srgb,var(--orange) 48%,var(--panel));transform:rotate(-12deg);background:var(--panel-2);color:var(--orange)}.orbit span{font:10px monospace;letter-spacing:2px;color:var(--muted)}.notice{background:color-mix(in srgb,var(--orange) 7%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 24%,var(--line));border-radius:8px;padding:14px 18px;font-size:12px;color:var(--muted);line-height:1.6}.notice strong{color:var(--text)}#status{min-height:52px;padding:16px 2px;font-size:13px;color:var(--orange)}#status.error{color:var(--danger)}.workspace{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;min-width:0}.panel-title{display:flex;align-items:center;gap:10px;margin-bottom:22px}.panel-title h2{font-size:17px;margin:0;font-weight:500;letter-spacing:-.4px}.step{font:11px monospace;color:var(--orange);background:color-mix(in srgb,var(--orange) 10%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 28%,var(--line));border-radius:6px;padding:7px}.tag{margin-left:auto;font:9px monospace;letter-spacing:1px;color:var(--muted);border:1px solid var(--line);padding:5px;border-radius:4px}.panel p{line-height:1.6;font-size:14px;color:var(--muted)}.label{display:block;font:10px monospace!important;letter-spacing:1.5px;color:var(--muted);margin:0 0 10px}.balance{display:flex;align-items:baseline;gap:12px;font-size:37px;letter-spacing:-1px;font-variant-numeric:tabular-nums}.balance small{color:var(--orange);font-size:13px;letter-spacing:1px}.muted{color:var(--muted)!important;font-size:12px!important;line-height:1.65}label{display:block;font-size:12px;color:var(--muted);margin:14px 0 9px}input,textarea{width:100%;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:12px;outline:none}input{font:11px monospace}textarea{resize:vertical;min-height:100px;line-height:1Line truncated
#indexes, #api-explorer { margin-top: 2rem; }
#protocol-events { max-height: 30rem; overflow: auto; }
#protocol-events article { padding: 1rem 0; border-bottom: 1px solid var(--line); }
#api-path { width: 100%; margin-bottom: 1rem; }
#api-response, #derived-state, .metadata-document pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto; }
#metadata { width: 100%; }
+139
View File
@@ -189,3 +189,142 @@ test('child reveal requires a valid parent-input signature', () => {
assert.throws(() => chain.submitPackage([commit, Tx.encode(reveal).hex]), /signature/);
assert.deepEqual(chain.snapshot(), before);
});
import { encodeMetadata, decodeMetadata, parseMetadataJSON, MAX_METADATA_BYTES } from '../src/adapters/metadata.js';
import { readEnvelope } from '../src/adapters/inscription.js';
import { MetadataIndex, deriveProtocol } from '../src/adapters/metadata-index.js';
import { LocalOrdAPI } from '../src/adapters/local-ord-api.js';
import { minerPreference, minerStatus, mineLab, setMiner } from '../src/miner.js';
import { mutateLab } from '../src/storage.js';
test('CBOR known vectors, JSON values, nesting safety and optional metadata', () => {
for (const [value, hex] of [[null,'f6'],[true,'f5'],[false,'f4'],[0,'00'],[-1,'20'],[24,'1818'],['hi','626869'],[{a:1},'a1616101'],[[1,2],'820102']]) {
assert.equal(Buffer.from(encodeMetadata(value)).toString('hex'),hex);
assert.deepEqual(decodeMetadata(Buffer.from(hex,'hex')),value);
}
const value = JSON.parse('{"__proto__":{"polluted":true},"values":[-0,1.5,4294967296,-9007199254740991,"🌱",null,true]}');
assert.deepEqual(decodeMetadata(encodeMetadata(value)), value);
assert.equal({}.polluted, undefined);
assert.equal(parseMetadataJSON(' '), undefined);
assert.throws(() => parseMetadataJSON('{bad'), /Invalid metadata JSON/);
assert.throws(() => parseMetadataJSON('1e999'), /JSON values/);
assert.throws(() => parseMetadataJSON(JSON.stringify('x'.repeat(2048))), /2048/);
assert.throws(() => decodeMetadata(Uint8Array.of(0x7a,255,255,255,255)), /Truncated/);
assert.throws(() => decodeMetadata(Uint8Array.of(0,0)), /Trailing/);
assert.throws(() => encodeMetadata(Array.from({ length: 1 }, () => undefined)), /JSON values/);
let nested = 0; for (let i=0;i<66;i++) nested=[nested];
assert.throws(() => encodeMetadata(nested), /nesting/);
});
test('metadata repeated tag 5 chunks reassemble from witness; body, parent and maximum caps independent', () => {
const {ins, chain, wallet} = setup();
const parent = ins.inscribe('parent');
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;
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.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);
assert.equal(encodeMetadata(max).length,MAX_METADATA_BYTES);
ins.inscribe('max', null, max);
// Reject before any signing is invoked, including direct adapter callers.
wallet.sign = () => { throw new Error('Signing must not happen'); };
const before = chain.snapshot();
assert.throws(() => ins.inscribe('bad',null,max+'x'), /2048 CBOR/);
assert.deepEqual(chain.snapshot(),before);
});
test('raw metadata index rebuild and protocol grouping do not interpret semantics; reducer sees only ordered matching events', () => {
const {ins,chain,wallet} = setup();
const a = ins.inscribe('a',null,{protocol:'counter',op:'burn',amount:'not-a-number'});
const b = ins.inscribe('b',null,{protocol:'different',op:'execute',code:'throw new Error()'});
chain.mine(wallet.script);
const c = ins.inscribe('c',a,{protocol:'counter',op:'unknown'});
ins.inscribe('not discovered',null,{nested:{protocol:'hidden'},protocol:42});
ins.inscribe('no metadata');
// Poison/drop derived records: index takes only confirmed raw blocks and mempool.
const rawOnly = {blocks:structuredClone(chain.blocks),mempool:[...chain.mempool],transactions:new Map()};
chain.transactions.get(a.slice(0,-2)).inscription.metadata={protocol:'forged'};
rawOnly.blocks[1].metadata={protocol:'sidecar'};
const index = new MetadataIndex(rawOnly);
assert.deepEqual(index.protocols,['counter','different']);
assert.deepEqual(index.events.slice(0,3).map(e=>e.id),[a,b,c]);
assert.deepEqual(index.stats,{events:5,withMetadata:4,confirmed:2,pending:3,protocols:['counter','different']});
assert.equal(index.events[0].metadata.amount,'not-a-number');
assert.equal(index.events[0].block,chain.blocks[1].hash);
assert.equal(index.events[2].parent,a);
assert.deepEqual(deriveProtocol(index,'counter','event-counter'),{count:2,confirmed:1,pending:1,ids:[a,c]});
assert.throws(()=>deriveProtocol(index,'counter','constructor'),/Unknown trusted/);
const noProtocol = ins.inscribe('null',null,null);
const event=new MetadataIndex(chain).events.find(e=>e.id===noProtocol);
assert.equal(event.hasMetadata,true); assert.equal(event.protocol,null);
});
test('LocalOrdAPI serves exact content, CBOR metadata, lineage, transaction and block lookups', () => {
const {ins,chain,wallet} = setup();
const metadata={protocol:'a / b',n:1};
const parent=ins.inscribe('parent',null,metadata), child=ins.inscribe('child',parent), grandchild=ins.inscribe('nested',child);
chain.mine(wallet.script);
const api=new LocalOrdAPI(chain), get=p=>{const r=api.request(p); assert.equal(r.status,200);return r.body;};
assert.deepEqual(get(`/content/${parent}`),new TextEncoder().encode('parent'));
assert.equal(api.request(`/content/${parent}`).contentType,'text/plain;charset=utf-8');
assert.deepEqual(decodeMetadata(Buffer.from(get(`/r/metadata/${parent}`),'hex')),metadata);
assert.equal(get(`/r/metadata/${child}`),null);
assert.deepEqual(get(`/r/inscription/${parent}`).metadata,metadata);
assert.deepEqual(get(`/r/parents/${child}`),{ids:[parent]});
assert.deepEqual(get(`/r/children/${parent}`),{ids:[child]});
assert.deepEqual(get(`/r/children/${child}`),{ids:[grandchild]});
assert.deepEqual(get(`/r/parents/${parent}`),{ids:[]});
const txid=parent.slice(0,-2);
assert.equal(Tx.util.getTxid(get(`/r/tx/${txid}`).hex),txid);
assert.equal(get('/r/blockheight'),2);
assert.equal(get('/r/blockhash/2'),chain.blocks[1].hash);
assert.deepEqual(get('/r/blockinfo/2'),get(`/r/blockinfo/${chain.blocks[1].hash}`));
assert.ok(get('/r/blockinfo/2').txids.includes(txid));
assert.equal(get('/local/metadata-index').events.length,3);
assert.deepEqual(get('/local/protocols'),[{protocol:'a / b',events:1,confirmed:1,pending:0}]);
assert.deepEqual(get('/local/protocol?name=a%20%2F%20b').map(e=>e.id),[parent]);
for(const path of ['/r/blockhash/0','/r/blockinfo/missing','/content/missing','/r/tx/missing','/unknown']) assert.equal(api.request(path).status,404);
assert.equal(api.request('https://example.com').status,400);
const later = ins.inscribe('later', null, { protocol: 'new' });
assert.equal(get(`/r/inscription/${later}`).status, 'pending');
});
test('miner defaults/persistence, countdown, manual schedule reset, overdue mining and validation', () => {
const {chain,wallet}=setup(); const lab={chain,wallet,miner:minerPreference()};
assert.equal(minerStatus(lab),'Auto mining OFF');
assert.equal(mineLab(lab,true,1000),false);
setMiner(lab,10,1000); assert.equal(minerStatus(lab,2000),'Next block in 9s · 10s interval');
assert.deepEqual(decodeLab(encodeLab(lab)).miner,{interval:10,nextAt:11000});
assert.equal(mineLab(lab,true,10999),false);
assert.equal(mineLab(lab,false,5000),true); assert.equal(lab.miner.nextAt,15000);
new InscriptionAdapter(chain,wallet).inscribe('pending');
assert.equal(mineLab(lab,true,20000),true); assert.equal(chain.mempool.length,0);
assert.equal(lab.miner.nextAt,30000); assert.equal(chain.blocks.length,3);
setMiner(lab,0); assert.equal(lab.miner.nextAt,null);
assert.throws(()=>minerPreference({interval:2,nextAt:10}),/Invalid miner/);
assert.throws(()=>minerPreference({interval:10,nextAt:null}),/Invalid miner/);
assert.deepEqual(decodeLab(null).miner,{interval:0,nextAt:null});
});
test('two auto miners re-read deadline under shared lock; manual mining uses identical path', async () => {
const {chain,wallet}=setup();const initial={chain,wallet};setMiner(initial,10,1000);
let stored=encodeLab(initial),tail=Promise.resolve(),writes=0,lockNames=[];
const io={read:()=>decodeLab(stored),save:lab=>{stored=encodeLab(lab);writes++;},locks:{request(name,action){lockNames.push(name);const next=tail.then(action);tail=next.catch(()=>{});return next;}}};
const result=await Promise.all([mutateLab(lab=>mineLab(lab,true,11000),io),mutateLab(lab=>mineLab(lab,true,11000),io)]);
assert.deepEqual(result.map(r=>r.changed),[true,false]);assert.equal(writes,1);
assert.equal(decodeLab(stored).chain.blocks.length,2);
await mutateLab(lab=>mineLab(lab,false,11500),io);
assert.equal(decodeLab(stored).chain.blocks.length,3);
assert.equal(decodeLab(stored).miner.nextAt,21500);
assert.equal(new Set(lockNames).size,1);
await assert.rejects(mutateLab(lab=>mineLab(lab,false),{...io,save:()=>{throw new Error('quota');}}),/quota/);
assert.equal(decodeLab(stored).chain.blocks.length,3);
});
+88 -7
View File
@@ -5,11 +5,11 @@ test('create → mine → mempool → inscription → block/transaction → relo
page.on('pageerror',e=>errors.push(e.message));
page.on('request',r=>{ if(!r.url().startsWith('http://127.0.0.1:4178')) remote.push(r.url()); });
await page.goto('/');
await expect(page.getByRole('button',{name:'Mine a block'})).toBeDisabled();
await expect(page.getByRole('button',{name:'Mine now'})).toBeDisabled();
await expect(page.locator('#mempool-list')).toContainText('Mempool empty');
await page.getByRole('button',{name:'Create test wallet'}).click();
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
await page.getByRole('button',{name:'Mine a block'}).click();
await page.getByRole('button',{name:'Mine now'}).click();
await expect(page.locator('#balance')).toHaveText('50.00000000');
await expect(page.locator('#blocks .block-card')).toHaveCount(1);
await expect(page.locator('#blocks .block-card').first()).toContainText('HEIGHT 1');
@@ -32,7 +32,7 @@ test('create → mine → mempool → inscription → block/transaction → relo
await expect(page.locator('.collectible pre')).toHaveText(payload);
await expect(page.locator('#pending')).toHaveText('2');
await expect(page.locator('#mempool-list .mempool-card')).toHaveCount(2);
await page.getByRole('button',{name:'Mine a block'}).click();
await page.getByRole('button',{name:'Mine now'}).click();
await expect(page.locator('#mempool-list')).toContainText('Mempool empty');
await expect(page.locator('#blocks .block-card')).toHaveCount(2);
await expect(page.locator('#blocks .block-card').first()).toContainText('HEIGHT 2');
@@ -90,7 +90,7 @@ test('wallet backup reload and rejected oversized or invalid input',async({page}
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
const address=await page.locator('#address').inputValue();
const [download]=await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save test wallet'}).click()]);
await page.getByRole('button',{name:'Mine a block'}).click();
await page.getByRole('button',{name:'Mine now'}).click();
await page.locator('#payload').fill('🌱'.repeat(4097));
await page.getByRole('button',{name:'Review inscription'}).click();
await expect(page.getByRole('status')).toContainText('at most 16384');
@@ -109,7 +109,7 @@ test('two tabs serialize mining and refresh shared storage',async({page,context}
await page.goto('/'); await page.getByRole('button',{name:'Create test wallet'}).click();
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
const other=await context.newPage(); await other.goto('/');
await Promise.all([page.getByRole('button',{name:'Mine a block'}).click(),other.getByRole('button',{name:'Mine a block'}).click()]);
await Promise.all([page.getByRole('button',{name:'Mine now'}).click(),other.getByRole('button',{name:'Mine now'}).click()]);
await expect(page.locator('#height')).toHaveText('2');
await expect(other.locator('#height')).toHaveText('2');
await expect(page.locator('#balance')).toHaveText('100.00000000');
@@ -120,13 +120,13 @@ test('storage failure leaves the displayed and persisted chain unchanged', async
await page.getByRole('button',{name:'Create test wallet'}).click();
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
await page.evaluate(()=>{ Storage.prototype.setItem = () => { throw new DOMException('Quota exceeded','QuotaExceededError'); }; });
await page.getByRole('button',{name:'Mine a block'}).click();
await page.getByRole('button',{name:'Mine now'}).click();
await expect(page.getByRole('status')).toContainText('No changes were saved');
await expect(page.locator('#height')).toHaveText('0');
await expect(page.locator('#balance')).toHaveText('0.00000000');
await page.reload();
await expect(page.locator('#height')).toHaveText('0');
await page.getByRole('button',{name:'Mine a block'}).click();
await page.getByRole('button',{name:'Mine now'}).click();
await expect(page.locator('#balance')).toHaveText('50.00000000');
});
test('MIME upload, reset only after success, and parent/child navigation', async ({ page }) => {
@@ -175,3 +175,84 @@ test('HTML preview cannot execute, navigate or fetch remote resources', async ({
expect(remote).toEqual([]);
await expect(page).toHaveURL('http://127.0.0.1:4178/');
});
test('metadata review validation, witness persistence, indexes/reducer and local API explorer', async ({page,context}) => {
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#payload').fill('independent body');
await page.locator('#metadata').fill('{bad'); await page.locator('#review').click();
await expect(page.getByRole('status')).toContainText('Invalid metadata JSON');
await expect(page.locator('#review-panel')).toBeHidden();
await page.locator('#metadata').fill(JSON.stringify({large:'x'.repeat(2048)})); await page.locator('#review').click();
await expect(page.getByRole('status')).toContainText('2048 CBOR');
await expect(page.locator('#pending')).toHaveText('0');
const metadata={protocol:'example',note:'chunked '+ 'x'.repeat(600),nested:{data:true},code:'window.unwanted=true'};
await page.locator('#metadata').fill(JSON.stringify(metadata));
await page.locator('#review').click();
await expect(page.locator('#review-metadata')).toContainText('CBOR, tag 5');
await page.locator('#sign').click();
await expect(page.locator('#metadata')).toHaveValue('');
await expect(page.locator('#metadata-count')).toContainText('0 / 2048');
await expect(page.locator('.metadata-document')).toContainText('example');
await page.reload();
await expect(page.locator('.metadata-document')).toContainText('chunked');
await page.getByRole('link',{name:/^Inscription /}).click();
await expect(page.locator('#inspector .metadata-document')).toContainText('example');
await expect(page.locator('#index-stats')).toContainText('1 events · 1 with metadata · 0 confirmed · 1 pending · 1 protocols');
await page.locator('#protocol').selectOption('"example"');
await expect(page.locator('#protocol-events')).not.toContainText('independent'); // Body is not part of metadata events.
await page.locator('#reducer').selectOption('event-counter');
await expect(page.locator('#derived-state')).toContainText('"count": 1');
await expect(page.locator('#protocol-events')).toContainText('"status": "pending"');
await page.locator('#mine').click();
await expect(page.locator('#protocol-events')).toContainText('"status": "confirmed"');
await expect(page.locator('#derived-state')).toContainText('"confirmed": 1');
await expect(page.locator('#api-explorer')).toContainText('LOCAL / IN-BROWSER');
await page.locator('#api-preset').selectOption('/r/inscription/:id'); await page.locator('#api-try').click();
await expect(page.locator('#api-response')).toContainText('"protocol": "example"');
await page.locator('#api-preset').selectOption('/content/:id'); await page.locator('#api-try').click();
await expect(page.locator('#api-response')).toContainText('independent body');
await page.locator('#api-path').fill('/r/blockheight'); await page.locator('#api-try').click();
await expect(page.locator('#api-response')).toContainText('"body": 2');
await context.grantPermissions(['clipboard-read','clipboard-write']);
await page.locator('#api-copy').click();
expect(await page.evaluate(()=>navigator.clipboard.readText())).toContain('"body": 2');
await page.locator('#api-path').fill('/missing'); await page.locator('#api-try').click();
await expect(page.locator('#api-response')).toContainText('404');
expect(await page.evaluate(()=>window.unwanted)).toBeUndefined();
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy();
});
test('auto miner default, preference reload, countdown, pending confirmation, manual reset and OFF', async ({page}) => {
await page.clock.install();
await page.goto('/'); await page.locator('#create-wallet').click();
await expect(page.locator('#miner-interval')).toHaveValue('0');
await expect(page.locator('#miner-status')).toHaveText('Auto mining OFF');
await page.locator('#miner-interval').selectOption('10');
await expect(page.locator('#miner-status')).toContainText('10s interval');
await page.clock.runFor(10000);
await expect(page.locator('#height')).toHaveText('1');
await page.locator('#payload').fill('auto confirmed'); await page.locator('#review').click(); await page.locator('#sign').click();
await expect(page.locator('#pending')).toHaveText('2');
await page.clock.runFor(10000);
await expect(page.locator('#height')).toHaveText('2'); await expect(page.locator('#pending')).toHaveText('0');
await page.locator('#miner-interval').selectOption('30');
await page.reload(); await expect(page.locator('#miner-interval')).toHaveValue('30');
await page.locator('#miner-interval').selectOption('60');
await page.clock.runFor(5000); await expect(page.locator('#miner-status')).toContainText('55s');
await page.locator('#mine').click(); await expect(page.locator('#height')).toHaveText('3');
await expect(page.locator('#miner-status')).toContainText('60s');
await page.locator('#miner-interval').selectOption('0');
await page.clock.runFor(65000); await expect(page.locator('#height')).toHaveText('3');
});
test('two tabs race one auto deadline under Web Locks', async ({page,context}) => {
const now = new Date('2026-10-05T12:00:00Z');
await page.clock.install({time:now}); await page.goto('/'); await page.locator('#create-wallet').click();
const other=await context.newPage(); await other.clock.install({time:now}); await other.goto('/');
await page.locator('#miner-interval').selectOption('10');
await expect(other.locator('#miner-interval')).toHaveValue('10');
await Promise.all([page.clock.runFor(10000),other.clock.runFor(10000)]);
await expect(page.locator('#height')).toHaveText('1'); await expect(other.locator('#height')).toHaveText('1');
await page.locator('#miner-interval').selectOption('0');
await expect(other.locator('#miner-interval')).toHaveValue('0');
});