Build browser-native Super Testnet v0 inscription loop
This commit is contained in:
1 parent
d3272032c8
commit
4fa4130150
25 files changed
+2393
-2
No files matched your search
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+183
@@ -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();
|
||||
@@ -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.'); }
|
||||
}
|
||||
@@ -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:40pxLine truncated
|
||||
@@ -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 });
|
||||
Reference in new issue
Block a user