2026-10-04 23:24:48 +00:00
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 ) } ` ;
2026-10-05 00:56:42 +00:00
const THEME_KEY = 'super-testnet-theme' ;
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 );
setTheme ( saved === 'light' || saved === 'dark' ? saved : ( matchMedia ( '(prefers-color-scheme: light)' ). matches ? 'light' : 'dark' ));
}
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 );
}
function closeReview () { reviewed = null ; $ ( 'review-panel' ). hidden = 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` ));
if ( record . inscription ) button . append ( el ( 'span' , `“ ${ record . inscription . text . slice ( 0 , 34 ) }${ record . inscription . text . length > 34 ? '…' : '' } ”` ));
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 ;
$ ( '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 );
}
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-04 23:24:48 +00:00
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 (); }
}
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.' );
$ ( 'mine' ). onclick = () => mutate ( next => {
if ( ! next . wallet ) throw new Error ( 'Create or load a wallet first.' );
next . chain . mine ( next . wallet . script );
2026-10-05 00:56:42 +00:00
}, 'Block mined. Pending transactions moved from the mempool into your newest block.' );
2026-10-04 23:24:48 +00:00
$ ( '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 );
2026-10-05 00:56:42 +00:00
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.' );
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 ( '/' );
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 ]);
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 );
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 ();
}
2026-10-05 00:56:42 +00:00
initTheme ();
load ();