Add CBOR metadata indexes, local API and coordinated auto mining
This commit is contained in:
1 parent
0405452ab2
commit
aa39db3879
14 files changed
+599
-54
No files matched your search
@@ -75,7 +75,7 @@ export class LocalChain {
|
||||
}
|
||||
config = { pubkey: parsed.pubkey };
|
||||
inscription = { id: `${txid}i0`, txid, commitTxid: input.txid, text: parsed.text,
|
||||
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, height, value: POSTAGE };
|
||||
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, metadata: parsed.metadata, height, value: POSTAGE };
|
||||
} else assert(tx.vin.length === 1, 'v0 protects inscription outputs: only explicit child reveals can spend a parent.');
|
||||
tx.vin.forEach((input, i) => {
|
||||
let valid = false;
|
||||
|
||||
+25
-11
@@ -1,3 +1,4 @@
|
||||
import { encodeMetadata, decodeMetadata } from './metadata.js';
|
||||
import { Script, Tap, Tx } from '@cmdcode/tapscript';
|
||||
import { getVin, getVout } from '../upstream/primitives.js';
|
||||
|
||||
@@ -37,8 +38,9 @@ function parseParent(value) {
|
||||
}
|
||||
// Envelope adapted from inscriptions-online at 470b8bdf29ead319cff9dc119a005cbf58ee598c (CC0).
|
||||
// Local extension: MIME bytes, 520-byte pushes, optional ord parent tag 3.
|
||||
export function envelope(pubkey, value, parent = null) {
|
||||
export function envelope(pubkey, value, parent = null, metadata = undefined) {
|
||||
const { contentType, bytes } = body(value);
|
||||
const metadataBytes = metadata === undefined ? undefined : encodeMetadata(metadata);
|
||||
const chunks = [];
|
||||
for (let i = 0; i < bytes.length; i += 520) chunks.push(hex(bytes.slice(i, i + 520)));
|
||||
// tapscript's generic encoder normalizes one-byte data into numbers (and can
|
||||
@@ -50,28 +52,40 @@ export function envelope(pubkey, value, parent = null) {
|
||||
: '4d' + (size % 256).toString(16).padStart(2, '0') + Math.floor(size / 256).toString(16).padStart(2, '0')) + value;
|
||||
};
|
||||
return push(pubkey) + 'ac0063' + push(textHex('ord')) + push('01') + push(textHex(contentType)) +
|
||||
(parent ? push('03') + push(serializeParent(parent)) : '') + '00' + chunks.map(push).join('') + '68';
|
||||
(parent ? push('03') + push(serializeParent(parent)) : '') +
|
||||
(metadataBytes === undefined ? '' : Array.from({ length: Math.ceil(metadataBytes.length / 520) }, (_, i) =>
|
||||
push('05') + push(hex(metadataBytes.slice(i * 520, (i + 1) * 520)))).join('')) + '00' + chunks.map(push).join('') + '68';
|
||||
}
|
||||
export function readEnvelope(witness) {
|
||||
if (!Array.isArray(witness) || witness.length !== 3) throw new Error('Unsupported script-path witness.');
|
||||
const parts = Script.decode(witness[1]);
|
||||
const parent = parts[7] === '03' ? parseParent(parts[8]) : null;
|
||||
const payload = parts.slice(parent ? 10 : 8, -1);
|
||||
let cursor = 7, parent = null;
|
||||
if (parts[cursor] === '03') { parent = parseParent(parts[cursor + 1]); cursor += 2; }
|
||||
const metadataChunks = [];
|
||||
while (parts[cursor] === '05') {
|
||||
const chunk = parts[cursor + 1];
|
||||
if (typeof chunk !== 'string' || !/^(?:[0-9a-f]{2}){1,520}$/.test(chunk)) throw new Error('Invalid metadata push.');
|
||||
metadataChunks.push(chunk); cursor += 2;
|
||||
}
|
||||
if (parts[cursor] !== 'OP_0') throw new Error('Unsupported ord tag or missing body separator.');
|
||||
const metadata = metadataChunks.length ? decodeMetadata(unhex(metadataChunks.join(''))) : undefined;
|
||||
const payload = parts.slice(cursor + 1, -1);
|
||||
if (!payload.length || payload.some(p => typeof p !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(p))) throw new Error('Invalid body envelope.');
|
||||
const contentType = new TextDecoder().decode(unhex(parts[6]));
|
||||
const value = body({ contentType, bytes: unhex(payload.join('')) });
|
||||
const expected = envelope(parts[0], value, parent);
|
||||
const expected = envelope(parts[0], value, parent, metadata);
|
||||
// Read original v0 text snapshots whose content-type tag used OP_1.
|
||||
const legacy = !parent && contentType === MIME && value.bytes.length <= 280 &&
|
||||
const legacy = metadata === undefined && !parent && contentType === MIME && value.bytes.length <= 280 &&
|
||||
Script.encode([parts[0], 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', hex(value.bytes), 'OP_ENDIF'], false).hex;
|
||||
if (expected !== witness[1] && legacy !== witness[1]) throw new Error('Unsupported ord envelope.');
|
||||
if (!/^[0-9a-f]{64}$/.test(parts[0])) throw new Error('Invalid inscription signing key.');
|
||||
return { ...value, parent, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
|
||||
return { ...value, parent, metadata, metadataBytes: metadataChunks.length ? unhex(metadataChunks.join('')) : undefined, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
|
||||
}
|
||||
export class InscriptionAdapter {
|
||||
constructor(chain, wallet) { this.chain = chain; this.wallet = wallet; }
|
||||
prepare(value, parentId = null) {
|
||||
prepare(value, parentId = null, metadata = undefined) {
|
||||
const payload = body(value);
|
||||
if (metadata !== undefined) encodeMetadata(metadata);
|
||||
const coins = this.chain.coins(this.wallet.script);
|
||||
const parent = parentId ? coins.find(c => c.inscription?.id === parentId) : null;
|
||||
if (parentId && !parent) throw new Error('Parent must be an owned, unspent inscription.');
|
||||
@@ -80,9 +94,9 @@ export class InscriptionAdapter {
|
||||
return { ...payload, coin, parent, totalFee: FEE * 2, postage: POSTAGE };
|
||||
}
|
||||
// Called only by the explicit UI “Sign & inscribe” action.
|
||||
inscribe(value, parentId = null) {
|
||||
const { coin, parent, ...payload } = this.prepare(value, parentId);
|
||||
const script = envelope(this.wallet.pubkey, payload, parentId);
|
||||
inscribe(value, parentId = null, metadata = undefined) {
|
||||
const { coin, parent, ...payload } = this.prepare(value, parentId, metadata);
|
||||
const script = envelope(this.wallet.pubkey, payload, parentId, metadata);
|
||||
const leaf = Tap.encodeScript(script);
|
||||
const [tapkey, control] = Tap.getPubKey(this.wallet.pubkey, { target: leaf });
|
||||
const commit = Tx.create({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Tx } from '@cmdcode/tapscript';
|
||||
import { readEnvelope } from './inscription.js';
|
||||
import { MetadataIndex } from './metadata-index.js';
|
||||
|
||||
// In-process request/response seam; these paths are NOT HTTP routes on the static host.
|
||||
export class LocalOrdAPI {
|
||||
constructor(chain, index = null) { this.chain = chain; this.index = index; }
|
||||
request(path) {
|
||||
const response = (body, contentType = 'application/json', status = 200) => ({ status, contentType, body });
|
||||
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return response({ error: 'Use a local absolute path.' }, undefined, 400);
|
||||
const url = new URL(path, 'https://local.invalid'), parts = url.pathname.split('/').filter(Boolean);
|
||||
const [root, kind, key] = parts;
|
||||
const chain = this.chain, index = this.index ?? new MetadataIndex(chain);
|
||||
const blockInfo = block => ({ height: block.height, hash: block.hash, previous: block.previous,
|
||||
time: block.time, txids: block.raws.map(raw => Tx.util.getTxid(raw)) });
|
||||
if (url.pathname === '/local/metadata-index') return response({ ...index.stats, events: index.events });
|
||||
if (url.pathname === '/local/protocols') return response(index.protocols.map(protocol => {
|
||||
const events = index.forProtocol(protocol);
|
||||
return { protocol, events: events.length, confirmed: events.filter(e => e.status === 'confirmed').length,
|
||||
pending: events.filter(e => e.status === 'pending').length };
|
||||
}));
|
||||
if (url.pathname === '/local/protocol') return response(index.forProtocol(url.searchParams.get('name')));
|
||||
if (url.pathname === '/r/blockheight') return response(chain.blocks.length);
|
||||
if (root === 'r' && parts.length === 3) {
|
||||
if (kind === 'blockhash' || kind === 'blockinfo') {
|
||||
const block = chain.blocks.find(b => String(b.height) === key || kind === 'blockinfo' && b.hash === key);
|
||||
if (block) return response(kind === 'blockhash' ? block.hash : blockInfo(block));
|
||||
}
|
||||
if (kind === 'tx') { const tx = chain.transactions.get(key); if (tx) return response({ txid: tx.txid, hex: tx.raw, height: tx.height }); }
|
||||
}
|
||||
const id = root === 'content' && parts.length === 2 ? kind : root === 'r' && parts.length === 3 ? key : null;
|
||||
const event = index.events.find(e => e.id === id);
|
||||
if (event) {
|
||||
if (root === 'content') {
|
||||
const record = chain.transactions.get(id.slice(0, -2));
|
||||
const item = readEnvelope(Tx.decode(record.raw).vin[0].witness);
|
||||
return response(item.bytes, item.contentType);
|
||||
}
|
||||
if (kind === 'inscription') return response(event);
|
||||
// ord's metadata endpoint returns CBOR hex. Expose decoded JSON in inscription/index responses.
|
||||
if (kind === 'metadata') {
|
||||
const record = chain.transactions.get(id.slice(0, -2));
|
||||
const item = readEnvelope(Tx.decode(record.raw).vin[0].witness);
|
||||
return response(item.metadataBytes ? Array.from(item.metadataBytes, b => b.toString(16).padStart(2, '0')).join('') : null);
|
||||
}
|
||||
if (kind === 'parents') return response({ ids: event.parent ? [event.parent] : [] });
|
||||
if (kind === 'children') return response({ ids: index.events.filter(e => e.parent === id).map(e => e.id) });
|
||||
}
|
||||
return response({ error: 'Not found in this local lab.' }, undefined, 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Tx } from '@cmdcode/tapscript';
|
||||
import { readEnvelope } from './inscription.js';
|
||||
|
||||
// Rebuild solely from serialized transactions, never collectible/UI sidecars.
|
||||
// Discovery is a convention, not native ord metaprotocol tag 7 support.
|
||||
export class MetadataIndex {
|
||||
constructor(chain) {
|
||||
this.events = [];
|
||||
const visit = (raw, height, block) => {
|
||||
const tx = Tx.decode(raw);
|
||||
if (tx.vin[0]?.witness?.length !== 3) return;
|
||||
const item = readEnvelope(tx.vin[0].witness);
|
||||
const metadata = item.metadata;
|
||||
this.events.push({ id: `${Tx.util.getTxid(tx)}i0`, contentType: item.contentType,
|
||||
status: height === null ? 'pending' : 'confirmed', height, block,
|
||||
metadata: metadata ?? null, hasMetadata: metadata !== undefined, parent: item.parent,
|
||||
protocol: metadata && !Array.isArray(metadata) && typeof metadata === 'object' &&
|
||||
Object.hasOwn(metadata, 'protocol') && typeof metadata.protocol === 'string' ? metadata.protocol : null });
|
||||
};
|
||||
for (const block of chain.blocks) for (const raw of block.raws) visit(raw, block.height, block.hash);
|
||||
for (const raw of chain.mempool) visit(raw, null, null);
|
||||
}
|
||||
forProtocol(name) { return this.events.filter(e => e.protocol === name); }
|
||||
get protocols() { return [...new Set(this.events.filter(e => e.protocol !== null).map(e => e.protocol))].sort(); }
|
||||
get stats() { return { events: this.events.length, withMetadata: this.events.filter(e => e.hasMetadata).length,
|
||||
confirmed: this.events.filter(e => e.status === 'confirmed').length,
|
||||
pending: this.events.filter(e => e.status === 'pending').length, protocols: this.protocols }; }
|
||||
}
|
||||
|
||||
// Trusted, application-bundled reducers only. Adding a plugin requires a code change/review.
|
||||
// No eval, dynamic imports, inscription scripts, or interpretation in MetadataIndex.
|
||||
const reducers = Object.freeze({
|
||||
'event-counter': events => events.reduce((state, event) => ({
|
||||
count: state.count + 1, confirmed: state.confirmed + Number(event.status === 'confirmed'),
|
||||
pending: state.pending + Number(event.status === 'pending'), ids: [...state.ids, event.id],
|
||||
}), { count: 0, confirmed: 0, pending: 0, ids: [] }),
|
||||
});
|
||||
export function deriveProtocol(index, protocol, reducerName) {
|
||||
if (!Object.hasOwn(reducers, reducerName)) throw new Error('Unknown trusted local reducer.');
|
||||
return reducers[reducerName](structuredClone(index.forProtocol(protocol)));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// RFC 8949 CBOR for the JSON data model. No executable code or protocol semantics.
|
||||
export const MAX_METADATA_BYTES = 2048;
|
||||
const utf8 = new TextEncoder();
|
||||
export function encodeMetadata(value) {
|
||||
const out = [];
|
||||
const head = (major, n) => {
|
||||
if (n < 24) out.push(major * 32 + n);
|
||||
else if (n <= 255) out.push(major * 32 + 24, n);
|
||||
else if (n <= 65535) out.push(major * 32 + 25, n >>> 8, n & 255);
|
||||
else if (n <= 0xffffffff) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); out.push(major * 32 + 26, ...b); }
|
||||
else { out.push(major * 32 + 27); const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n)); out.push(...b); }
|
||||
};
|
||||
const write = (v, depth = 0) => {
|
||||
if (depth > 64) throw new Error('Metadata nesting exceeds 64 levels.');
|
||||
if (v === null) out.push(246);
|
||||
else if (typeof v === 'boolean') out.push(v ? 245 : 244);
|
||||
else if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
if (Number.isSafeInteger(v) && !Object.is(v, -0)) head(v >= 0 ? 0 : 1, v >= 0 ? v : -1 - v);
|
||||
else { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); out.push(251, ...b); }
|
||||
} else if (typeof v === 'string') { const b = utf8.encode(v); head(3, b.length); for (const byte of b) out.push(byte); }
|
||||
else if (Array.isArray(v)) { head(4, v.length); v.forEach(x => write(x, depth + 1)); }
|
||||
else if (typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype) {
|
||||
const entries = Object.entries(v); head(5, entries.length);
|
||||
for (const [k, x] of entries) { write(k, depth + 1); write(x, depth + 1); }
|
||||
} else throw new Error('Metadata must contain only JSON values.');
|
||||
if (out.length > MAX_METADATA_BYTES) throw new Error(`Metadata must be at most ${MAX_METADATA_BYTES} CBOR bytes.`);
|
||||
};
|
||||
write(value); return Uint8Array.from(out);
|
||||
}
|
||||
export function parseMetadataJSON(text) {
|
||||
if (!text.trim()) return undefined;
|
||||
let value;
|
||||
try { value = JSON.parse(text); } catch { throw new Error('Invalid metadata JSON.'); }
|
||||
encodeMetadata(value); return value;
|
||||
}
|
||||
export function decodeMetadata(bytes) {
|
||||
bytes = new Uint8Array(bytes);
|
||||
if (!bytes.length || bytes.length > MAX_METADATA_BYTES) throw new Error('Invalid CBOR metadata size.');
|
||||
let offset = 0;
|
||||
const take = n => { if (offset + n > bytes.length) throw new Error('Truncated CBOR metadata.'); const b = bytes.slice(offset, offset + n); offset += n; return b; };
|
||||
const read = (depth = 0) => {
|
||||
if (depth > 64) throw new Error('Metadata nesting exceeds 64 levels.');
|
||||
const first = take(1)[0], major = first >> 5, ai = first & 31;
|
||||
if (major === 7) {
|
||||
if (ai === 20) return false; if (ai === 21) return true; if (ai === 22) return null;
|
||||
if (ai === 27) { const n = new DataView(take(8).buffer).getFloat64(0); if (Number.isFinite(n)) return n; }
|
||||
throw new Error('Unsupported CBOR value.');
|
||||
}
|
||||
let n = ai;
|
||||
if (ai >= 24 && ai <= 27) { n = 0; for (const b of take(2 ** (ai - 24))) n = n * 256 + b; }
|
||||
else if (ai >= 28) throw new Error('Unsupported CBOR length.');
|
||||
if (!Number.isSafeInteger(n)) throw new Error('CBOR integer exceeds JSON precision.');
|
||||
if (major === 0) return n;
|
||||
if (major === 1) return -1 - n;
|
||||
if (major === 3) return new TextDecoder('utf-8', { fatal: true }).decode(take(n));
|
||||
if ((major === 4 || major === 5) && n > bytes.length) throw new Error('Invalid CBOR collection length.');
|
||||
if (major === 4) return Array.from({ length: n }, () => read(depth + 1));
|
||||
if (major === 5) {
|
||||
const result = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const key = read(depth + 1);
|
||||
if (typeof key !== 'string' || Object.hasOwn(result, key)) throw new Error('Invalid CBOR JSON map key.');
|
||||
Object.defineProperty(result, key, { value: read(depth + 1), enumerable: true, writable: true, configurable: true });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
throw new Error('Unsupported CBOR JSON type.');
|
||||
};
|
||||
const value = read(); if (offset !== bytes.length) throw new Error('Trailing CBOR metadata.'); return value;
|
||||
}
|
||||
+80
-21
@@ -1,9 +1,13 @@
|
||||
import './style.css';
|
||||
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
|
||||
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
|
||||
import { LocalOrdAPI } from './adapters/local-ord-api.js';
|
||||
import { mineLab, setMiner, minerStatus } from './miner.js';
|
||||
import { TestWallet } from './adapters/wallet.js';
|
||||
import { LocalRPC } from './adapters/chain.js';
|
||||
import { InscriptionAdapter, MAX_BYTES, MIME_TYPES } from './adapters/inscription.js';
|
||||
import { preview, clearPreviews } from './preview.js';
|
||||
import { STORAGE_KEY, readLab, saveLab, decodeLab, encodeLab } from './storage.js';
|
||||
import { STORAGE_KEY, readLab, mutateLab, decodeLab, encodeLab } from './storage.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const btc = sats => (sats / 1e8).toFixed(8);
|
||||
@@ -47,13 +51,13 @@ function download(name, contents) {
|
||||
function closeReview() {
|
||||
reviewed = null; $('review-panel').hidden = true;
|
||||
clearPreviews($('review-preview')); $('review-preview').replaceChildren();
|
||||
$('review-text').textContent = ''; $('review-parent').textContent = '';
|
||||
$('review-metadata').textContent = ''; $('review-text').textContent = ''; $('review-parent').textContent = '';
|
||||
}
|
||||
const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileBytes ?? new TextEncoder().encode($('payload').value) });
|
||||
function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
|
||||
function resetComposer() {
|
||||
fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
|
||||
$('parent').value = ''; closeReview(); updateCount();
|
||||
$('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
|
||||
}
|
||||
function lineage(item, chain) {
|
||||
const p = el('p', undefined, 'tx-links');
|
||||
@@ -64,6 +68,54 @@ function lineage(item, chain) {
|
||||
}
|
||||
|
||||
|
||||
function updateMetadataCount() {
|
||||
try {
|
||||
const value = parseMetadataJSON($('metadata').value);
|
||||
$('metadata-count').textContent = `${value === undefined ? 0 : encodeMetadata(value).length} / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only`;
|
||||
} catch (error) { $('metadata-count').textContent = `${error.message} Limit: 2048 CBOR bytes.`; }
|
||||
}
|
||||
function metadataView(item) {
|
||||
const node = el('div', undefined, 'metadata-document');
|
||||
if (item.metadata !== undefined) node.append(el('strong', 'Decoded metadata'), el('pre', JSON.stringify(item.metadata, null, 2)));
|
||||
return node;
|
||||
}
|
||||
function renderIndexes(chain) {
|
||||
if (!chain) return;
|
||||
const index = new MetadataIndex(chain), selected = $('protocol').value;
|
||||
const stats = index.stats;
|
||||
$('index-stats').textContent = `Metadata index: ${stats.events} events · ${stats.withMetadata} with metadata · ${stats.confirmed} confirmed · ${stats.pending} pending · ${stats.protocols.length} protocols`;
|
||||
$('protocol').replaceChildren(new Option('Select a protocol', ''));
|
||||
index.protocols.forEach(name => $('protocol').append(new Option(JSON.stringify(name), JSON.stringify(name))));
|
||||
if ([...$('protocol').options].some(o => o.value === selected)) $('protocol').value = selected;
|
||||
const protocol = $('protocol').value ? JSON.parse($('protocol').value) : null;
|
||||
const events = protocol === null ? [] : index.forProtocol(protocol);
|
||||
$('protocol-stats').textContent = `${events.length} events · ${events.filter(e => e.status === 'confirmed').length} confirmed · ${events.filter(e => e.status === 'pending').length} pending`;
|
||||
$('protocol-events').replaceChildren();
|
||||
for (const event of events) {
|
||||
const row = el('article'); row.append(link(short(event.id), `#inscription/${event.id}`), el('pre', JSON.stringify(event, null, 2)));
|
||||
$('protocol-events').append(row);
|
||||
}
|
||||
$('derived-state').textContent = !$('reducer').value ? 'No reducer selected.' : protocol === null ? 'Select a protocol first.' : JSON.stringify(deriveProtocol(index, protocol, $('reducer').value), null, 2);
|
||||
}
|
||||
$('protocol').onchange = $('reducer').onchange = () => renderIndexes(lab.chain);
|
||||
const apiPresets = ['/r/blockheight', '/r/blockhash/1', '/r/blockinfo/1', '/r/blockinfo/:hash', '/content/:id', '/r/inscription/:id', '/r/metadata/:id', '/r/parents/:id', '/r/children/:id', '/r/tx/:txid', '/local/metadata-index', '/local/protocols', '/local/protocol?name=example'];
|
||||
apiPresets.forEach(path => $('api-preset').append(new Option(path, path)));
|
||||
$('api-preset').onchange = () => {
|
||||
const event = new MetadataIndex(lab.chain).events.at(-1);
|
||||
$('api-path').value = $('api-preset').value.replace(':id', event?.id ?? ':id').replace(':txid', event?.id.slice(0, -2) ?? ':txid').replace(':hash', lab.chain.blocks.at(-1)?.hash ?? ':hash');
|
||||
};
|
||||
$('api-try').onclick = () => {
|
||||
try {
|
||||
const response = new LocalOrdAPI(lab.chain).request($('api-path').value);
|
||||
const binary = response.body instanceof Uint8Array;
|
||||
$('api-response').textContent = JSON.stringify({ ...response, ...(binary ? { body: Array.from(response.body), encoding: 'byte array', textPreview: new TextDecoder().decode(response.body) } : {}) }, null, 2);
|
||||
} catch (error) { $('api-response').textContent = error.message; }
|
||||
};
|
||||
$('api-copy').onclick = async () => {
|
||||
try { await navigator.clipboard.writeText($('api-response').textContent); message('Local API response copied.'); }
|
||||
catch (error) { message(`Could not copy response: ${error.message}`, true); }
|
||||
};
|
||||
|
||||
function renderMempool(chain) {
|
||||
const pane = $('mempool-list');
|
||||
pane.replaceChildren();
|
||||
@@ -107,8 +159,11 @@ function render() {
|
||||
$('wallet-state').textContent = wallet ? 'LOCAL / TEST ONLY' : 'NOT CREATED';
|
||||
$('create-wallet').disabled = disabled; $('load-wallet').disabled = disabled;
|
||||
$('mine').disabled = disabled || !wallet;
|
||||
$('miner-interval').disabled = disabled || !wallet;
|
||||
$('miner-interval').value = String(lab?.miner?.interval ?? 0);
|
||||
$('miner-status').textContent = minerStatus(lab);
|
||||
$('payload').disabled = disabled || !wallet || !!fileBytes || fileLoading || $('mime-type').value.startsWith('image/') && $('mime-type').value !== 'image/svg+xml';
|
||||
for (const id of ['mime-type', 'body-file', 'parent']) $(id).disabled = disabled || !wallet || fileLoading;
|
||||
for (const id of ['mime-type', 'body-file', 'parent', 'metadata']) $(id).disabled = disabled || !wallet || fileLoading;
|
||||
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || fileLoading || !composerBody().bytes.length;
|
||||
$('sign').disabled = disabled;
|
||||
$('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
|
||||
@@ -140,8 +195,9 @@ function render() {
|
||||
const trail = el('p', undefined, 'tx-links');
|
||||
trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
|
||||
if (item.height) trail.append(link(`View block ${item.height} ↗`, `#block/${chain.blocks[item.height - 1].hash}`));
|
||||
card.append(lineage(item, chain), trail); $('collectibles').append(card);
|
||||
card.append(metadataView(item), lineage(item, chain), trail); $('collectibles').append(card);
|
||||
}
|
||||
renderIndexes(chain);
|
||||
renderMempool(chain);
|
||||
renderBlocks(chain);
|
||||
inspect(false);
|
||||
@@ -151,13 +207,8 @@ async function mutate(action, success, onSuccess = () => {}) {
|
||||
if (busy || failed) return;
|
||||
busy = true; render();
|
||||
try {
|
||||
if (!navigator.locks) throw new Error('This app needs a secure-context browser with Web Locks. Use localhost or HTTPS.');
|
||||
await navigator.locks.request(STORAGE_KEY, async () => {
|
||||
const candidate = readLab();
|
||||
await action(candidate);
|
||||
saveLab(candidate); lab = candidate;
|
||||
});
|
||||
closeReview(); onSuccess(); message(success);
|
||||
const result = await mutateLab(action); lab = result.lab;
|
||||
if (result.changed) { closeReview(); onSuccess(); message(success); }
|
||||
} catch (error) { message(error.message, true); }
|
||||
finally { busy = false; render(); }
|
||||
}
|
||||
@@ -167,10 +218,17 @@ $('create-wallet').onclick = () => mutate(next => {
|
||||
if (next.wallet) throw new Error('A wallet already exists in this browser. Reload to use it.');
|
||||
next.wallet = TestWallet.create();
|
||||
}, 'Test wallet created. Next: mine a block to get 50 fake BTC.');
|
||||
$('mine').onclick = () => mutate(next => {
|
||||
if (!next.wallet) throw new Error('Create or load a wallet first.');
|
||||
next.chain.mine(next.wallet.script);
|
||||
}, 'Block mined. Pending transactions moved from the mempool into your newest block.');
|
||||
const mine = automatic => mutate(next => mineLab(next, automatic), 'Block mined. Pending transactions moved from the mempool into your newest block.');
|
||||
$('mine').onclick = () => mine(false);
|
||||
$('miner-interval').onchange = () => {
|
||||
const interval = Number($('miner-interval').value);
|
||||
mutate(next => setMiner(next, interval), interval ? `Auto miner set to ${interval}s.` : 'Auto mining OFF');
|
||||
};
|
||||
setInterval(() => {
|
||||
$('miner-status').textContent = minerStatus(lab);
|
||||
if (!busy && !failed && lab?.wallet && lab.miner.interval && Date.now() >= lab.miner.nextAt) mine(true);
|
||||
}, 250);
|
||||
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
|
||||
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
|
||||
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
|
||||
$('parent').onchange = closeReview;
|
||||
@@ -194,13 +252,14 @@ $('body-file').onchange = async event => {
|
||||
};
|
||||
$('review').onclick = () => {
|
||||
try {
|
||||
const payload = composerBody(), parent = $('parent').value || null;
|
||||
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent);
|
||||
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
|
||||
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
|
||||
closeReview();
|
||||
reviewed = { payload, parent, address: lab.wallet.address };
|
||||
reviewed = { payload, parent, metadata, address: lab.wallet.address };
|
||||
$('review-text').textContent = ['text/plain', 'application/json'].includes(payload.contentType) ? new TextDecoder().decode(payload.bytes) : `${payload.contentType} · ${payload.bytes.length} bytes`;
|
||||
if (!['text/plain', 'application/json'].includes(payload.contentType)) $('review-preview').append(preview(payload));
|
||||
$('review-parent').textContent = parent ? `Parent: ${parent}. Its output will be spent and its full value returned to this wallet. This relationship is immutable.` : 'No parent.';
|
||||
$('review-metadata').textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${JSON.stringify(metadata, null, 2)}`;
|
||||
$('review-panel').hidden = false;
|
||||
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
|
||||
} catch (error) { message(error.message, true); }
|
||||
@@ -211,7 +270,7 @@ $('sign').onclick = () => {
|
||||
if (!consent) return;
|
||||
mutate(next => {
|
||||
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
|
||||
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent);
|
||||
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata);
|
||||
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.', resetComposer);
|
||||
};
|
||||
$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
|
||||
@@ -277,7 +336,7 @@ function inspect(scroll = true) {
|
||||
if (record.inscription) {
|
||||
add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
|
||||
add('Body size', `${record.inscription.bytes.length} bytes`);
|
||||
pane.append(preview(record.inscription), lineage(record.inscription, chain));
|
||||
pane.append(preview(record.inscription), metadataView(record.inscription), lineage(record.inscription, chain));
|
||||
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
|
||||
}
|
||||
pane.append(el('h3', 'Inputs → outputs'));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const MINER_INTERVALS = [0, 10, 30, 60];
|
||||
export function minerPreference(value = { interval: 0, nextAt: null }) {
|
||||
if (!value || !MINER_INTERVALS.includes(value.interval) ||
|
||||
(value.interval === 0 ? value.nextAt !== null : !Number.isSafeInteger(value.nextAt) || value.nextAt <= 0)) throw new Error('Invalid miner preference.');
|
||||
return { interval: value.interval, nextAt: value.nextAt };
|
||||
}
|
||||
export function setMiner(lab, interval, now = Date.now()) {
|
||||
lab.miner = minerPreference({ interval, nextAt: interval ? now + interval * 1000 : null });
|
||||
}
|
||||
export function minerStatus(lab, now = Date.now()) {
|
||||
if (!lab?.wallet) return 'Create or load a wallet to mine.';
|
||||
if (!lab.miner?.interval) return 'Auto mining OFF';
|
||||
return `Next block in ${Math.max(0, Math.ceil((lab.miner.nextAt - now) / 1000))}s · ${lab.miner.interval}s interval`;
|
||||
}
|
||||
// Called inside the shared mutation lock, after reading latest state. One overdue
|
||||
// block at most; inactive/suspended tabs do not accumulate catch-up rewards.
|
||||
export function mineLab(lab, automatic = false, now = Date.now()) {
|
||||
if (!lab.wallet) { if (automatic) return false; throw new Error('Create or load a wallet first.'); }
|
||||
if (automatic && (!lab.miner?.interval || now < lab.miner.nextAt)) return false;
|
||||
lab.chain.mine(lab.wallet.script);
|
||||
setMiner(lab, lab.miner?.interval ?? 0, now);
|
||||
return true;
|
||||
}
|
||||
+15
-3
@@ -1,18 +1,30 @@
|
||||
import { minerPreference } from './miner.js';
|
||||
import { LocalChain } from './adapters/chain.js';
|
||||
import { TestWallet, NETWORK } from './adapters/wallet.js';
|
||||
export const STORAGE_KEY = 'super-testnet:lab:v1';
|
||||
export function decodeLab(raw) {
|
||||
if (!raw) return { wallet: null, chain: new LocalChain() };
|
||||
if (!raw) return { wallet: null, chain: new LocalChain(), miner: minerPreference() };
|
||||
const data = JSON.parse(raw);
|
||||
if (data.kind !== 'test-lab' || data.network !== NETWORK || data.version !== 1) throw new Error('Unsupported lab backup.');
|
||||
return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain) };
|
||||
return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain), miner: minerPreference(data.miner) };
|
||||
}
|
||||
export const encodeLab = lab => JSON.stringify({
|
||||
kind: 'test-lab', network: NETWORK, version: 1,
|
||||
wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(),
|
||||
wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(), miner: minerPreference(lab.miner),
|
||||
});
|
||||
export const readLab = () => decodeLab(localStorage.getItem(STORAGE_KEY));
|
||||
export function saveLab(lab) {
|
||||
try { localStorage.setItem(STORAGE_KEY, encodeLab(lab)); }
|
||||
catch { throw new Error('Browser storage is full or unavailable. No changes were saved. Export a lab backup before clearing storage.'); }
|
||||
}
|
||||
|
||||
// Shared by manual mining, auto mining, and all other lab mutations.
|
||||
export async function mutateLab(action, { locks = navigator.locks, read = readLab, save = saveLab } = {}) {
|
||||
if (!locks) throw new Error('This app needs a secure-context browser with Web Locks. Use localhost or HTTPS.');
|
||||
return locks.request(STORAGE_KEY, async () => {
|
||||
const candidate = read();
|
||||
const changed = await action(candidate);
|
||||
if (changed !== false) save(candidate);
|
||||
return { lab: candidate, changed: changed !== false };
|
||||
});
|
||||
}
|
||||
@@ -1 +1,8 @@
|
||||
:root{font-family:Arial,Helvetica,sans-serif;font-synthesis:none;--orange:#f7931a;--orange-2:#ffad42;--bg:#0f1113;--panel:#171a1e;--panel-2:#20242a;--text:#f3f5f7;--muted:#9ca4ad;--line:#30363d;--soft:#23272e;--danger:#ff9b8d;color:var(--text);background:var(--bg)}:root[data-theme="light"]{--bg:#f5f3ef;--panel:#fffdf9;--panel-2:#f0ece5;--text:#1a1a1a;--muted:#6f6a63;--line:#d8d1c8;--soft:#ece7df;--danger:#a54536}*{box-sizing:border-box}html{background:var(--bg);color-scheme:dark}html[data-theme="light"]{color-scheme:light}body{margin:0;background:var(--bg);color:var(--text);transition:background .18s,color .18s}a{color:var(--orange);text-underline-offset:4px}button,input,textarea{font:inherit}.file-button,button{cursor:pointer}button{border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--text);padding:12px 17px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}button:hover:not(:disabled){border-color:var(--orange);transform:translateY(-1px)}button:disabled{opacity:.4;cursor:not-allowed;transform:none}button.primary{background:var(--orange);color:#1c1207;border-color:var(--orange)}button.primary:hover:not(:disabled){background:var(--orange-2)}.primary span{float:right;margin-left:20px}.quiet{background:transparent;border-color:var(--line);font-size:13px}button:focus-visible,a:focus-visible,summary:focus-visible,.file-button:focus-visible{outline:3px solid var(--orange-2);outline-offset:4px}header{max-width:1240px;margin:auto;padding:24px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{text-decoration:none;color:var(--text);display:flex;align-items:center;gap:10px;font-size:15px;font-weight:800;letter-spacing:1px}.mark{display:inline-block;flex-shrink:0;background:var(--orange);width:34px;height:34px;border-radius:8px}.version{font:11px monospace;background:var(--soft);color:var(--orange);border-radius:4px;padding:3px 5px}.header-actions{display:flex;align-items:center;gap:14px}.network{font:11px monospace;color:var(--muted);letter-spacing:1px}.network i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--orange);margin-right:6px}.theme-toggle{padding:8px 10px;font:11px monospace}main{max-width:1176px;padding:0 0 10px;margin:auto}.hero{display:flex;justify-content:space-between;align-items:center;padding:53px 0 36px}.eyebrow{font:11px monospace;letter-spacing:1.8px;color:var(--orange);margin:0 0 19px}h1{font-size:clamp(42px,5.5vw,70px);font-weight:500;line-height:1.02;letter-spacing:-3px;margin:0 0 24px}.intro{color:var(--muted);font-size:16px;line-height:1.65}.orbit{width:255px;height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:36px;background:radial-gradient(ellipse,color-mix(in srgb,var(--orange) 16%,transparent),transparent 70%);margin-right:65px}.cube{display:grid;place-items:center;width:110px;height:110px;border:1px solid var(--orange);box-shadow:13px 13px 0 color-mix(in srgb,var(--orange) 18%,var(--panel)),14px 14px 0 color-mix(in srgb,var(--orange) 48%,var(--panel));transform:rotate(-12deg);background:var(--panel-2);color:var(--orange)}.orbit span{font:10px monospace;letter-spacing:2px;color:var(--muted)}.notice{background:color-mix(in srgb,var(--orange) 7%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 24%,var(--line));border-radius:8px;padding:14px 18px;font-size:12px;color:var(--muted);line-height:1.6}.notice strong{color:var(--text)}#status{min-height:52px;padding:16px 2px;font-size:13px;color:var(--orange)}#status.error{color:var(--danger)}.workspace{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;min-width:0}.panel-title{display:flex;align-items:center;gap:10px;margin-bottom:22px}.panel-title h2{font-size:17px;margin:0;font-weight:500;letter-spacing:-.4px}.step{font:11px monospace;color:var(--orange);background:color-mix(in srgb,var(--orange) 10%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 28%,var(--line));border-radius:6px;padding:7px}.tag{margin-left:auto;font:9px monospace;letter-spacing:1px;color:var(--muted);border:1px solid var(--line);padding:5px;border-radius:4px}.panel p{line-height:1.6;font-size:14px;color:var(--muted)}.label{display:block;font:10px monospace!important;letter-spacing:1.5px;color:var(--muted);margin:0 0 10px}.balance{display:flex;align-items:baseline;gap:12px;font-size:37px;letter-spacing:-1px;font-variant-numeric:tabular-nums}.balance small{color:var(--orange);font-size:13px;letter-spacing:1px}.muted{color:var(--muted)!important;font-size:12px!important;line-height:1.65}label{display:block;font-size:12px;color:var(--muted);margin:14px 0 9px}input,textarea{width:100%;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:12px;outline:none}input{font:11px monospace}textarea{resize:vertical;min-height:100px;line-height:1Line truncated
|
||||
|
||||
#indexes, #api-explorer { margin-top: 2rem; }
|
||||
#protocol-events { max-height: 30rem; overflow: auto; }
|
||||
#protocol-events article { padding: 1rem 0; border-bottom: 1px solid var(--line); }
|
||||
#api-path { width: 100%; margin-bottom: 1rem; }
|
||||
#api-response, #derived-state, .metadata-document pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto; }
|
||||
#metadata { width: 100%; }
|
||||
Reference in new issue
Block a user