diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..6500995
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.git
+node_modules
+dist
+test-results
+playwright-report
+.env*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c5a0ba3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+test-results/
+playwright-report/
+.env*
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4477a7a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY index.html ./
+COPY src ./src
+COPY public ./public
+RUN npm run build
+
+FROM node:22-alpine
+ENV NODE_ENV=production PORT=8080
+WORKDIR /app
+COPY --from=build --chown=node:node /app/dist ./dist
+COPY --chown=node:node server.js package.json ./
+USER node
+EXPOSE 8080
+CMD ["node", "server.js"]
diff --git a/PROVENANCE.md b/PROVENANCE.md
new file mode 100644
index 0000000..b56752c
--- /dev/null
+++ b/PROVENANCE.md
@@ -0,0 +1,20 @@
+# Provenance
+
+Reviewed 2026-10-04 against the exact upstream revisions below. Upstream repositories were inspected outside this worktree; only the small adaptations identified here were imported. Original adapter/UI code is distinct from the upstream projects and is not endorsed by their authors.
+
+| 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/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. |
+
+The upstream CC0 legal text is retained unchanged at [`public/licenses/supertestnet-CC0.txt`](public/licenses/supertestnet-CC0.txt). Source comments identify the exact adapted code and revisions.
+
+Runtime dependencies are pinned in `package.json` and `package-lock.json`:
+
+- **@cmdcode/tapscript 1.5.3**, Christopher Scott, [upstream](https://github.com/cmdruid/tapscript), CC-BY-1.0. Used for transaction encoding, signing/verification, Taproot commitments and addresses. Installed package license retained unchanged at [`public/licenses/tapscript.txt`](public/licenses/tapscript.txt). Library source is unmodified; Vite bundles/minifies it for the browser.
+- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt).
+- **@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.
diff --git a/README.md b/README.md
index 3af3b7b..b1819f3 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,95 @@
-# supertestnet
+# Super Testnet v0
-A composable browser-native Bitcoin super testnet built from Supertestnet primitives.
\ No newline at end of file
+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.
+
+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.
+
+## Run locally
+
+Requires Node.js 20.19+ (Node 22 recommended) and npm.
+
+```sh
+npm ci
+npm run dev
+# Open http://localhost:5173
+```
+
+For the exact production server:
+
+```sh
+npm run build
+npm start
+# Open http://localhost:8080
+```
+
+Use localhost or HTTPS: secure-context Web Crypto and Web Locks are required. Chromium is the verified browser; recent browsers supporting Web Locks and Array.toReversed should work. No API keys, Bitcoin node, faucet, CDN, database, account or environment file is needed. Runtime assets are local. Installing dependencies requires network access; the built app makes no third-party requests. It is not an installable/offline service-worker app.
+
+## 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. 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.
+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.
+
+**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.
+
+## 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`: text envelope, 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.
+- `server.js`: dependency-free static file server with security headers and `/healthz`. **No signing, chain state, RPC, database or wallet secrets on the server.**
+
+## 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.
+- 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.
+- No network security or economic assurances: a user controlling their browser can rewrite their entire private lab. Validation is useful for testing this flow, not an adversarial consensus boundary.
+
+## 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.
+
+```sh
+npm test
+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.
+
+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/`.
+
+## Independent Fly deployment
+
+`Dockerfile` builds static assets and runs a non-root Node static server. `fly.toml` targets **only `supertestnet`**, one shared CPU, 256 MB RAM, HTTPS, health checks, scale-to-zero, and **no volume or database**. No Teahub app, service, secrets or network integration is configured. No custom domain is configured.
+
+From a worker with authorized Fly CLI/account access, create the new app in the intended organization, then deploy:
+
+```sh
+fly auth whoami
+fly apps create supertestnet --org YOUR_AUTHORIZED_ORG
+fly deploy --app supertestnet --config fly.toml --ha=false
+fly status --app supertestnet
+```
+
+Use `--ha=false` to avoid a redundant second machine. If the app name already exists, verify its ownership and purpose before deploying; do not overwrite an unrelated app. Expected URL after successful creation: `https://supertestnet.fly.dev`.
+
+Current worker deployment status: **not deployed**. Neither `fly` nor `flyctl` is installed/on PATH, no default `~/.fly` configuration is present, and no Fly credential environment variables are configured. Authorized account access could not be established; no app was created and no credentials were invented. Docker is also unavailable here, so the image was not built locally; the identical production Node server is exercised by Playwright. Fly app-name availability and deployment remain unverified.
+
+Configuration references: [Fly app configuration](https://docs.fly.io/reference/configuration), [Fly deploy options](https://docs.fly.io/flyctl/cmd/fly_deploy).
+
+## Attribution and licenses
+
+See [PROVENANCE.md](PROVENANCE.md) for upstream commit pins, exactly what was adapted, what was only inspiration, and the node_faker decision. License texts ship in `public/licenses/` and the app links them. No upstream UI, network relay, mainnet API or remote script loader was imported.
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..ca2b276
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,28 @@
+app = "supertestnet"
+primary_region = "iad"
+
+[build]
+ dockerfile = "Dockerfile"
+
+[env]
+ PORT = "8080"
+
+[http_service]
+ internal_port = 8080
+ force_https = true
+ auto_stop_machines = "stop"
+ auto_start_machines = true
+ min_machines_running = 0
+ processes = ["app"]
+
+ [[http_service.checks]]
+ grace_period = "10s"
+ interval = "30s"
+ method = "GET"
+ timeout = "3s"
+ path = "/healthz"
+
+[[vm]]
+ memory = "256mb"
+ cpu_kind = "shared"
+ cpus = 1
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..2adad4d
--- /dev/null
+++ b/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ Super Testnet — your little Bitcoin universe
+
+
+
+
+
+ BITCOIN, WITH ROOM TO EXPERIMENT
Your little Bitcoin universe. Mine a block. Inscribe a thought. See it land in your wallet. All in your browser, all with pretend money.
+ Test coins only. This is a private simulation, not Bitcoin testnet or mainnet. Keys stay in this browser, unencrypted. Never load real keys or send real funds here.
+ Create a test wallet to start your first loop.
+
+ 01
Your test wallet NOT CREATED
+ One wallet. A whole chain to play with.
Create test wallet ↗
+ FAKE BTC BALANCE
0 BTC
Local regtest-format address ↓ Save test wallet
+
+
+ 02
Make some blocks Skip the faucet queue. Every block gives you 50 fake BTC , ready to use immediately.
BLOCK HEIGHT 0
PENDING TXS 0
Mine a block + Mining also confirms your pending inscriptions.
+ 03
Leave a little something TEXT INSCRIPTION What will you put on your chain?
+ Review before signing Two transactions: commit + reveal. 1,000 fake sats in fees; 1,000 fake sats reserved in your collectible. It returns to your current wallet.
The next button signs both transactions locally with this test wallet and submits them to your private chain.
Sign & inscribe Cancel
+
+
+ 04 / THE THINGS YOU MADE
Your inscriptions 0 Owned by this wallet Your first inscription belongs here.Fund your wallet, write some text, and make it yours.
+ FOLLOW THE TRAIL
Chain explorer ↓ Save complete lab
+ Under the hood & attribution Real serialized Bitcoin transactions, Schnorr signatures and Taproot commit/reveal scripts, on a deliberately simplified local UTXO chain. No proof of work, Bitcoin consensus, peer network, public broadcast, or production custody. Collectible outputs cannot be spent in v0.
Built from and inspired by Supertestnet’s testnet_generator , inscriptions-online , and vanilla-js-browser-wallet . node_faker was evaluated for future RPC compatibility.
Transaction library: tapscript by Christopher Scott (CC-BY-1.0 ). Supertestnet CC0 license · noble-curves license · noble-hashes license .
+ SMALL CHAIN. BIG POSSIBILITIES. Reset local lab
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..26b1aa9
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1227 @@
+{
+ "name": "super-testnet",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "super-testnet",
+ "version": "0.1.0",
+ "dependencies": {
+ "@cmdcode/tapscript": "1.5.3",
+ "@noble/curves": "1.9.7",
+ "@noble/hashes": "1.8.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.56.1",
+ "vite": "6.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@cmdcode/tapscript": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@cmdcode/tapscript/-/tapscript-1.5.3.tgz",
+ "integrity": "sha512-WCTtZsp5aXk7IKM9DpCSkDjx9eW9WrPxfmFPyfLcYnwEqnEvd+6iAjp8A5elkVfepMZ1e69OHO2qULYlH4TUWA==",
+ "license": "CC-BY-1.0"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
+ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.56.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz",
+ "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz",
+ "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz",
+ "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz",
+ "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz",
+ "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz",
+ "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz",
+ "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz",
+ "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz",
+ "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz",
+ "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz",
+ "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz",
+ "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz",
+ "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz",
+ "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz",
+ "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz",
+ "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz",
+ "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz",
+ "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz",
+ "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz",
+ "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz",
+ "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz",
+ "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz",
+ "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz",
+ "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz",
+ "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.56.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz",
+ "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.0",
+ "@rollup/rollup-android-arm64": "4.64.0",
+ "@rollup/rollup-darwin-arm64": "4.64.0",
+ "@rollup/rollup-darwin-x64": "4.64.0",
+ "@rollup/rollup-freebsd-arm64": "4.64.0",
+ "@rollup/rollup-freebsd-x64": "4.64.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.0",
+ "@rollup/rollup-linux-arm64-musl": "4.64.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.0",
+ "@rollup/rollup-linux-loong64-musl": "4.64.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.0",
+ "@rollup/rollup-linux-x64-gnu": "4.64.0",
+ "@rollup/rollup-linux-x64-musl": "4.64.0",
+ "@rollup/rollup-openbsd-x64": "4.64.0",
+ "@rollup/rollup-openharmony-arm64": "4.64.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.0",
+ "@rollup/rollup-win32-x64-gnu": "4.64.0",
+ "@rollup/rollup-win32-x64-msvc": "4.64.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c1a7256
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "super-testnet",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "vite build",
+ "preview": "vite preview --host 0.0.0.0",
+ "test": "node --test tests/adapters.test.js",
+ "test:e2e": "npm run build && playwright test",
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "@cmdcode/tapscript": "1.5.3",
+ "@noble/curves": "1.9.7",
+ "@noble/hashes": "1.8.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.56.1",
+ "vite": "6.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..6d91572
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,7 @@
+import { defineConfig, devices } from '@playwright/test';
+export default defineConfig({
+ testDir: './tests', testMatch: '**/*.spec.js', fullyParallel: false,
+ use: { baseURL: 'http://127.0.0.1:4178', trace: 'retain-on-failure' },
+ projects: [ { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['Pixel 7'] } } ],
+ webServer: { command: 'node server.js', env: { PORT: '4178' }, url: 'http://127.0.0.1:4178/healthz', reuseExistingServer: false },
+});
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..21f5083
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+S
diff --git a/public/licenses/noble-curves.txt b/public/licenses/noble-curves.txt
new file mode 100644
index 0000000..9297a04
--- /dev/null
+++ b/public/licenses/noble-curves.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2022 Paul Miller (https://paulmillr.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/public/licenses/noble-hashes.txt b/public/licenses/noble-hashes.txt
new file mode 100644
index 0000000..9297a04
--- /dev/null
+++ b/public/licenses/noble-hashes.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2022 Paul Miller (https://paulmillr.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the “Software”), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/public/licenses/supertestnet-CC0.txt b/public/licenses/supertestnet-CC0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/public/licenses/supertestnet-CC0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/public/licenses/tapscript.txt b/public/licenses/tapscript.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/public/licenses/tapscript.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..3b05e3d
--- /dev/null
+++ b/server.js
@@ -0,0 +1,23 @@
+// Static production server; no wallet, signing, chain state, RPC, DB, or secrets on the server.
+import http from 'node:http';
+import { readFile } from 'node:fs/promises';
+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'",
+ 'X-Content-Type-Options':'nosniff', 'Referrer-Policy':'no-referrer', 'X-Frame-Options':'DENY',
+ 'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
+};
+http.createServer(async (req,res) => {
+ const send = (status, body, headers={}) => { res.writeHead(status, {...security,...headers}); res.end(req.method==='HEAD' ? undefined : body); };
+ if (!['GET','HEAD'].includes(req.method)) return send(405,'Method not allowed',{'Allow':'GET, HEAD'});
+ try {
+ const path = decodeURIComponent(new URL(req.url,'http://localhost').pathname);
+ if (path==='/healthz') return send(200,'ok',{'Content-Type':'text/plain'});
+ const target=resolve(root, '.'+(path==='/' ? '/index.html' : path));
+ if (!target.startsWith(root+sep)) return send(403,'Forbidden');
+ const data=await readFile(target);
+ send(200,data,{'Content-Type':types[extname(target)]??'application/octet-stream','Cache-Control':path.startsWith('/assets/')?'public, max-age=31536000, immutable':'no-cache'});
+ } catch { send(404,'Not found'); }
+}).listen(Number(process.env.PORT || 8080),'0.0.0.0',()=>console.log(`Super Testnet static server listening on ${process.env.PORT || 8080}`));
diff --git a/src/adapters/chain.js b/src/adapters/chain.js
new file mode 100644
index 0000000..b60619c
--- /dev/null
+++ b/src/adapters/chain.js
@@ -0,0 +1,134 @@
+import { Script, Signer, Tx } from '@cmdcode/tapscript';
+import { sha256 } from '@noble/hashes/sha2';
+import { bytesToHex } from '@noble/curves/abstract/utils';
+import { getVout } from '../upstream/primitives.js';
+import { readEnvelope, POSTAGE, FEE } from './inscription.js';
+import { NETWORK } from './wallet.js';
+
+const REWARD = 50 * 100_000_000;
+const ZERO = '0'.repeat(64);
+const hash = value => bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(value))));
+const key = (txid, vout) => `${txid}:${vout}`;
+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.
+export class LocalChain {
+ constructor(snapshot) {
+ this.blocks = []; this.mempool = []; this.utxos = new Map(); this.transactions = new Map();
+ if (snapshot) {
+ assert(snapshot.network === NETWORK && snapshot.version === 1, 'Unsupported local chain version/network.');
+ assert(Array.isArray(snapshot.blocks) && Array.isArray(snapshot.mempool), 'Invalid chain snapshot.');
+ for (const block of snapshot.blocks) this.acceptBlock(structuredClone(block));
+ this.submitPackage(snapshot.mempool);
+ }
+ }
+ snapshot() { return { network: NETWORK, version: 1, blocks: structuredClone(this.blocks), mempool: [...this.mempool] }; }
+ 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.');
+ 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.');
+ let total = 0;
+ for (const output of tx.vout) {
+ const value = Number(output.value);
+ assert(Number.isSafeInteger(value) && value >= 330 && value <= REWARD, 'Invalid output amount.');
+ assert(/^5120[0-9a-f]{64}$/.test(scriptHex(output.scriptPubKey)), 'Only Taproot outputs are supported.');
+ total += value;
+ }
+ let inscription;
+ 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.');
+ 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.');
+ let config = {};
+ if (witness.length === 3) {
+ const parsed = readEnvelope(witness);
+ assert(tx.vout.length === 1 && total === 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 };
+ }
+ let valid = false;
+ try { valid = Signer.taproot.verify(tx, 0, config); } catch { /* Normalize malformed curve/signature errors. */ }
+ assert(valid, 'Invalid signature or Taproot commitment.');
+ 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 } : {}),
+ }));
+ this.transactions.set(txid, { txid, raw, height, coinbase, tx, ...(inscription ? { inscription } : {}) });
+ return txid;
+ }
+ submitPackage(raws) {
+ assert(Array.isArray(raws) && this.mempool.length + raws.length <= 1000, 'Invalid package or mempool full; mine a block first.');
+ const utxos = new Map(this.utxos), transactions = new Map(this.transactions);
+ try { raws.forEach(raw => this.apply(raw, null)); }
+ catch (error) { this.utxos = utxos; this.transactions = transactions; throw error; }
+ this.mempool.push(...raws);
+ }
+ acceptBlock(block) {
+ assert(block.height === this.blocks.length + 1 && block.previous === (this.blocks.at(-1)?.hash ?? ZERO), 'Invalid block ancestry.');
+ assert(Number.isSafeInteger(block.time) && block.time > 0 && Array.isArray(block.raws) && block.raws.length > 0 && block.raws.length <= 1001, 'Invalid block.');
+ const { hash: blockHash, ...header } = block;
+ assert(hash(header) === blockHash, 'Invalid local block hash.');
+ const utxos = new Map(this.utxos), transactions = new Map(this.transactions);
+ try { block.raws.forEach((raw, i) => this.apply(raw, block.height, i === 0)); }
+ catch (error) { this.utxos = utxos; this.transactions = transactions; throw error; }
+ this.blocks.push(block);
+ }
+ mine(script) {
+ // Rebuild confirmed state so pending transactions are validated in block order.
+ const next = new LocalChain({ ...this.snapshot(), mempool: [] });
+ const height = this.blocks.length + 1;
+ const coinbase = Tx.create({
+ vin: [{ txid: ZERO, vout: 0xffffffff, scriptSig: [new TextEncoder().encode(`${NETWORK}:${height}`)] }],
+ vout: [getVout(REWARD, script)],
+ });
+ const header = { height, previous: this.blocks.at(-1)?.hash ?? ZERO, time: Date.now(), raws: [Tx.encode(coinbase).hex, ...this.mempool] };
+ const block = { ...header, hash: hash(header) };
+ next.acceptBlock(block);
+ Object.assign(this, next);
+ return block;
+ }
+}
+
+// A small in-process bitcoind-like read facade, not node_faker or a full JSON-RPC server.
+export class LocalRPC {
+ constructor(chain) { this.chain = chain; }
+ call(method, params = []) {
+ if (method === 'getblockcount') return this.chain.blocks.length;
+ if (method === 'getblockhash') {
+ const block = this.chain.blocks.find(b => b.height === params[0]);
+ if (!block) throw new Error('Block not found.');
+ return block.hash;
+ }
+ if (method === 'getblock') {
+ const block = this.chain.blocks.find(b => b.hash === params[0]);
+ if (!block) throw new Error('Block not found.');
+ return { hash: block.hash, height: block.height, previousblockhash: block.previous, time: Math.floor(block.time / 1000), tx: block.raws.map(raw => Tx.util.getTxid(raw)) };
+ }
+ if (method === 'getrawtransaction') {
+ const tx = this.chain.transactions.get(params[0]);
+ if (!tx) throw new Error('Transaction not found.');
+ return tx.raw;
+ }
+ throw new Error(`Unsupported local RPC method: ${method}`);
+ }
+}
diff --git a/src/adapters/inscription.js b/src/adapters/inscription.js
new file mode 100644
index 0000000..6b5c3d0
--- /dev/null
+++ b/src/adapters/inscription.js
@@ -0,0 +1,65 @@
+import { Script, Tap, Tx } from '@cmdcode/tapscript';
+import { getVin, getVout } from '../upstream/primitives.js';
+
+export const MAX_BYTES = 280;
+export const POSTAGE = 1000;
+export const FEE = 500; // Fixed fee per transaction on our private chain.
+const encoder = new TextEncoder();
+const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
+export const textHex = text => hex(encoder.encode(text));
+export const MIME = 'text/plain;charset=utf-8';
+
+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.`);
+}
+// 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.');
+ if (!/^[0-9a-f]{64}$/.test(parts[0])) throw new Error('Invalid inscription signing key.');
+ return { text, pubkey: parts[0], contentType: MIME };
+}
+
+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);
+ if (!coin) throw new Error('Mine a block first to get spendable fake BTC.');
+ return { text, coin, 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);
+ const leaf = Tap.encodeScript(script);
+ const [tapkey, control] = Tap.getPubKey(this.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, this.wallet.script)],
+ });
+ 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)],
+ });
+ 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.
+ this.chain.submitPackage([Tx.encode(commit).hex, Tx.encode(reveal).hex]);
+ return `${Tx.util.getTxid(reveal)}i0`;
+ }
+ list() { return this.chain.coins(this.wallet.script).filter(c => c.inscription).map(c => c.inscription); }
+}
diff --git a/src/adapters/wallet.js b/src/adapters/wallet.js
new file mode 100644
index 0000000..614beab
--- /dev/null
+++ b/src/adapters/wallet.js
@@ -0,0 +1,28 @@
+import { Address, Signer } from '@cmdcode/tapscript';
+import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
+import { bytesToHex } from '@noble/curves/abstract/utils';
+
+export const NETWORK = 'super-testnet-local-v0';
+export class TestWallet {
+ constructor(secret) {
+ if (!/^[0-9a-f]{64}$/.test(secret) || !secp256k1.utils.isValidPrivateKey(secret)) {
+ throw new Error('Invalid test wallet key. Load a Super Testnet v0 backup.');
+ }
+ this.secret = secret;
+ this.pubkey = bytesToHex(schnorr.getPublicKey(secret));
+ // Untweaked key-path output, as in testnet_generator. Never a mainnet address.
+ this.address = Address.p2tr.fromPubKey(this.pubkey, 'regtest');
+ this.script = ['OP_1', this.pubkey];
+ }
+ static create() { return new TestWallet(bytesToHex(secp256k1.utils.randomPrivateKey())); }
+ static load(backup) {
+ if (backup?.network !== NETWORK || backup?.version !== 1 || backup?.kind !== 'test-wallet') {
+ throw new Error('Only Super Testnet v0 test-wallet backups are accepted.');
+ }
+ 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;
+ }
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..84f58d2
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,183 @@
+import './style.css';
+import { TestWallet } from './adapters/wallet.js';
+import { LocalRPC } from './adapters/chain.js';
+import { InscriptionAdapter, MAX_BYTES } from './adapters/inscription.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)}`;
+let lab, busy = false, reviewed = null, failed = false;
+const message = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('error', error); };
+const el = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; };
+const link = (text, hash) => { const node = el('a', text); node.href = hash; return node; };
+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 render() {
+ const wallet = lab?.wallet, chain = lab?.chain;
+ const disabled = busy || failed;
+ $('empty-wallet').hidden = !!wallet; $('active-wallet').hidden = !wallet;
+ $('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();
+ $('sign').disabled = disabled;
+ $('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
+ $('height').textContent = chain?.blocks.length ?? 0;
+ $('pending').textContent = chain?.mempool.length ?? 0;
+ if (wallet) {
+ $('address').value = wallet.address;
+ $('balance').textContent = btc(chain.balance(wallet.script));
+ const reserved = chain.coins(wallet.script).filter(c => c.inscription).reduce((n,c) => n+c.value,0);
+ $('reserved').textContent = `${reserved.toLocaleString()} sats protected in collectibles · balance includes pending outputs`;
+ }
+ const items = wallet ? new InscriptionAdapter(chain, wallet).list() : [];
+ $('collection-count').textContent = items.length;
+ $('collectibles').replaceChildren();
+ if (!items.length) {
+ const empty = el('p', 'Your first inscription belongs here.', 'empty');
+ empty.append(el('br'), el('span', 'Fund your wallet, write some text, and make it yours.'));
+ $('collectibles').append(empty);
+ }
+ 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(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);
+ }
+ $('blocks').replaceChildren();
+ if (!chain?.blocks.length) $('blocks').append(el('p', 'No blocks yet. Mine your first one above.', 'muted'));
+ for (const block of (chain?.blocks ?? []).toReversed()) {
+ const button = el('button', undefined, 'block-card');
+ button.append(el('strong', `Block ${block.height}`), el('span', `${block.raws.length} transaction${block.raws.length === 1 ? '' : 's'} · ${short(block.hash)}`));
+ button.onclick = () => { location.hash = `block/${block.hash}`; }; $('blocks').append(button);
+ }
+ inspect(false);
+}
+async function mutate(action, success) {
+ 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(); message(success);
+ } catch (error) { message(error.message, true); }
+ finally { busy = false; render(); }
+}
+$('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. You received 50 fake BTC and all pending transactions are now confirmed.');
+$('payload').oninput = () => {
+ closeReview(); const bytes = new TextEncoder().encode($('payload').value).length;
+ $('byte-count').textContent = `${bytes} / ${MAX_BYTES} UTF-8 bytes`; 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;
+ $('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
+ } catch (error) { message(error.message, true); }
+};
+$('cancel-review').onclick = closeReview;
+$('sign').onclick = () => {
+ const consent = reviewed;
+ 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 and visible in your wallet. Mine a block to confirm it.');
+};
+$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
+$('export-lab').onclick = () => {
+ try { download('super-testnet-lab.json', failed ? localStorage.getItem(STORAGE_KEY) ?? '{}' : encodeLab(lab)); }
+ catch (error) { message(error.message, true); }
+};
+$('load-wallet').onchange = async event => {
+ const file = event.target.files[0]; event.target.value = '';
+ if (!file) return;
+ if (file.size > 5_000_000) { message('Backup too large (maximum 5 MB).', true); return; }
+ try {
+ const raw = await file.text(), data = JSON.parse(raw);
+ const restore = data.kind === 'test-lab';
+ const loaded = restore ? decodeLab(raw) : TestWallet.load(data);
+ if (lab.wallet && !confirm(restore ? 'Replace this entire local lab with the backup? Save your current lab first.' : 'Switch the active test wallet? The local chain stays here. Save your current wallet first.')) return;
+ await mutate(next => {
+ if (restore) Object.assign(next, loaded); else next.wallet = loaded;
+ }, restore ? 'Complete lab restored, including its wallet and chain.' : 'Test wallet loaded. Its collectibles on this local chain are shown below.');
+ } catch (error) { message(`Backup not loaded: ${error.message}`, true); }
+};
+$('reset').onclick = async () => {
+ if (busy || !confirm('Delete this browser’s local chain and test wallet? Export a complete lab first if you want to keep it.')) return;
+ try { await navigator.locks.request(STORAGE_KEY, () => localStorage.removeItem(STORAGE_KEY)); location.hash = ''; location.reload(); }
+ catch (error) { message(error.message, true); }
+};
+
+function inspect(scroll = true) {
+ const [kind, id] = location.hash.slice(1).split('/');
+ const pane = $('inspector'); 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); };
+ try {
+ if (kind === 'block') {
+ const block = rpc.call('getblock', [id]);
+ pane.append(el('h2', `Block ${block.height}`)); add('Local block hash', block.hash); add('Previous block', block.previousblockhash);
+ add('Mined', new Date(block.time * 1000).toISOString());
+ pane.append(el('p', 'Synthetic local block; not a Bitcoin block header.', 'muted'));
+ for (const txid of block.tx) { const p = el('p'); p.append(link(`Transaction ${txid}`, `#tx/${txid}`)); pane.append(p); }
+ } else if (kind === 'tx' || kind === 'inscription') {
+ const txid = kind === 'tx' ? id : id?.replace(/i0$/, '');
+ const record = chain.transactions.get(txid);
+ if (!record || (kind === 'inscription' && record.inscription?.id !== id)) throw new Error('Transaction or inscription not found on this local chain.');
+ pane.append(el('h2', kind === 'inscription' ? 'Inscription detail' : record.coinbase ? 'Coinbase transaction' : 'Transaction detail'));
+ add('Transaction ID', txid); add('Status', record.height ? `${chain.blocks.length - record.height + 1} confirmation(s)` : 'Pending — mine a block');
+ 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));
+ pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
+ }
+ pane.append(el('h3', 'Inputs → outputs'));
+ const tx = record.tx;
+ tx.vin.forEach(input => {
+ const p = el('p');
+ if (record.coinbase) p.textContent = 'Local coinbase · 50 fake BTC created';
+ else p.append(link(`Input ${input.txid}:${input.vout}`, `#tx/${input.txid}`));
+ pane.append(p);
+ });
+ tx.vout.forEach((out, i) => add(`Output ${i}`, `${Number(out.value).toLocaleString()} sats`));
+ add('Fee', record.coinbase ? '0 (local reward)' : '500 fake sats');
+ pane.append(el('h3', 'Raw Bitcoin transaction'), el('pre', rpc.call('getrawtransaction', [txid])));
+ if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin[0].witness, null, 2))); }
+ } else throw new Error('Unknown explorer view.');
+ } catch (error) { pane.append(el('p', error.message)); }
+ if (scroll) { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); pane.focus({ preventScroll: true }); }
+}
+window.addEventListener('hashchange', () => inspect());
+window.addEventListener('storage', event => {
+ if (event.key !== STORAGE_KEY) return;
+ closeReview(); load(); if (!failed) message('Local lab changed in another tab; refreshed.');
+});
+function load() {
+ try { lab = readLab(); failed = false; if (lab.wallet) message('Test wallet loaded from this browser. Your local chain is ready.'); }
+ catch (error) { failed = true; message(`Cannot load saved lab: ${error.message} Export it for recovery, then reset this local lab.`, true); }
+ render();
+}
+load();
diff --git a/src/storage.js b/src/storage.js
new file mode 100644
index 0000000..1f70087
--- /dev/null
+++ b/src/storage.js
@@ -0,0 +1,18 @@
+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() };
+ 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) };
+}
+export const encodeLab = lab => JSON.stringify({
+ kind: 'test-lab', network: NETWORK, version: 1,
+ wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(),
+});
+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.'); }
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..0ad25ab
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1 @@
+:root{font-family:Arial,Helvetica,sans-serif;color:#e8eee7;background:#101711;font-synthesis:none;--lime:#d3fb83;--muted:#9eac9e;--line:#344235;--panel:#19231b}*{box-sizing:border-box}body{margin:0}a{color:var(--lime);text-underline-offset:4px}button,input,textarea{font:inherit}button,.file-button{cursor:pointer}button{border:1px solid #5f7553;border-radius:8px;background:#283825;color:#e9f4df;padding:12px 17px;font-weight:600;transition:background .15s}button:hover:not(:disabled){background:#385033}button:disabled{opacity:.4;cursor:not-allowed}button.primary{background:var(--lime);color:#17200f;border-color:var(--lime)}button.primary:hover:not(:disabled){background:#e4ffb3}.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 #e4ffb3;outline-offset:4px}header{max-width:1240px;margin:auto;padding:26px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{text-decoration:none;color:#eff5eb;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(--lime);color:#17200f;width:34px;height:34px;border-radius:8px;font-size:21px;letter-spacing:-4px;padding-right:4px}.version{font:11px monospace;background:#293426;color:var(--lime);border-radius:4px;padding:3px 5px}.network{font:11px monospace;color:#c8d9bc;letter-spacing:1px}.network i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--lime);margin-right:6px}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(--lime);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,#3a502531,transparent 70%);margin-right:65px}.cube{display:grid;place-items:center;width:110px;height:110px;border:1px solid var(--lime);box-shadow:13px 13px 0 #2c3e21,14px 14px 0 #7d9d53;transform:rotate(-12deg);background:#25351e;color:var(--lime);font:66px monospace}.orbit span{font:10px monospace;letter-spacing:2px;color:#92a582}.notice{background:#24291c;border:1px solid #455032;border-radius:8px;padding:14px 18px;font-size:12px;color:#bcc6aa;line-height:1.6}.notice strong{color:#e1eabf}#status{min-height:52px;padding:16px 2px;font-size:13px;color:var(--lime)}#status.error{color:#ffb9a7}.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(--lime);background:#2a3921;border:1px solid #495b36;border-radius:6px;padding:7px}.tag{margin-left:auto;font:9px monospace;letter-spacing:1px;color:#b9c4b1;border:1px solid #425039;padding:5px;border-radius:4px}.panel p{line-height:1.6;font-size:14px;color:#b5c2b0}.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(--lime);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:#c3cfbc;margin:14px 0 9px}input,textarea{width:100%;color:#e5eddd;background:#111b13;border:1px solid #3b4b36;border-radius:6px;padding:12px;outline:none}input{font:11px monospace}textarea{resize:vertical;min-height:100px;line-height:1.5}input:focus,textarea:focus{border-color:var(--lime)}#export-wallet{margin-top:12px}.wallet-footer{border-top:1px solid var(--line);margin-top:23px;padding-top:5px}.file-button{display:inline-block;color:var(--lime);text-decoration:underline;text-underline-offset:4px}input[type=file]{font-size:11px;margin-top:4px}.wallet-footer p{margin-bottom:0}.chain-stats{display:flex;gap:64px;margin:34px 0}.chain-stats strong{font:40px monospace}#mine{width:100%}.inscribe{grid-column:1/-1}.compose-footer{display:flex;align-items:center;justify-content:space-between;margin-top:14px}#review-panel{border-top:1px solid #526343;margin-top:22px;padding-top:14px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#101912;border:1px solid var(--line);padding:16px;border-radius:8px;font-size:13px;line-height:1.6}h3{font-size:17px;font-weight:500}.section-title{display:flex;align-items:center;justify-content:space-between;margin:40px 0 20px;gap:14px}.section-title .eyebrow{margin-bottom:8px;font-size:10px}.section-title h2{font-size:24px;font-weight:500;letter-spacing:-.7px;margin:0}#collection-count{font:12px monospace;padding:4px 8px;margin-left:7px;background:#2a3921;color:var(--lime);border-radius:5px}.collectibles{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.empty{grid-column:1/-1;border:1px dashed #44563a;border-radius:10px;padding:35px;text-align:center;color:#c6d2be;font-size:14px;line-height:1.9;margin:0}.empty span{font-size:12px;color:var(--muted)}.collectible{border:1px solid #415736;border-radius:10px;background:#1d2b1c;padding:20px;min-width:0}.collectible pre{background:transparent;border:0;padding:15px 0;min-height:68px;color:#e5f0d8;margin:0;font:17px/1.5 Georgia,serif}.collectible .tag{display:inline-block}.collectible a{font:11px monospace;overflow-wrap:anywhere}.collectible p{margin:12px 0 0}.blocks{display:flex;gap:12px;overflow-x:auto;padding-bottom:10px}.block-card{min-width:160px;text-align:left;padding:16px}.block-card strong{display:block;font:23px monospace;margin-bottom:10px}.block-card span{font:11px monospace;color:var(--muted)}#inspector{margin-top:16px;scroll-margin-top:20px}#inspector p{overflow-wrap:anywhere}#inspector code{font-size:12px;overflow-wrap:anywhere}#inspector h2{font-weight:500;font-size:22px}#inspector a{font-size:12px;overflow-wrap:anywhere}.tx-links{display:flex;gap:16px;flex-wrap:wrap}.about{margin-top:40px;border-top:1px solid var(--line);padding:20px 0;color:var(--muted);font-size:12px;line-height:1.8}.about summary{cursor:pointer;color:#d0d9c8}.about p{max-width:900px}footer{border-top:1px solid var(--line);padding:22px 0;display:flex;align-items:center;justify-content:space-between;font:10px monospace;color:#839479;letter-spacing:1px}.danger{color:#d8a399}[hidden]{display:none!important}@media(max-width:1240px){main{margin:0 32px}}@media(max-width:700px){header{padding:20px}.network{font-size:8px;letter-spacing:0}.brand{font-size:12px;gap:7px}main{margin:0 18px}.hero{padding:36px 0 20px}.orbit{display:none}h1{letter-spacing:-2px}.intro{font-size:14px}.workspace{grid-template-columns:1fr}.inscribe{grid-column:auto}.panel{padding:20px}.tag{font-size:8px}.collectibles{grid-template-columns:1fr}.section-title{align-items:start}.section-title h2{font-size:21px}.section-title>.muted{font-size:10px!important}.compose-footer{gap:10px}.compose-footer button{font-size:12px}.notice{font-size:11px}footer{gap:10px}}
diff --git a/src/upstream/primitives.js b/src/upstream/primitives.js
new file mode 100644
index 0000000..9822fda
--- /dev/null
+++ b/src/upstream/primitives.js
@@ -0,0 +1,7 @@
+// Adapted from Supertestnet/testnet_generator index.html, getVin/getVout.
+// Commit 498be4cc775ef18e7ff17357d007918518f47b98. CC0-1.0.
+// Changes: ES module exports, explicit script arrays (no address detection/global UI).
+export const getVin = (txid, vout, value, scriptPubKey) => ({
+ txid, vout, sequence: 0xffffffff, prevout: { value, scriptPubKey },
+});
+export const getVout = (value, scriptPubKey) => ({ value, scriptPubKey });
diff --git a/tests/adapters.test.js b/tests/adapters.test.js
new file mode 100644
index 0000000..4ec14ad
--- /dev/null
+++ b/tests/adapters.test.js
@@ -0,0 +1,90 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { Tx, Script } 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 { encodeLab, decodeLab } from '../src/storage.js';
+
+function setup() {
+ const wallet = TestWallet.create(), chain = new LocalChain();
+ chain.mine(wallet.script);
+ return { wallet, chain, ins: new InscriptionAdapter(chain, wallet) };
+}
+test('signed commit/reveal, owned collectible, confirmation, raw tx/block inspection and restoration', () => {
+ const { wallet, chain, ins } = setup();
+ assert.match(wallet.address, /^bcrt1p/);
+ assert.equal(chain.balance(wallet.script), 5e9);
+ const id = ins.inscribe('Hello from v0 🌱');
+ assert.equal(chain.mempool.length, 2);
+ assert.equal(chain.balance(wallet.script), 5e9 - 1000);
+ assert.equal(ins.list()[0].height, null);
+ assert.equal(ins.list()[0].id, id);
+ assert.deepEqual(new InscriptionAdapter(chain, TestWallet.create()).list(), []);
+ const pendingRestored = decodeLab(encodeLab({wallet,chain}));
+ assert.equal(new InscriptionAdapter(pendingRestored.chain,pendingRestored.wallet).list()[0].id, id);
+ const block = chain.mine(wallet.script);
+ assert.equal(chain.mempool.length, 0);
+ assert.equal(ins.list()[0].height, 2);
+ assert.equal(chain.balance(wallet.script), 1e10 - 1000);
+ const rpc = new LocalRPC(chain);
+ const revealId = id.slice(0,-2);
+ assert.equal(Tx.util.getTxid(rpc.call('getrawtransaction',[revealId])), revealId);
+ assert.ok(rpc.call('getblock',[block.hash]).tx.includes(revealId));
+ assert.equal(rpc.call('getblockhash',[2]),block.hash);
+ assert.equal(rpc.call('getblockcount'),2);
+ assert.throws(()=>rpc.call('sendrawtransaction'),/Unsupported/);
+ const restored = decodeLab(encodeLab({wallet,chain}));
+ assert.equal(restored.wallet.address,wallet.address);
+ assert.deepEqual(new InscriptionAdapter(restored.chain,restored.wallet).list(),ins.list());
+});
+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)));
+ 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/);
+});
+test('invalid reveal signature rolls back the entire package, and double-spends fail', () => {
+ const {wallet,chain,ins} = setup();
+ const before = chain.snapshot(); ins.inscribe('atomic');
+ const [commit,reveal] = chain.mempool;
+ const bad = Tx.decode(reveal); bad.vin[0].witness[0]='00'.repeat(64);
+ const clean = new LocalChain(before);
+ assert.throws(()=>clean.submitPackage([commit,Tx.encode(bad).hex]),/signature|Signature/);
+ assert.deepEqual(clean.snapshot(),before);
+ assert.equal(clean.balance(wallet.script),5e9);
+ clean.submitPackage([commit,reveal]);
+ assert.throws(()=>clean.submitPackage([commit]),/Duplicate/);
+ const spend = Tx.decode(commit); spend.vout[1].value = Number(spend.vout[1].value)-1;
+ assert.throws(()=>clean.submitPackage([Tx.encode(spend).hex]),/spent/);
+});
+test('tampering with reveal payload, control block or value is rejected', () => {
+ const {chain,ins} = setup(); const before=chain.snapshot(); ins.inscribe('original');
+ const [commit,reveal]=chain.mempool;
+ for (const mutate of [
+ tx=>{ const script=Script.decode(tx.vin[0].witness[1]); script[8]='6861636b6564'; tx.vin[0].witness[1]=Script.encode(script,false).hex; },
+ tx=>{ tx.vin[0].witness[2]='c0'+'00'.repeat(32); },
+ tx=>{ tx.vout[0].value=5000; },
+ ]) {
+ const tx=Tx.decode(reveal); mutate(tx); const candidate=new LocalChain(before);
+ assert.throws(()=>candidate.submitPackage([commit,Tx.encode(tx).hex]));
+ assert.deepEqual(candidate.snapshot(),before);
+ }
+});
+test('collectibles are protected and subsequent inscriptions use change', () => {
+ const {wallet,chain,ins}=setup(); const first=ins.inscribe('first'); ins.inscribe('second');
+ assert.equal(ins.list().length,2);
+ const record=chain.transactions.get(first.slice(0,-2));
+ const spend=Tx.create({vin:[{txid:record.txid,vout:0,sequence:0xffffffff,prevout:record.tx.vout[0]}],vout:[{value:500,scriptPubKey:wallet.script}]});
+ spend.vin[0].witness=[wallet.sign(spend)];
+ assert.throws(()=>chain.submitPackage([Tx.encode(spend).hex]),/protects inscription/);
+ chain.mine(wallet.script); assert.equal(ins.list().length,2);
+});
+test('corrupted persistence is rejected rather than silently reset', () => {
+ assert.throws(()=>decodeLab('{bad'));
+ const {chain}=setup(); const snapshot=chain.snapshot(); snapshot.blocks[0].hash='0'.repeat(64);
+ assert.throws(()=>new LocalChain(snapshot),/block hash/);
+ assert.throws(()=>new LocalChain({...snapshot,network:'mainnet'}),/network/);
+});
diff --git a/tests/flow.spec.js b/tests/flow.spec.js
new file mode 100644
index 0000000..09149ab
--- /dev/null
+++ b/tests/flow.spec.js
@@ -0,0 +1,97 @@
+import { test, expect } from '@playwright/test';
+
+test('create → mine → review/sign → owned inscription → block/transaction → reload/load', async ({page}) => {
+ const errors=[], remote=[];
+ 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 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 expect(page.locator('#balance')).toHaveText('50.00000000');
+ const payload=' Hello 🌱';
+ await page.getByLabel('What will you put on your chain?').fill(payload);
+ await page.getByRole('button',{name:'Review inscription'}).click();
+ await expect(page.locator('#review-text')).toHaveText(payload);
+ await expect(page.locator('#pending')).toHaveText('0');
+ await page.getByRole('button',{name:'Sign & inscribe'}).click();
+ await expect(page.locator('#pending')).toHaveText('2');
+ await expect(page.locator('.collectible pre')).toHaveText(payload);
+ await expect(page.locator('.collectible .tag')).toContainText('PENDING');
+ await expect(page.locator('#balance')).toHaveText('49.99999000');
+ await page.reload();
+ await expect(page.locator('.collectible pre')).toHaveText(payload);
+ await expect(page.locator('#pending')).toHaveText('2');
+ await page.getByRole('button',{name:'Mine a block'}).click();
+ await expect(page.locator('.collectible .tag')).toHaveText('CONFIRMED · BLOCK 2');
+ await page.getByRole('link',{name:'View reveal transaction'}).click();
+ await expect(page.locator('#inspector')).toContainText('1 confirmation(s)');
+ await expect(page.locator('#inspector')).toContainText('Raw Bitcoin transaction');
+ await page.getByRole('link',{name:'Inspect commit transaction'}).click();
+ await expect(page.locator('#inspector')).toContainText('4,999,998,000 sats');
+ await page.getByRole('link',{name:'View block 2'}).click();
+ await expect(page.locator('#inspector h2')).toHaveText('Block 2');
+ await expect(page.locator('#inspector a')).toHaveCount(3);
+ await page.getByRole('link',{name:/^Inscription /}).click();
+ await expect(page.locator('#inspector')).toContainText('Inscription detail');
+ const [download] = await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save complete lab'}).click()]);
+ const backupPath=await download.path();
+ page.on('dialog',dialog=>dialog.accept());
+ await page.getByRole('button',{name:'Reset local lab'}).click();
+ await expect(page.getByRole('button',{name:'Create test wallet'})).toBeVisible();
+ await page.locator('#load-wallet').setInputFiles(backupPath);
+ await expect(page.locator('.collectible pre')).toHaveText(payload);
+ await expect(page.locator('#height')).toHaveText('2');
+ await page.reload();
+ await expect(page.locator('#balance')).toHaveText('99.99999000');
+ expect(await page.evaluate(()=>window.unwanted)).toBeUndefined();
+ expect(errors).toEqual([]); expect(remote).toEqual([]);
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy();
+ await page.screenshot({path:`test-results/${test.info().project.name}-complete.png`,fullPage:true});
+});
+
+test('wallet backup reload and rejected oversized or invalid input',async({page})=>{
+ await page.goto('/'); await page.getByRole('button',{name:'Create test wallet'}).click();
+ 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.locator('#payload').fill('🌱'.repeat(71));
+ await page.getByRole('button',{name:'Review inscription'}).click();
+ await expect(page.getByRole('status')).toContainText('at most 280');
+ 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');
+ await expect(page.locator('#address')).toHaveValue(address);
+ page.on('dialog',d=>d.accept());
+ await page.locator('#load-wallet').setInputFiles(await download.path());
+ await expect(page.getByRole('status')).toContainText('Test wallet loaded.');
+ await expect(page.locator('#address')).toHaveValue(address);
+ await expect(page.locator('#balance')).toHaveText('50.00000000');
+});
+
+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 expect(page.locator('#height')).toHaveText('2');
+ await expect(other.locator('#height')).toHaveText('2');
+ await expect(page.locator('#balance')).toHaveText('100.00000000');
+});
+
+test('storage failure leaves the displayed and persisted chain unchanged', async ({page}) => {
+ await page.goto('/');
+ 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 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 expect(page.locator('#balance')).toHaveText('50.00000000');
+});