Extend local inscription lab with MIME bodies and parent provenance

This commit is contained in:
Codex committed 2026-10-05 01:23:21 +00:00
1 parent 83a19b51ff
commit acd2652046
12 files changed
+379 -91

No files matched your search

+1 -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, text-only UTF-8 payload, small cap, single commit/reveal, same-wallet destination, no external broadcast or fee services. |
| [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/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. |
+11 -10
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 text inscription, see it in that wallet’s collectibles, and follow its transactions into a local block.
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.
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.
@@ -28,12 +28,12 @@ Use localhost or HTTPS: secure-context Web Crypto and Web Locks are required. Ch
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. Enter up to **280 UTF-8 bytes** of non-empty text. **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.
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.
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.
Wallet ownership is derived from the unspent reveal output’s script, not a disconnected UI list. New inscriptions spend ordinary change and never select collectible outputs. Loading another wallet on the same chain displays only that wallet’s collectibles.
Wallet ownership is derived from the unspent reveal output’s script, not a disconnected UI list. Commits spend ordinary change. Child reveals explicitly spend and preserve an owned parent output, tracked at its current outpoint. Parent and children links support nested lineage, including pending ancestors. Loading another wallet on the same chain displays only that wallet’s collectibles.
**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.
@@ -41,7 +41,7 @@ Wallet ownership is derived from the unspent reveal output’s script, not a dis
- `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`: text envelope, 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 tag 3, commit/reveal construction, confirmation/ownership view. `prepare` does not sign; `inscribe` is invoked only after the user’s explicit signing action.
- `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.
@@ -50,8 +50,9 @@ Wallet ownership is derived from the unspent reveal output’s script, not a dis
## 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` text script-path templates 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, 280 UTF-8 bytes, text only. No images, files, BRC-20, recursive inscriptions, sat rarity/ordinal numbering, general inscription discovery, transfer tracking or external Ord compatibility guarantee. Collectible outputs cannot be spent in v0.
- 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.
- 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.
@@ -59,7 +60,7 @@ Wallet ownership is derived from the unspent reveal output’s script, not a dis
## Verification
Verified in this worktree: 6 adapter tests and 8 Playwright tests passed (desktop and mobile), production build passed, and npm audit reported zero vulnerabilities. Browser tests also verify rollback when localStorage writes fail. Screenshots were inspected at both viewport sizes.
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:
```sh
npm test
@@ -67,9 +68,9 @@ npx playwright install --with-deps chromium
npm run test:e2e
```
The adapter suite covers ownership, real signed commit/reveal construction, confirmation, RPC inspection, pending/confirmed restoration, malformed imports, UTF-8 limits, bad signatures, modified payload/control blocks, over-spending, atomic rollback, double-spending and collectible protection.
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, script-like payload 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. Screenshots/traces are written to ignored `test-results/`.
## Independent Fly deployment
+3 -3
View File
@@ -23,8 +23,8 @@
<div class="wallet-footer"><label class="file-button" for="load-wallet">↑ Load test wallet or lab backup</label><input id="load-wallet" type="file" accept=".json,application/json"><p class="muted">Saved on this device. A wallet backup contains its test key; a lab backup also preserves the chain.</p></div>
</article>
<article class="panel"><div class="panel-title"><span class="step">02</span><h2>Make some blocks</h2></div><p>Skip the faucet queue. Every block gives you <strong>50 fake BTC</strong>, ready to use immediately.</p><div class="chain-stats"><div><span class="label">BLOCK HEIGHT</span><strong id="height">0</strong></div><div><span class="label">PENDING TXS</span><strong id="pending">0</strong></div></div><button id="mine" class="primary" disabled>Mine 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">TEXT INSCRIPTION</span></div><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 / 280 UTF-8 bytes</span><button id="review" disabled>Review inscription ↗</button></div>
<div id="review-panel" hidden><h3>Review before signing</h3><pre id="review-text"></pre><p>Two transactions: commit + reveal. <strong>1,000 fake sats</strong> in fees; <strong>1,000 fake sats</strong> reserved in your collectible. It returns to your current wallet.</p><p class="muted">The next button signs both transactions locally with this test wallet and submits them to your private chain.</p><button id="sign" class="primary">Sign & inscribe</button> <button id="cancel-review" class="quiet">Cancel</button></div>
<article class="panel inscribe"><div class="panel-title"><span class="step">03</span><h2>Leave a little something</h2><span class="tag">INSCRIPTION LAB</span></div><label for="mime-type">Content type</label><select id="mime-type"></select><label for="body-file">Or upload a local file (16 KiB maximum)</label><input id="body-file" type="file"><label for="parent">Parent inscription (optional, immutable)</label><select id="parent"><option value="">No parent</option></select><label for="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>
</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,7 +39,7 @@
</div>
<article id="inspector" class="panel inspector" hidden tabindex="-1"></article>
</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. Collectible outputs cannot be spent in v0.</p><p>Built from and inspired by <a href="https://github.com/supertestnet/testnet_generator">Supertestnet’s testnet_generator</a>, <a href="https://github.com/supertestnet/inscriptions-online">inscriptions-online</a>, and <a href="https://github.com/supertestnet/vanilla-js-browser-wallet">vanilla-js-browser-wallet</a>. <a href="https://github.com/supertestnet/node_faker">node_faker</a> was evaluated for future RPC compatibility.</p><p>Transaction library: <a href="https://github.com/cmdruid/tapscript">tapscript by Christopher Scott</a> (<a href="/licenses/tapscript.txt">CC-BY-1.0</a>). <a href="/licenses/supertestnet-CC0.txt">Supertestnet CC0 license</a> · <a href="/licenses/noble-curves.txt">noble-curves license</a> · <a href="/licenses/noble-hashes.txt">noble-hashes license</a>.</p></details>
<details class="about"><summary>Under the hood & attribution</summary><p>Real serialized Bitcoin transactions, Schnorr signatures and Taproot commit/reveal scripts, on a deliberately simplified local UTXO chain. No proof of work, Bitcoin consensus, peer network, public broadcast, or production custody. 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>
</body></html>
+1 -1
View File
@@ -5,7 +5,7 @@ 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:; 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 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=()',
};
+35 -19
View File
@@ -13,7 +13,7 @@ const scriptHex = script => Script.encode(script, false).hex;
const assert = (condition, message) => { if (!condition) throw new Error(message); };
// Original, intentionally narrow implementation of testnet_generator's local UTXO/mining model.
// This is NOT a Bitcoin consensus engine. Only our key-path and ord text templates are accepted.
// This is NOT a Bitcoin consensus engine. Only our key-path and ord MIME templates are accepted.
export class LocalChain {
constructor(snapshot) {
this.blocks = []; this.mempool = []; this.utxos = new Map(); this.transactions = new Map();
@@ -28,11 +28,11 @@ export class LocalChain {
coins(script) { return [...this.utxos.values()].filter(c => scriptHex(c.scriptPubKey) === scriptHex(script)); }
balance(script) { return this.coins(script).reduce((sum, c) => sum + c.value, 0); }
apply(raw, height, coinbase = false) {
assert(typeof raw === 'string' && raw.length < 20000 && /^(?:[a-f0-9]{2})+$/.test(raw), 'Invalid raw transaction.');
assert(typeof raw === 'string' && raw.length < 40000 && /^(?:[a-f0-9]{2})+$/.test(raw), 'Invalid raw transaction.');
const tx = Tx.decode(raw), txid = Tx.util.getTxid(tx);
assert(!this.transactions.has(txid), 'Duplicate transaction.');
assert(tx.version === 2 && tx.locktime === 0, 'Unsupported transaction version or locktime.');
assert(tx.vin.length === 1 && tx.vout.length >= 1 && tx.vout.length <= 2, 'Unsupported transaction shape.');
assert((tx.vin.length === 1 || (!coinbase && tx.vin.length === 2)) && tx.vout.length >= 1 && tx.vout.length <= 2, 'Unsupported transaction shape.');
let total = 0;
for (const output of tx.vout) {
const value = Number(output.value);
@@ -40,38 +40,54 @@ export class LocalChain {
assert(/^5120[0-9a-f]{64}$/.test(scriptHex(output.scriptPubKey)), 'Only Taproot outputs are supported.');
total += value;
}
let inscription;
let inscription, preservedParent;
const input = tx.vin[0];
if (coinbase) {
assert(input.txid === ZERO && input.vout === 0xffffffff && tx.vout.length === 1 && total === REWARD, 'Invalid local coinbase.');
assert(scriptHex(input.scriptSig) === Script.encode([new TextEncoder().encode(`${NETWORK}:${height}`)], false).hex, 'Wrong coinbase height.');
assert(!input.witness?.length, 'Unexpected coinbase witness.');
} else {
const coin = this.utxos.get(key(input.txid, input.vout));
assert(coin, 'Input is missing or already spent.');
assert(!coin.inscription, 'v0 protects inscription outputs from spending.');
const coins = tx.vin.map(input => this.utxos.get(key(input.txid, input.vout)));
assert(coins.every(Boolean), 'Input is missing or already spent.');
assert(new Set(tx.vin.map(i => key(i.txid, i.vout))).size === tx.vin.length, 'Duplicate input.');
assert(!coins[0].inscription, 'v0 protects inscription outputs from spending.');
tx.vin.forEach((input, i) => {
assert(input.sequence === 'ffffffff' && scriptHex(input.scriptSig) === '', 'Unsupported input flags.');
assert(coin.value - total === FEE, 'v0 requires a fixed 500 sat fee per transaction.');
input.prevout = { value: coin.value, scriptPubKey: coin.scriptPubKey };
const witness = input.witness;
assert(witness?.length === 1 || witness?.length === 3, 'Unsupported witness.');
assert(/^[0-9a-f]{128}$/.test(witness[0]), 'Only default 64-byte Schnorr signatures are accepted.');
input.prevout = { value: coins[i].value, scriptPubKey: coins[i].scriptPubKey };
assert(input.witness?.length === 1 || (i === 0 && input.witness?.length === 3), 'Unsupported witness.');
assert(/^[0-9a-f]{128}$/.test(input.witness[0]), 'Only default 64-byte Schnorr signatures are accepted.');
});
assert(coins.reduce((sum, c) => sum + c.value, 0) - total === FEE, 'v0 requires a fixed 500 sat fee per transaction.');
let config = {};
if (witness.length === 3) {
const parsed = readEnvelope(witness);
assert(tx.vout.length === 1 && total === POSTAGE, 'Invalid reveal postage.');
if (input.witness.length === 3) {
const parsed = readEnvelope(input.witness);
assert(Number(tx.vout[0].value) === POSTAGE, 'Invalid reveal postage.');
assert(scriptHex(tx.vout[0].scriptPubKey) === `5120${parsed.pubkey}`, 'Reveal must return to its signing wallet.');
config = { pubkey: parsed.pubkey };
inscription = { id: `${txid}i0`, txid, commitTxid: input.txid, text: parsed.text, contentType: parsed.contentType, height, value: POSTAGE };
if (parsed.parent) {
const parent = coins[1];
assert(parent?.inscription?.id === parsed.parent, 'Referenced parent inscription must actually be spent.');
assert(tx.vout.length === 2 && Number(tx.vout[1].value) === parent.value &&
scriptHex(parent.scriptPubKey) === `5120${parsed.pubkey}` &&
scriptHex(tx.vout[1].scriptPubKey) === scriptHex(parent.scriptPubKey), 'Parent must be owned and preserved in the same wallet.');
preservedParent = parent.inscription;
} else {
assert(tx.vin.length === 1 && tx.vout.length === 1, 'v0 protects inscription outputs: parent spend requires correct tag.');
}
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 };
} 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;
try { valid = Signer.taproot.verify(tx, 0, config); } catch { /* Normalize malformed curve/signature errors. */ }
try { valid = Signer.taproot.verify(tx, i, i === 0 ? config : {}); } catch { /* Normalize malformed signatures. */ }
assert(valid, 'Invalid signature or Taproot commitment.');
this.utxos.delete(key(input.txid, input.vout));
});
tx.vin.forEach(input => this.utxos.delete(key(input.txid, input.vout)));
}
tx.vout.forEach((output, vout) => this.utxos.set(key(txid, vout), {
txid, vout, value: Number(output.value), scriptPubKey: output.scriptPubKey, height,
...(inscription && vout === 0 ? { inscription } : {}),
...(preservedParent && vout === 1 ? { inscription: preservedParent } : {}),
}));
this.transactions.set(txid, { txid, raw, height, coinbase, tx, ...(inscription ? { inscription } : {}) });
return txid;
+68 -29
View File
@@ -1,49 +1,88 @@
import { Script, Tap, Tx } from '@cmdcode/tapscript';
import { getVin, getVout } from '../upstream/primitives.js';
export const MAX_BYTES = 280;
export const MAX_BYTES = 16 * 1024;
export const POSTAGE = 1000;
export const FEE = 500; // Fixed fee per transaction on our private chain.
export const FEE = 500;
export const MIME = 'text/plain;charset=utf-8'; // Legacy snapshots remain readable.
export const MIME_TYPES = ['text/plain', 'application/json', 'text/html', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/webp'];
const encoder = new TextEncoder();
const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
const unhex = value => Uint8Array.from(value.match(/../g) ?? [], b => parseInt(b, 16));
export const textHex = text => hex(encoder.encode(text));
export const MIME = 'text/plain;charset=utf-8';
export function body(value) {
const result = typeof value === 'string' ? { contentType: MIME, bytes: encoder.encode(value) } : value;
if (!result || ![...MIME_TYPES, MIME].includes(result.contentType)) throw new Error('Unsupported MIME type.');
if (!(result.bytes instanceof Uint8Array) || !result.bytes.length) throw new Error('Enter non-empty inscription bytes.');
if (result.bytes.length > MAX_BYTES) throw new Error(`Body must be at most ${MAX_BYTES} bytes.`);
return { contentType: result.contentType, bytes: new Uint8Array(result.bytes) };
}
export function validateText(text) {
if (typeof text !== 'string' || !text.trim()) throw new Error('Enter a non-empty text inscription.');
if (encoder.encode(text).length > MAX_BYTES) throw new Error(`Text must be at most ${MAX_BYTES} UTF-8 bytes.`);
body(text);
}
export function serializeParent(id) {
const match = /^([0-9a-f]{64})i(0|[1-9][0-9]*)$/.exec(id);
if (!match || Number(match[2]) > 0xffffffff) throw new Error('Invalid parent inscription id.');
let index = Number(match[2]), suffix = '';
while (index) { suffix += (index % 256).toString(16).padStart(2, '0'); index = Math.floor(index / 256); }
return match[1].match(/../g).reverse().join('') + suffix;
}
function parseParent(value) {
if (typeof value !== 'string' || !/^[0-9a-f]{64,72}$/.test(value) || value.length % 2) throw new Error('Invalid parent tag.');
const index = [...unhex(value.slice(64))].reduce((sum, b, i) => sum + b * 256 ** i, 0);
const id = `${value.slice(0,64).match(/../g).reverse().join('')}i${index}`;
if (serializeParent(id) !== value) throw new Error('Non-canonical parent tag.');
return id;
}
// 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) {
const { contentType, bytes } = body(value);
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
// append a sign byte). Encode data pushes explicitly to preserve every byte.
const push = value => {
const size = value.length / 2;
return (size <= 75 ? size.toString(16).padStart(2, '0') : size <= 255
? '4c' + size.toString(16).padStart(2, '0')
: '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';
}
// Envelope adapted from inscriptions-online index.html at 470b8bdf29ead319cff9dc119a005cbf58ee598c (CC0).
// Modern tapscript API; text-only, 280-byte cap, one inscription per reveal.
export const envelope = (pubkey, text) => [
pubkey, 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', textHex(text), 'OP_ENDIF',
];
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 payload = parts[8];
if (typeof payload !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(payload)) throw new Error('Invalid text envelope.');
const text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(payload.match(/../g), b => parseInt(b, 16)));
validateText(text);
const expected = Script.encode(envelope(parts[0], text), false).hex;
if (expected !== witness[1]) throw new Error('Only the v0 ord text envelope is supported.');
const parent = parts[7] === '03' ? parseParent(parts[8]) : null;
const payload = parts.slice(parent ? 10 : 8, -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);
// Read original v0 text snapshots whose content-type tag used OP_1.
const legacy = !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 { text, pubkey: parts[0], contentType: MIME };
return { ...value, parent, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
}
export class InscriptionAdapter {
constructor(chain, wallet) { this.chain = chain; this.wallet = wallet; }
prepare(text) {
validateText(text);
const coin = this.chain.coins(this.wallet.script).find(c => !c.inscription && c.value >= POSTAGE + 2 * FEE + 330);
prepare(value, parentId = null) {
const payload = body(value);
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.');
const coin = coins.find(c => !c.inscription && c.value >= POSTAGE + 2 * FEE + 330);
if (!coin) throw new Error('Mine a block first to get spendable fake BTC.');
return { text, coin, totalFee: FEE * 2, postage: POSTAGE };
return { ...payload, coin, parent, totalFee: FEE * 2, postage: POSTAGE };
}
// Called only by the explicit UI “Sign & inscribe” action.
inscribe(text) {
const { coin } = this.prepare(text);
const script = envelope(this.wallet.pubkey, text);
inscribe(value, parentId = null) {
const { coin, parent, ...payload } = this.prepare(value, parentId);
const script = envelope(this.wallet.pubkey, payload, parentId);
const leaf = Tap.encodeScript(script);
const [tapkey, control] = Tap.getPubKey(this.wallet.pubkey, { target: leaf });
const commit = Tx.create({
@@ -53,11 +92,11 @@ export class InscriptionAdapter {
commit.vin[0].witness = [this.wallet.sign(commit)];
const commitId = Tx.util.getTxid(commit);
const reveal = Tx.create({
vin: [getVin(commitId, 0, POSTAGE + FEE, ['OP_1', tapkey])],
vout: [getVout(POSTAGE, this.wallet.script)],
vin: [getVin(commitId, 0, POSTAGE + FEE, ['OP_1', tapkey]), ...(parent ? [getVin(parent.txid, parent.vout, parent.value, parent.scriptPubKey)] : [])],
vout: [getVout(POSTAGE, this.wallet.script), ...(parent ? [getVout(parent.value, this.wallet.script)] : [])],
});
reveal.vin[0].witness = [this.wallet.sign(reveal, leaf), Script.encode(script, false).hex, control];
// Package acceptance is atomic: no stranded commit on a failed reveal.
if (parent) reveal.vin[1].witness = [this.wallet.sign(reveal, undefined, 1)];
this.chain.submitPackage([Tx.encode(commit).hex, Tx.encode(reveal).hex]);
return `${Tx.util.getTxid(reveal)}i0`;
}
+2 -2
View File
@@ -22,7 +22,7 @@ export class TestWallet {
return new TestWallet(backup.secret);
}
backup() { return { kind: 'test-wallet', version: 1, network: NETWORK, secret: this.secret }; }
sign(tx, extension) {
return Signer.taproot.sign(this.secret, tx, 0, extension ? { extension } : {}).hex;
sign(tx, extension, inputIndex = 0) {
return Signer.taproot.sign(this.secret, tx, inputIndex, extension ? { extension } : {}).hex;
}
}
+68 -19
View File
@@ -1,13 +1,15 @@
import './style.css';
import { TestWallet } from './adapters/wallet.js';
import { LocalRPC } from './adapters/chain.js';
import { InscriptionAdapter, MAX_BYTES } from './adapters/inscription.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';
const $ = id => document.getElementById(id);
const btc = sats => (sats / 1e8).toFixed(8);
const short = id => `${id.slice(0, 12)}…${id.slice(-8)}`;
const THEME_KEY = 'super-testnet-theme';
let fileBytes = null, fileLoading = false, fileRevision = 0;
let lab, busy = false, reviewed = null, failed = false;
const message = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('error', error); };
@@ -42,7 +44,25 @@ function download(name, contents) {
const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' }));
const anchor = link('', url); anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function closeReview() { reviewed = null; $('review-panel').hidden = true; }
function closeReview() {
reviewed = null; $('review-panel').hidden = true;
clearPreviews($('review-preview')); $('review-preview').replaceChildren();
$('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();
}
function lineage(item, chain) {
const p = el('p', undefined, 'tx-links');
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}`));
return p;
}
function renderMempool(chain) {
const pane = $('mempool-list');
@@ -57,7 +77,7 @@ function renderMempool(chain) {
button.append(el('strong', txKind(record, chain)));
button.append(el('span', short(record.txid)));
button.append(el('span', `${Math.round(record.raw.length / 2).toLocaleString()} bytes · 500 fake sat fee`));
if (record.inscription) button.append(el('span', `“${record.inscription.text.slice(0, 34)}${record.inscription.text.length > 34 ? '…' : ''}”`));
if (record.inscription) button.append(el('span', `${record.inscription.contentType} · ${record.inscription.bytes.length} bytes`));
button.onclick = () => { location.hash = `tx/${record.txid}`; };
pane.append(button);
}
@@ -87,8 +107,9 @@ function render() {
$('wallet-state').textContent = wallet ? 'LOCAL / TEST ONLY' : 'NOT CREATED';
$('create-wallet').disabled = disabled; $('load-wallet').disabled = disabled;
$('mine').disabled = disabled || !wallet;
$('payload').disabled = disabled || !wallet;
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || !$('payload').value.trim();
$('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;
$('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;
$('height').textContent = chain?.blocks.length ?? 0;
@@ -100,7 +121,12 @@ function render() {
$('reserved').textContent = `${reserved.toLocaleString()} sats protected in collectibles · balance includes pending outputs`;
}
const items = wallet ? new InscriptionAdapter(chain, wallet).list() : [];
const selectedParent = $('parent').value;
$('parent').replaceChildren(new Option('No parent', ''));
items.forEach(item => $('parent').append(new Option(short(item.id), item.id)));
$('parent').value = selectedParent;
$('collection-count').textContent = items.length;
clearPreviews($('collectibles'));
$('collectibles').replaceChildren();
if (!items.length) {
const empty = el('p', 'Your first inscription belongs here.', 'empty');
@@ -109,19 +135,19 @@ 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'), el('pre', item.text));
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item));
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}`));
if (item.height) trail.append(link(`View block ${item.height} ↗`, `#block/${chain.blocks[item.height - 1].hash}`));
card.append(trail); $('collectibles').append(card);
card.append(lineage(item, chain), trail); $('collectibles').append(card);
}
renderMempool(chain);
renderBlocks(chain);
inspect(false);
}
async function mutate(action, success) {
async function mutate(action, success, onSuccess = () => {}) {
if (busy || failed) return;
busy = true; render();
try {
@@ -131,7 +157,7 @@ async function mutate(action, success) {
await action(candidate);
saveLab(candidate); lab = candidate;
});
closeReview(); message(success);
closeReview(); onSuccess(); message(success);
} catch (error) { message(error.message, true); }
finally { busy = false; render(); }
}
@@ -145,15 +171,37 @@ $('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.');
$('payload').oninput = () => {
closeReview(); const bytes = new TextEncoder().encode($('payload').value).length;
$('byte-count').textContent = `${bytes} / ${MAX_BYTES} UTF-8 bytes`; render();
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
$('parent').onchange = closeReview;
$('mime-type').onchange = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; closeReview(); updateCount(); render();
};
$('body-file').accept = MIME_TYPES.join(',');
$('body-file').onchange = async event => {
const file = event.target.files[0], revision = ++fileRevision;
closeReview(); fileBytes = null;
if (!file) { updateCount(); render(); return; }
fileLoading = true; render();
try {
if (file.size > MAX_BYTES) throw new Error(`Body must be at most ${MAX_BYTES} bytes.`);
if (!MIME_TYPES.includes(file.type)) throw new Error('Unsupported file MIME type.');
const bytes = new Uint8Array(await file.arrayBuffer());
if (revision !== fileRevision) return;
fileBytes = bytes; $('mime-type').value = file.type; $('payload').value = '';
} catch (error) { $('body-file').value = ''; message(error.message, true); }
finally { fileLoading = false; updateCount(); render(); }
};
$('review').onclick = () => {
try {
new InscriptionAdapter(lab.chain, lab.wallet).prepare($('payload').value);
reviewed = { text: $('payload').value, address: lab.wallet.address };
$('review-text').textContent = reviewed.text; $('review-panel').hidden = false;
const payload = composerBody(), parent = $('parent').value || null;
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent);
closeReview();
reviewed = { payload, parent, 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-panel').hidden = false;
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
} catch (error) { message(error.message, true); }
};
@@ -163,8 +211,8 @@ $('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.text);
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.');
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent);
}, '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));
$('export-lab').onclick = () => {
@@ -193,7 +241,7 @@ $('reset').onclick = async () => {
function inspect(scroll = true) {
const [kind, id] = location.hash.slice(1).split('/');
const pane = $('inspector'); pane.replaceChildren(); pane.hidden = !kind;
const pane = $('inspector'); clearPreviews(pane); pane.replaceChildren(); pane.hidden = !kind;
if (!kind || !lab) return;
const chain = lab.chain, rpc = new LocalRPC(chain);
const add = (label, value) => { const p = el('p'); p.append(el('strong', `${label}: `), el('code', String(value))); pane.append(p); };
@@ -228,7 +276,8 @@ function inspect(scroll = true) {
if (record.height) { const p = el('p'); p.append(link(`Inspect block ${record.height} ↗`, `#block/${chain.blocks[record.height - 1].hash}`)); pane.append(p); }
if (record.inscription) {
add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
pane.append(el('pre', record.inscription.text));
add('Body size', `${record.inscription.bytes.length} bytes`);
pane.append(preview(record.inscription), lineage(record.inscription, chain));
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
}
pane.append(el('h3', 'Inputs → outputs'));
+36
View File
@@ -0,0 +1,36 @@
// 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) {
const bytes = new Uint8Array(item.bytes);
const type = item.contentType.split(';')[0];
const text = new TextDecoder().decode(bytes);
if (type === 'text/plain' || type === 'application/json') {
const node = document.createElement('pre'); node.textContent = text; return node;
}
let node, blob;
if (type === 'text/html' || type === 'image/svg+xml') {
const inert = document.implementation.createHTMLDocument('');
const template = inert.createElement('template'); template.innerHTML = text;
const clean = parent => {
for (const child of [...parent.children]) {
if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
for (const attr of [...child.attributes]) if (!attrs.has(attr.name.toLowerCase())) child.removeAttribute(attr.name);
clean(child);
}
};
clean(template.content);
const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; form-action 'none'; base-uri 'none'";
blob = new Blob([`<!doctype html><meta http-equiv="Content-Security-Policy" content="${csp}">`, template.innerHTML], { type: 'text/html' });
node = document.createElement('iframe'); node.setAttribute('sandbox', ''); node.title = `${type} safe preview`;
node.referrerPolicy = 'no-referrer';
} else {
blob = new Blob([bytes], { type }); node = document.createElement('img'); node.alt = `${type} inscription`;
}
const url = URL.createObjectURL(blob); node.src = url; node.dataset.previewUrl = url;
node.className = 'inscription-preview'; return node;
}
export function clearPreviews(root) {
root.querySelectorAll('[data-preview-url]').forEach(node => URL.revokeObjectURL(node.dataset.previewUrl));
}
+1 -1
View File
@@ -1 +1 @@
: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-grid;place-items:center;background:var(--orange);color:#1c1207;width:34px;height:34px;border-radius:8px;font-size:21px;letter-spacing:-4px;padding-right:4px}.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{fontLine truncated
: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-grid;place-items:center;background:var(--orange);color:#1c1207;width:34px;height:34px;border-radius:8px;font-size:21px;letter-spacing:-4px;padding-right:4px}.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{fontLine truncated
+105 -4
View File
@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Tx, Script } from '@cmdcode/tapscript';
import { Tx, Script, Tap } from '@cmdcode/tapscript';
import { TestWallet } from '../src/adapters/wallet.js';
import { LocalChain, LocalRPC } from '../src/adapters/chain.js';
import { InscriptionAdapter, validateText } from '../src/adapters/inscription.js';
import { InscriptionAdapter, validateText, MAX_BYTES, MIME_TYPES, envelope, serializeParent, POSTAGE, FEE } from '../src/adapters/inscription.js';
import { getVin, getVout } from '../src/upstream/primitives.js';
import { encodeLab, decodeLab } from '../src/storage.js';
function setup() {
@@ -40,8 +41,8 @@ test('signed commit/reveal, owned collectible, confirmation, raw tx/block inspec
});
test('empty wallet, UTF-8 limit, and untagged/mainnet wallet imports rejected', () => {
assert.throws(()=>new InscriptionAdapter(new LocalChain(),TestWallet.create()).inscribe('hi'), /Mine a block/);
for (const bad of ['', ' ', '🌱'.repeat(71)]) assert.throws(()=>validateText(bad));
assert.doesNotThrow(()=>validateText('🌱'.repeat(70)));
for (const bad of ['', ' ', '🌱'.repeat(MAX_BYTES / 4 + 1)]) assert.throws(()=>validateText(bad));
assert.doesNotThrow(()=>validateText('🌱'.repeat(MAX_BYTES / 4)));
assert.throws(()=>TestWallet.load({secret:'1'.repeat(64)}), /Only Super Testnet/);
const backup = TestWallet.create().backup(); backup.network='mainnet';
assert.throws(()=>TestWallet.load(backup), /Only Super Testnet/);
@@ -88,3 +89,103 @@ test('corrupted persistence is rejected rather than silently reset', () => {
assert.throws(()=>new LocalChain(snapshot),/block hash/);
assert.throws(()=>new LocalChain({...snapshot,network:'mainnet'}),/network/);
});
test('all MIME bodies round-trip arbitrary bytes, chunk boundaries, cap, and snapshots', () => {
const { wallet, chain, ins } = setup();
for (const contentType of MIME_TYPES) {
const bytes = Uint8Array.from({ length: MAX_BYTES }, (_, i) => i % 256);
const id = ins.inscribe({ contentType, bytes });
const item = ins.list().find(i => i.id === id);
assert.deepEqual(item.bytes, Array.from(bytes)); assert.equal(item.contentType, contentType);
}
const before = chain.snapshot();
assert.throws(() => ins.inscribe({ contentType: 'image/png', bytes: new Uint8Array(MAX_BYTES + 1) }), /at most/);
assert.deepEqual(chain.snapshot(), before);
const restored = decodeLab(encodeLab({ wallet, chain }));
assert.deepEqual(new InscriptionAdapter(restored.chain, restored.wallet).list(), ins.list());
});
test('tag 3 encoding reverses txid and uses minimal little-endian index', () => {
const txid = '0123456789abcdef'.repeat(4);
const reversed = txid.match(/../g).reverse().join('');
for (const [index, suffix] of [[0,''],[1,'01'],[256,'0001'],[65536,'000001'],[4294967295,'ffffffff']])
assert.equal(serializeParent(`${txid}i${index}`), reversed + suffix);
assert.throws(() => serializeParent(`${txid}i4294967296`));
});
test('parent stays owned across children, nested lineage, mining and replay', () => {
const { wallet, chain, ins } = setup();
const parent = ins.inscribe('parent'), child = ins.inscribe('child', parent);
const grandchild = ins.inscribe('grandchild', child);
ins.inscribe('sibling', parent); // Spend its relocated output, not its original reveal.
assert.equal(ins.list().length, 4);
assert.equal(ins.list().find(i => i.id === parent).parent, null);
assert.equal(ins.list().find(i => i.id === child).parent, parent);
assert.equal(ins.list().find(i => i.id === grandchild).parent, child);
const reveal = chain.transactions.get(child.slice(0,-2)).tx;
assert.equal(reveal.vin.length, 2); assert.equal(Number(reveal.vout[1].value), POSTAGE);
assert.throws(() => new InscriptionAdapter(chain, TestWallet.create()).prepare('fraud', parent), /owned/);
chain.mine(wallet.script);
const restored = decodeLab(encodeLab({ wallet, chain }));
assert.deepEqual(new InscriptionAdapter(restored.chain, restored.wallet).list(), ins.list());
assert.equal(ins.list().find(i => i.id === parent).height, 2);
});
// Build genuinely signed malformed packages so rejection tests exercise provenance, not signature failure.
function customReveal(chain, wallet, tag, spentParent, redirect = false) {
const coin = chain.coins(wallet.script).find(c => !c.inscription);
const script = envelope(wallet.pubkey, 'claim', tag), leaf = Tap.encodeScript(script);
const [tapkey, control] = Tap.getPubKey(wallet.pubkey, { target: leaf });
const commit = Tx.create({ vin: [getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey)],
vout: [getVout(POSTAGE + FEE, ['OP_1', tapkey]), getVout(coin.value - POSTAGE - 2 * FEE, wallet.script)] });
commit.vin[0].witness = [wallet.sign(commit)];
const reveal = Tx.create({ vin: [getVin(Tx.util.getTxid(commit), 0, POSTAGE + FEE, ['OP_1', tapkey]),
...(spentParent ? [getVin(spentParent.txid, spentParent.vout, spentParent.value, spentParent.scriptPubKey)] : [])],
vout: [getVout(POSTAGE, wallet.script), ...(spentParent ? [getVout(spentParent.value, redirect ? TestWallet.create().script : wallet.script)] : [])] });
reveal.vin[0].witness = [wallet.sign(reveal, leaf), Script.encode(script, false).hex, control];
if (spentParent) reveal.vin[1].witness = [wallet.sign(reveal, undefined, 1)];
return [Tx.encode(commit).hex, Tx.encode(reveal).hex];
}
test('false parent claims, untagged parent spends, wrong parent and redirected returns are rejected atomically', () => {
const { wallet, chain, ins } = setup(); const parent = ins.inscribe('parent');
const other = ins.inscribe('other');
const coin = chain.coins(wallet.script).find(c => c.inscription?.id === parent);
for (const [tag, spent, redirect, reason] of [
[parent, null, false, /actually be spent/],
[null, coin, false, /correct tag/],
[other, coin, false, /actually be spent/],
[parent, coin, true, /preserved/],
]) {
const before = chain.snapshot();
assert.throws(() => chain.submitPackage(customReveal(chain, wallet, tag, spent, redirect)), reason);
assert.deepEqual(chain.snapshot(), before);
assert.equal(ins.list().find(i => i.id === parent).parent, null);
}
});
test('single bytes and one-byte final chunks preserve exact binary values', () => {
const { ins } = setup();
for (const byte of [0, 1, 3, 16, 127, 128, 129, 255]) {
for (const size of [1, 521]) {
const bytes = new Uint8Array(size).fill(byte);
const id = ins.inscribe({ contentType: 'image/png', bytes });
assert.deepEqual(ins.list().find(i => i.id === id).bytes, Array.from(bytes));
}
}
});
test('legacy text envelopes still decode without changing their bytes', async () => {
const { readEnvelope, MIME, textHex } = await import('../src/adapters/inscription.js');
const wallet = TestWallet.create(), text = 'original v0 🌱';
const script = Script.encode([wallet.pubkey, 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', textHex(text), 'OP_ENDIF'], false).hex;
const parsed = readEnvelope(['00'.repeat(64), script, 'unused']);
assert.equal(parsed.text, text); assert.equal(parsed.parent, null);
assert.deepEqual(parsed.bytes, new TextEncoder().encode(text));
});
test('child reveal requires a valid parent-input signature', () => {
const { chain, wallet, ins } = setup();
const parent = ins.inscribe('parent'), before = chain.snapshot();
const coin = chain.coins(wallet.script).find(c => c.inscription?.id === parent);
const [commit, raw] = customReveal(chain, wallet, parent, coin);
const reveal = Tx.decode(raw); reveal.vin[1].witness[0] = '00'.repeat(64);
assert.throws(() => chain.submitPackage([commit, Tx.encode(reveal).hex]), /signature/);
assert.deepEqual(chain.snapshot(), before);
});
+48 -2
View File
@@ -84,9 +84,9 @@ test('wallet backup reload and rejected oversized or invalid input',async({page}
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.locator('#payload').fill('🌱'.repeat(71));
await page.locator('#payload').fill('🌱'.repeat(4097));
await page.getByRole('button',{name:'Review inscription'}).click();
await expect(page.getByRole('status')).toContainText('at most 280');
await expect(page.getByRole('status')).toContainText('at most 16384');
await expect(page.locator('#pending')).toHaveText('0');
await page.locator('#load-wallet').setInputFiles({name:'bad.json',mimeType:'application/json',buffer:Buffer.from('{"network":"mainnet"}')});
await expect(page.getByRole('status')).toContainText('Only Super Testnet');
@@ -122,3 +122,49 @@ test('storage failure leaves the displayed and persisted chain unchanged', async
await page.getByRole('button',{name:'Mine a block'}).click();
await expect(page.locator('#balance')).toHaveText('50.00000000');
});
test('MIME upload, reset only after success, and parent/child navigation', async ({ page }) => {
await page.goto('/');
await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#payload').fill('parent'); await page.locator('#review').click(); await page.locator('#sign').click();
await expect(page.locator('#payload')).toHaveValue('');
await expect(page.locator('#byte-count')).toHaveText('0 / 16384 bytes');
const parent = await page.locator('#parent option').nth(1).getAttribute('value');
await page.locator('#parent').selectOption(parent);
await page.locator('#body-file').setInputFiles({ name: 'art.svg', mimeType: 'image/svg+xml', buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="orange"/></svg>') });
await page.locator('#review').click();
await expect(page.locator('#review-preview iframe')).toHaveAttribute('sandbox', '');
await page.evaluate(() => { window.originalSetItem = Storage.prototype.setItem; Storage.prototype.setItem = () => { throw new Error('quota'); }; });
await page.locator('#sign').click();
await expect(page.getByRole('status')).toContainText('No changes were saved');
await expect(page.locator('#review-panel')).toBeVisible();
await expect(page.locator('#parent')).toHaveValue(parent);
expect(await page.locator('#body-file').evaluate(e => e.files.length)).toBe(1);
await page.evaluate(() => { Storage.prototype.setItem = window.originalSetItem; });
await page.locator('#sign').click();
await expect(page.locator('.collectible')).toHaveCount(2);
await expect(page.locator('#review-panel')).toBeHidden();
await expect(page.locator('#parent')).toHaveValue('');
await expect(page.locator('#byte-count')).toHaveText('0 / 16384 bytes');
expect(await page.locator('#body-file').evaluate(e => e.files.length)).toBe(0);
await page.locator('.collectible').getByRole('link', { name: /^Parent / }).click();
await expect(page.locator('#inspector pre').first()).toHaveText('parent');
await page.locator('#inspector').getByRole('link', { name: /^Child / }).click();
await expect(page.locator('#inspector')).toContainText('image/svg+xml');
await page.reload();
await expect(page.locator('#inspector iframe')).toBeVisible();
});
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()); });
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#mime-type').selectOption('text/html');
await page.locator('#payload').fill('<meta http-equiv="refresh" content="0;url=https://example.com"><script>parent.unwanted=true</script><img src="https://example.com/a"><iframe src="https://example.com"></iframe><p style="background:url(https://example.com/b)" onclick="parent.unwanted=true">safe words</p><a href="https://example.com">link</a>');
await page.locator('#review').click();
await expect(page.frameLocator('#review-preview iframe').locator('p')).toHaveText('safe words');
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([]);
await expect(page).toHaveURL('http://127.0.0.1:4178/');
});