2026-10-04 23:24:48 +00:00
import './style.css' ;
2026-10-05 02:33:54 +00:00
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' ;
2026-10-04 23:24:48 +00:00
import { TestWallet } from './adapters/wallet.js' ;
import { LocalRPC } from './adapters/chain.js' ;
2026-10-05 01:23:21 +00:00
import { InscriptionAdapter , MAX_BYTES , MIME_TYPES } from './adapters/inscription.js' ;
import { preview , clearPreviews } from './preview.js' ;
2026-10-05 02:33:54 +00:00
import { STORAGE_KEY , readLab , mutateLab , decodeLab , encodeLab } from './storage.js' ;
2026-10-04 23:24:48 +00:00
const $ = id => document . getElementById ( id );
const btc = sats => ( sats / 1e8 ). toFixed ( 8 );
const short = id => ` ${ id . slice ( 0 , 12 ) } … ${ id . slice ( - 8 ) } ` ;
2026-10-05 00:56:42 +00:00
const THEME_KEY = 'super-testnet-theme' ;
2026-10-05 01:23:21 +00:00
let fileBytes = null , fileLoading = false , fileRevision = 0 ;
2026-10-04 23:24:48 +00:00
let lab , busy = false , reviewed = null , failed = false ;
2026-10-05 00:56:42 +00:00
2026-10-04 23:24:48 +00:00
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 ; };
2026-10-05 00:56:42 +00:00
const byteSize = raws => Math . round ( raws . reduce (( sum , raw ) => sum + raw . length / 2 , 0 ));
const age = time => {
const seconds = Math . max ( 0 , Math . floor (( Date . now () - time ) / 1000 ));
if ( seconds < 5 ) return 'just now' ;
if ( seconds < 60 ) return ` ${ seconds } s ago` ;
const minutes = Math . floor ( seconds / 60 );
if ( minutes < 60 ) return ` ${ minutes } m ago` ;
return ` ${ Math . floor ( minutes / 60 ) } h ago` ;
};
function txKind ( record , chain ) {
if ( record . coinbase ) return 'COINBASE' ;
if ( record . inscription ) return 'INSCRIPTION REVEAL' ;
const reveal = [... chain . transactions . values ()]. find ( tx => tx . inscription ? . commitTxid === record . txid );
return reveal ? 'INSCRIPTION COMMIT' : 'TRANSACTION' ;
}
function setTheme ( theme , persist = false ) {
document . documentElement . dataset . theme = theme ;
$ ( 'theme-toggle' ). textContent = theme === 'dark' ? '☀ Light' : '☾ Dark' ;
$ ( 'theme-toggle' ). setAttribute ( 'aria-label' , `Switch to ${ theme === 'dark' ? 'light' : 'dark' } mode` );
if ( persist ) localStorage . setItem ( THEME_KEY , theme );
}
function initTheme () {
const saved = localStorage . getItem ( THEME_KEY );
2026-10-05 01:29:49 +00:00
setTheme ( saved === 'light' || saved === 'dark' ? saved : 'dark' );
2026-10-05 00:56:42 +00:00
}
2026-10-04 23:24:48 +00:00
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 );
}
2026-10-05 01:23:21 +00:00
function closeReview () {
reviewed = null ; $ ( 'review-panel' ). hidden = true ;
clearPreviews ( $ ( 'review-preview' )); $ ( 'review-preview' ). replaceChildren ();
2026-10-05 02:33:54 +00:00
$ ( 'review-metadata' ). textContent = '' ; $ ( 'review-text' ). textContent = '' ; $ ( 'review-parent' ). textContent = '' ;
2026-10-05 01:23:21 +00:00
}
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 = '' ;
2026-10-05 02:33:54 +00:00
$ ( 'metadata' ). value = '' ; updateMetadataCount (); $ ( 'parent' ). value = '' ; closeReview (); updateCount ();
2026-10-05 01:23:21 +00:00
}
function lineage ( item , chain ) {
const p = el ( 'p' , undefined , 'tx-links' );
if ( item . parent ) p . append ( link ( `Parent ${ short ( item . parent ) } ` , `#inscription/ ${ item . parent } ` ));
for ( const record of chain . transactions . values ()) if ( record . inscription ? . parent === item . id )
p . append ( link ( `Child ${ short ( record . inscription . id ) } ` , `#inscription/ ${ record . inscription . id } ` ));
return p ;
}
2026-10-05 00:56:42 +00:00
2026-10-05 02:33:54 +00:00
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 ); }
};
2026-10-05 00:56:42 +00:00
function renderMempool ( chain ) {
const pane = $ ( 'mempool-list' );
pane . replaceChildren ();
const pending = [...( chain ? . transactions ? . values ? .() ?? [])]. filter ( record => ! record . height && ! record . coinbase );
if ( ! pending . length ) {
pane . append ( el ( 'div' , 'Mempool empty. Create an inscription and its commit + reveal transactions will wait here until you mine.' , 'mempool-empty' ));
return ;
}
for ( const record of pending ) {
const button = el ( 'button' , undefined , 'mempool-card' );
button . append ( el ( 'strong' , txKind ( record , chain )));
button . append ( el ( 'span' , short ( record . txid )));
button . append ( el ( 'span' , ` ${ Math . round ( record . raw . length / 2 ). toLocaleString () } bytes · 500 fake sat fee` ));
2026-10-05 01:23:21 +00:00
if ( record . inscription ) button . append ( el ( 'span' , ` ${ record . inscription . contentType } · ${ record . inscription . bytes . length } bytes` ));
2026-10-05 00:56:42 +00:00
button . onclick = () => { location . hash = `tx/ ${ record . txid } ` ; };
pane . append ( button );
}
}
function renderBlocks ( chain ) {
const pane = $ ( 'blocks' );
pane . replaceChildren ();
if ( ! chain ? . blocks . length ) {
pane . append ( el ( 'p' , 'No blocks yet. Mine your first one above.' , 'muted' ));
return ;
}
for ( const block of chain . blocks . toReversed ()) {
const button = el ( 'button' , undefined , 'block-card' );
button . append ( el ( 'span' , `HEIGHT ${ block . height } ` , 'block-accent' ));
button . append ( el ( 'strong' , ` ${ block . raws . length } tx` ));
button . append ( el ( 'span' , ` ${ byteSize ( block . raws ). toLocaleString () } bytes · ${ age ( block . time ) } ` ));
button . append ( el ( 'span' , short ( block . hash )));
button . onclick = () => { location . hash = `block/ ${ block . hash } ` ; };
pane . append ( button );
}
}
2026-10-04 23:24:48 +00:00
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 ;
2026-10-05 02:33:54 +00:00
$ ( 'miner-interval' ). disabled = disabled || ! wallet ;
$ ( 'miner-interval' ). value = String ( lab ? . miner ? . interval ?? 0 );
$ ( 'miner-status' ). textContent = minerStatus ( lab );
2026-10-05 01:23:21 +00:00
$ ( 'payload' ). disabled = disabled || ! wallet || !! fileBytes || fileLoading || $ ( 'mime-type' ). value . startsWith ( 'image/' ) && $ ( 'mime-type' ). value !== 'image/svg+xml' ;
2026-10-05 02:33:54 +00:00
for ( const id of [ 'mime-type' , 'body-file' , 'parent' , 'metadata' ]) $ ( id ). disabled = disabled || ! wallet || fileLoading ;
2026-10-05 01:23:21 +00:00
$ ( 'review' ). disabled = disabled || ! wallet || ! chain ? . coins ( wallet . script ). some ( c => ! c . inscription ) || fileLoading || ! composerBody (). bytes . length ;
2026-10-04 23:24:48 +00:00
$ ( '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 () : [];
2026-10-05 01:23:21 +00:00
const selectedParent = $ ( 'parent' ). value ;
$ ( 'parent' ). replaceChildren ( new Option ( 'No parent' , '' ));
items . forEach ( item => $ ( 'parent' ). append ( new Option ( short ( item . id ), item . id )));
$ ( 'parent' ). value = selectedParent ;
2026-10-04 23:24:48 +00:00
$ ( 'collection-count' ). textContent = items . length ;
2026-10-05 01:23:21 +00:00
clearPreviews ( $ ( 'collectibles' ));
2026-10-04 23:24:48 +00:00
$ ( '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' );
2026-10-05 01:23:21 +00:00
card . append ( el ( 'span' , item . height ? `CONFIRMED · BLOCK ${ item . height } ` : 'PENDING · MINE TO CONFIRM' , 'tag' ), preview ( item ));
2026-10-04 23:24:48 +00:00
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 } ` ));
2026-10-05 02:33:54 +00:00
card . append ( metadataView ( item ), lineage ( item , chain ), trail ); $ ( 'collectibles' ). append ( card );
2026-10-04 23:24:48 +00:00
}
2026-10-05 02:33:54 +00:00
renderIndexes ( chain );
2026-10-05 00:56:42 +00:00
renderMempool ( chain );
renderBlocks ( chain );
2026-10-04 23:24:48 +00:00
inspect ( false );
}
2026-10-05 00:56:42 +00:00
2026-10-05 01:23:21 +00:00
async function mutate ( action , success , onSuccess = () => {}) {
2026-10-04 23:24:48 +00:00
if ( busy || failed ) return ;
busy = true ; render ();
try {
2026-10-05 02:33:54 +00:00
const result = await mutateLab ( action ); lab = result . lab ;
if ( result . changed ) { closeReview (); onSuccess (); message ( success ); }
2026-10-04 23:24:48 +00:00
} catch ( error ) { message ( error . message , true ); }
finally { busy = false ; render (); }
}
2026-10-05 00:56:42 +00:00
$ ( 'theme-toggle' ). onclick = () => setTheme ( document . documentElement . dataset . theme === 'dark' ? 'light' : 'dark' , true );
2026-10-04 23:24:48 +00:00
$ ( '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.' );
2026-10-05 02:33:54 +00:00
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 (); };
2026-10-05 01:23:21 +00:00
MIME_TYPES . forEach ( type => $ ( 'mime-type' ). append ( new Option ( type , type )));
$ ( 'payload' ). oninput = () => { closeReview (); updateCount (); render (); };
$ ( 'parent' ). onchange = closeReview ;
$ ( 'mime-type' ). onchange = () => {
fileRevision ++ ; fileBytes = null ; $ ( 'body-file' ). value = '' ; closeReview (); updateCount (); render ();
};
$ ( 'body-file' ). accept = MIME_TYPES . join ( ',' );
$ ( 'body-file' ). onchange = async event => {
const file = event . target . files [ 0 ], revision = ++ fileRevision ;
closeReview (); fileBytes = null ;
if ( ! file ) { updateCount (); render (); return ; }
fileLoading = true ; render ();
try {
if ( file . size > MAX_BYTES ) throw new Error ( `Body must be at most ${ MAX_BYTES } bytes.` );
if ( ! MIME_TYPES . includes ( file . type )) throw new Error ( 'Unsupported file MIME type.' );
const bytes = new Uint8Array ( await file . arrayBuffer ());
if ( revision !== fileRevision ) return ;
fileBytes = bytes ; $ ( 'mime-type' ). value = file . type ; $ ( 'payload' ). value = '' ;
} catch ( error ) { $ ( 'body-file' ). value = '' ; message ( error . message , true ); }
finally { fileLoading = false ; updateCount (); render (); }
2026-10-04 23:24:48 +00:00
};
$ ( 'review' ). onclick = () => {
try {
2026-10-05 02:33:54 +00:00
const payload = composerBody (), parent = $ ( 'parent' ). value || null , metadata = parseMetadataJSON ( $ ( 'metadata' ). value );
new InscriptionAdapter ( lab . chain , lab . wallet ). prepare ( payload , parent , metadata );
2026-10-05 01:23:21 +00:00
closeReview ();
2026-10-05 02:33:54 +00:00
reviewed = { payload , parent , metadata , address : lab . wallet . address };
2026-10-05 01:23:21 +00:00
$ ( '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.' ;
2026-10-05 02:33:54 +00:00
$ ( 'review-metadata' ). textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${ JSON . stringify ( metadata , null , 2 ) } ` ;
2026-10-05 01:23:21 +00:00
$ ( 'review-panel' ). hidden = false ;
2026-10-04 23:24:48 +00:00
$ ( '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.' );
2026-10-05 02:33:54 +00:00
new InscriptionAdapter ( next . chain , next . wallet ). inscribe ( consent . payload , consent . parent , consent . metadata );
2026-10-05 01:23:21 +00:00
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.' , resetComposer );
2026-10-04 23:24:48 +00:00
};
$ ( '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 ( '/' );
2026-10-05 01:23:21 +00:00
const pane = $ ( 'inspector' ); clearPreviews ( pane ); pane . replaceChildren (); pane . hidden = ! kind ;
2026-10-04 23:24:48 +00:00
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 ]);
2026-10-05 00:56:42 +00:00
const source = chain . blocks . find ( candidate => candidate . hash === id );
pane . append ( el ( 'h2' , `Block ${ block . height } ` ));
add ( 'Local block hash' , block . hash );
if ( block . previousblockhash && block . height > 1 ) {
const previous = chain . blocks [ block . height - 2 ];
const p = el ( 'p' ); p . append ( el ( 'strong' , 'Previous block: ' ), link ( short ( block . previousblockhash ), `#block/ ${ previous . hash } ` )); pane . append ( p );
} else add ( 'Previous block' , block . previousblockhash );
2026-10-04 23:24:48 +00:00
add ( 'Mined' , new Date ( block . time * 1000 ). toISOString ());
2026-10-05 00:56:42 +00:00
add ( 'Transactions' , block . tx . length );
add ( 'Approx. serialized size' , ` ${ byteSize ( source . raws ). toLocaleString () } bytes` );
pane . append ( el ( 'p' , 'Synthetic local block; not a Bitcoin block header and no proof-of-work is performed.' , 'muted' ));
pane . append ( el ( 'h3' , 'Transactions' ));
for ( const txid of block . tx ) {
const record = chain . transactions . get ( txid ), p = el ( 'p' );
p . append ( link ( ` ${ txKind ( record , chain ) } · ${ short ( txid ) } ` , `#tx/ ${ txid } ` )); pane . append ( p );
}
2026-10-04 23:24:48 +00:00
} 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' ));
2026-10-05 00:56:42 +00:00
add ( 'Type' , txKind ( record , chain ));
add ( 'Transaction ID' , txid );
add ( 'Status' , record . height ? ` ${ chain . blocks . length - record . height + 1 } confirmation(s)` : 'Pending in mempool — mine a block' );
add ( 'Serialized size' , ` ${ Math . round ( record . raw . length / 2 ). toLocaleString () } bytes` );
2026-10-04 23:24:48 +00:00
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 );
2026-10-05 01:23:21 +00:00
add ( 'Body size' , ` ${ record . inscription . bytes . length } bytes` );
2026-10-05 02:33:54 +00:00
pane . append ( preview ( record . inscription ), metadataView ( record . inscription ), lineage ( record . inscription , chain ));
2026-10-04 23:24:48 +00:00
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 ();
}
2026-10-05 00:56:42 +00:00
initTheme ();
load ();