feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
// Wallet panel. All state comes from the add-on's activate() context via
// window.silentmode.invoke / on; this file only renders and collects input.
const $ = ( id ) => document . getElementById ( id ) ;
const S = window . silentmode ;
let state = null ;
let tab = "receive" ;
// 8 decimals, trailing zeros trimmed, never fewer than two: 0.00, 0.001, 1.23456789
function fmtBch ( sats ) {
let s = ( Number ( sats || 0 ) / 1e8 ) . toFixed ( 8 ) . replace ( /0+$/ , "" ) ;
if ( s . endsWith ( "." ) ) s += "00" ; else if ( /\.\d$/ . test ( s ) ) s += "0" ;
return s ;
}
const fmtSats = ( sats ) => Number ( sats || 0 ) . toLocaleString ( "en-US" ) ;
const esc = ( s ) => String ( s ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } ) [ c ] ) ;
const hostOf = ( url ) => { try { return new URL ( url ) . host ; } catch { return url ; } } ;
const openUrl = ( url ) => S . invoke ( "openUrl" , { url } ) . catch ( ( ) => { } ) ;
// ---- tabs ------------------------------------------------------------------
document . querySelectorAll ( "nav button" ) . forEach ( ( b ) => b . addEventListener ( "click" , ( ) => showTab ( b . dataset . tab ) ) ) ;
function showTab ( name ) {
tab = name ;
document . querySelectorAll ( "nav button" ) . forEach ( ( b ) => b . classList . toggle ( "on" , b . dataset . tab === name ) ) ;
document . querySelectorAll ( "main section" ) . forEach ( ( s ) => { s . hidden = s . id !== "tab-" + name ; } ) ;
if ( name === "settings" ) fillSettings ( ) ;
}
// ---- render ----------------------------------------------------------------
function render ( ) {
if ( ! state ) return ;
const ready = state . phase === "ready" ;
const gate = $ ( "gate" ) ;
$ ( "tabs" ) . hidden = ! ready ;
gate . hidden = ready ;
if ( ! ready ) {
const copy = {
locked : [ "🔒" , "Unlock your password vault to open the wallet." , "Settings › Passwords. The wallet keys derive from the vault seed, so there is nothing separate to unlock." ] ,
nosetup : [ "🗝" , "Set up a password vault to create your wallet." , "Settings › Passwords › Set up. Use a recovery phrase there and this wallet can be recreated from it on any machine." ] ,
error : [ "⚠" , "The wallet could not start." , state . error || "" ] ,
} [ state . phase ] || [ "…" , "Starting…" , "" ] ;
gate . innerHTML = ` <div class="big"> ${ copy [ 0 ] } </div><div><b> ${ esc ( copy [ 1 ] ) } </b></div><div class="hint" style="margin-top:8px"> ${ esc ( copy [ 2 ] ) } </div> ` ;
}
// header
const dot = $ ( "dot" ) ;
dot . className = "dot " + ( state . server ? ( state . scanning ? "busy" : "on" ) : "" ) ;
$ ( "netlbl" ) . textContent = state . server ? hostOf ( state . server ) + ( state . scanning ? " · syncing" : "" ) : ( ready ? "connecting…" : "mainnet" ) ;
if ( ready ) {
const total = ( state . balance ? . confirmed || 0 ) + ( state . balance ? . unconfirmed || 0 ) ;
$ ( "balBch" ) . textContent = fmtBch ( total ) ;
const parts = [ fmtSats ( total ) + " sat" ] ;
if ( state . balance ? . unconfirmed ) parts . push ( fmtBch ( state . balance . unconfirmed ) + " unconfirmed" ) ;
if ( state . height ) parts . push ( "block " + fmtSats ( state . height ) ) ;
$ ( "balSub" ) . textContent = parts . join ( " · " ) ;
} else { $ ( "balBch" ) . textContent = "—" ; $ ( "balSub" ) . textContent = "mainnet" ; }
if ( ! ready ) return ;
// receive
if ( state . address && $ ( "addr" ) . textContent !== state . address ) {
$ ( "addr" ) . textContent = state . address ;
drawQr ( "bitcoincash:" + state . address . replace ( /^bitcoincash:/ , "" ) ) ;
}
$ ( "addrMeta" ) . textContent = ` · # ${ state . addressIndex } · ${ state . addressPath || "" } ` ;
// history
renderHistory ( ) ;
if ( state . error ) { $ ( "balSub" ) . textContent = state . error ; }
}
function drawQr ( text ) {
const cv = $ ( "qr" ) ;
const g = cv . getContext ( "2d" ) ;
let q ;
try { q = window . QR . build ( text ) ; } catch { g . clearRect ( 0 , 0 , cv . width , cv . height ) ; return ; }
const scale = Math . max ( 2 , Math . floor ( 200 / ( q . size + 2 ) ) ) ;
const px = ( q . size + 2 ) * scale ;
cv . width = cv . height = px ;
cv . style . width = cv . style . height = px + "px" ;
g . fillStyle = "#fff" ; g . fillRect ( 0 , 0 , px , px ) ;
g . fillStyle = "#000" ;
for ( let r = 0 ; r < q . size ; r ++ ) for ( let c = 0 ; c < q . size ; c ++ ) if ( q . modules [ r ] [ c ] ) g . fillRect ( ( c + 1 ) * scale , ( r + 1 ) * scale , scale , scale ) ;
}
function renderHistory ( ) {
const list = state . history || [ ] ;
const el = $ ( "txlist" ) ;
if ( ! list . length ) { el . innerHTML = ` <div class="empty"> ${ state . scanning ? "Syncing…" : "No transactions yet." } </div> ` ; return ; }
el . innerHTML = list . map ( ( t ) => {
const inc = t . delta >= 0 ;
const when = t . time ? new Date ( t . time * 1000 ) . toLocaleString ( undefined , { dateStyle : "medium" , timeStyle : "short" } ) : "pending" ;
const what = inc ? "Received" : ( "Sent" + ( t . to ? " to " + esc ( t . to . replace ( /^bitcoincash:/ , "" ) . slice ( 0 , 12 ) ) + "…" : "" ) ) ;
const conf = t . confirmations > 0 ? ( t . confirmations >= 6 ? "confirmed" : t . confirmations + " conf" ) : "unconfirmed" ;
return ` <div class="tx" data-txid=" ${ esc ( t . txid ) } " title=" ${ esc ( t . txid ) } ">
< div class = "ic ${inc ? " in " : " out "}" > $ { inc ? "↓" : "↑" } < / d i v >
< div class = "what" > $ { what } < / d i v >
< div class = "amt2 ${inc ? " in " : " "}" > $ { inc ? "+" : "− " } $ { fmtBch ( Math . abs ( t . delta ) ) } < / d i v >
< div class = "when" > $ { esc ( when ) } $ { t . fee != null ? " · fee " + fmtSats ( t . fee ) + " sat" : "" } < / d i v >
< div class = "conf ${t.confirmations > 0 ? " " : " pending "}" > $ { conf } < / d i v >
< / d i v > ` ;
} ) . join ( "" ) ;
el . querySelectorAll ( ".tx" ) . forEach ( ( row ) => row . addEventListener ( "click" , ( ) => openUrl ( state . explorerTx + row . dataset . txid ) ) ) ;
}
// ---- receive actions -------------------------------------------------------
$ ( "copyAddr" ) . addEventListener ( "click" , async ( ) => {
try { await navigator . clipboard . writeText ( state . address ) ; flash ( $ ( "copyAddr" ) , "Copied" ) ; } catch { }
} ) ;
$ ( "nextAddr" ) . addEventListener ( "click" , async ( ) => {
try { state = await S . invoke ( "nextAddress" ) ; render ( ) ; } catch ( e ) { flash ( $ ( "nextAddr" ) , "Failed" ) ; }
} ) ;
$ ( "viewAddr" ) . addEventListener ( "click" , ( ) => openUrl ( state . explorerAddr + state . address ) ) ;
function flash ( btn , text ) {
const old = btn . textContent ; btn . textContent = text ;
setTimeout ( ( ) => { btn . textContent = old ; } , 1200 ) ;
}
feat(theseus/bchwallet): send — plan, approval overlay, sign, broadcast
Send tab: recipient (cashaddr or legacy, testnet rejected), amount with
BCH/sat toggle and Max, 1-5 sat/B fee slider, live fee/total preview via
planSend. Sending goes plan -> approval-modal (To/Amount/Fee/Total) ->
ECDSA DER + SIGHASH_ALL|FORKID -> blockchain.transaction.broadcast, then
shows the txid with an explorer link. Confirmed coins are spent before
unconfirmed; dust change folds into the fee. Verified end to end against a
fake Fulcrum: broadcast tx re-parsed, sighash recomputed, signature checks.
2026-09-06 02:49:09 +02:00
// ---- send ------------------------------------------------------------------
let unit = "bch" ;
let sendMax = false ;
let planTimer = null ;
let lastPlan = null ;
function amountSats ( ) {
const raw = $ ( "sendAmt" ) . value . trim ( ) . replace ( /,/g , "" ) ;
if ( ! raw ) return 0 ;
if ( unit === "sat" ) return Math . round ( Number ( raw ) ) ;
const [ w , f = "" ] = raw . split ( "." ) ;
return Number ( w || 0 ) * 1e8 + Math . round ( Number ( "0." + ( f || "0" ) ) * 1e8 ) ;
}
function setUnit ( u ) {
if ( u === unit ) return ;
const s = amountSats ( ) ;
unit = u ;
$ ( "unitBch" ) . classList . toggle ( "on" , u === "bch" ) ; $ ( "unitSat" ) . classList . toggle ( "on" , u === "sat" ) ;
$ ( "sendAmt" ) . placeholder = u === "bch" ? "0.00" : "0" ;
if ( s ) $ ( "sendAmt" ) . value = u === "bch" ? fmtBch ( s ) : String ( s ) ;
}
$ ( "unitBch" ) . addEventListener ( "click" , ( ) => setUnit ( "bch" ) ) ;
$ ( "unitSat" ) . addEventListener ( "click" , ( ) => setUnit ( "sat" ) ) ;
$ ( "sendMax" ) . addEventListener ( "click" , ( ) => {
sendMax = ! sendMax ;
$ ( "sendMax" ) . classList . toggle ( "primary" , sendMax ) ;
$ ( "sendAmt" ) . disabled = sendMax ;
if ( ! sendMax ) $ ( "sendAmt" ) . value = "" ;
schedulePlan ( ) ;
} ) ;
$ ( "feeRate" ) . addEventListener ( "input" , ( ) => { $ ( "feeLbl" ) . textContent = $ ( "feeRate" ) . value + " sat/B" ; schedulePlan ( ) ; } ) ;
[ "sendTo" , "sendAmt" ] . forEach ( ( id ) => $ ( id ) . addEventListener ( "input" , ( ) => { if ( id === "sendAmt" && sendMax ) return ; schedulePlan ( ) ; } ) ) ;
function schedulePlan ( ) { clearTimeout ( planTimer ) ; planTimer = setTimeout ( updatePlan , 250 ) ; }
async function updatePlan ( ) {
const to = $ ( "sendTo" ) . value . trim ( ) ;
const msg = $ ( "sendMsg" ) ; msg . hidden = true ;
lastPlan = null ; $ ( "sendBtn" ) . disabled = true ;
$ ( "sumAmt" ) . textContent = $ ( "sumFee" ) . textContent = $ ( "sumTotal" ) . textContent = "—" ;
$ ( "sendToHint" ) . textContent = "" ;
if ( ! to || ( ! sendMax && ! amountSats ( ) ) ) return ;
try {
const p = await S . invoke ( "planSend" , { to , amount : amountSats ( ) , feeRate : Number ( $ ( "feeRate" ) . value ) , sendMax } ) ;
lastPlan = p ;
$ ( "sendToHint" ) . textContent = p . recipients [ 0 ] . to !== to ? "→ " + p . recipients [ 0 ] . to : "" ;
$ ( "sumAmt" ) . textContent = fmtBch ( p . recipients [ 0 ] . value ) + " BCH" ;
$ ( "sumFee" ) . textContent = fmtSats ( p . fee ) + " sat" ;
$ ( "sumTotal" ) . textContent = fmtBch ( p . total ) + " BCH" ;
if ( sendMax ) $ ( "sendAmt" ) . value = unit === "bch" ? fmtBch ( p . recipients [ 0 ] . value ) : String ( p . recipients [ 0 ] . value ) ;
$ ( "sendBtn" ) . disabled = false ;
} catch ( e ) {
msg . className = "msg err" ; msg . textContent = cleanErr ( e ) ; msg . hidden = false ;
}
}
$ ( "sendBtn" ) . addEventListener ( "click" , async ( ) => {
if ( ! lastPlan ) return ;
const msg = $ ( "sendMsg" ) ; msg . hidden = true ;
$ ( "sendBtn" ) . disabled = true ; $ ( "sendBtn" ) . textContent = "Waiting for approval…" ;
try {
const r = await S . invoke ( "send" , { to : $ ( "sendTo" ) . value . trim ( ) , amount : amountSats ( ) , feeRate : Number ( $ ( "feeRate" ) . value ) , sendMax } ) ;
msg . className = "msg ok" ;
msg . innerHTML = ` Sent. <a class="link" data-tx=" ${ esc ( r . txid ) } "> ${ esc ( r . txid . slice ( 0 , 16 ) ) } …</a> ` ;
msg . querySelector ( "a" ) . addEventListener ( "click" , ( ) => openUrl ( state . explorerTx + r . txid ) ) ;
msg . hidden = false ;
$ ( "sendTo" ) . value = "" ; $ ( "sendAmt" ) . value = "" ; sendMax = false ; $ ( "sendMax" ) . classList . remove ( "primary" ) ; $ ( "sendAmt" ) . disabled = false ;
lastPlan = null ;
} catch ( e ) {
const t = cleanErr ( e ) ;
if ( t !== "cancelled" ) { msg . className = "msg err" ; msg . textContent = t ; msg . hidden = false ; }
$ ( "sendBtn" ) . disabled = ! lastPlan ;
} finally { $ ( "sendBtn" ) . textContent = "Send" ; }
} ) ;
feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
// ---- settings --------------------------------------------------------------
let settingsFilled = false ;
function fillSettings ( ) {
if ( ! state ) return ;
if ( ! settingsFilled ) {
$ ( "setPath" ) . value = state . accountPath || "" ;
$ ( "setServers" ) . value = ( state . servers || [ ] ) . join ( "\n" ) ;
settingsFilled = true ;
}
$ ( "serverHint" ) . textContent = ( state . customServers ? "Custom list." : "Bundled defaults." ) + ( state . server ? " Connected to " + hostOf ( state . server ) + "." : " Not connected." ) ;
$ ( "purpose" ) . textContent = "silentmode/addons/bchwallet/mainnet/0" ;
2026-09-06 02:56:34 +02:00
renderSites ( ) ;
}
async function renderSites ( ) {
let perms = { } ;
try { perms = await S . invoke ( "permissions" ) ; } catch { }
2026-09-06 12:43:17 +02:00
const origins = Object . keys ( perms ) . filter ( ( o ) => perms [ o ] && ( perms [ o ] . readAddress || perms [ o ] . sendTx ) ) ;
2026-09-06 02:56:34 +02:00
const el = $ ( "sites" ) ;
if ( ! origins . length ) { el . innerHTML = ` <div class="hint">None yet.</div> ` ; return ; }
2026-09-06 12:43:17 +02:00
el . innerHTML = origins . map ( ( o ) => {
const p = perms [ o ] ; const what = [ ] ;
if ( p . readAddress ) what . push ( "address" ) ;
if ( p . sendTx ) what . push ( ` payments: ${ fmtBch ( Math . max ( 0 , p . sendTx . capSats - ( p . sendTx . usedSats || 0 ) ) ) } of ${ fmtBch ( p . sendTx . capSats ) } BCH left ` ) ;
return ` <div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono"> ${ esc ( o ) } </div><div class="hint"> ${ esc ( what . join ( " · " ) ) } </div></div><button class="btn sm" data-origin=" ${ esc ( o ) } ">Revoke</button></div> ` ;
} ) . join ( "" ) ;
2026-09-06 02:56:34 +02:00
el . querySelectorAll ( "button[data-origin]" ) . forEach ( ( b ) => b . addEventListener ( "click" , async ( ) => {
try { await S . invoke ( "revoke" , { origin : b . dataset . origin } ) ; renderSites ( ) ; } catch { }
} ) ) ;
feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
}
$ ( "applySettings" ) . addEventListener ( "click" , async ( ) => {
const msg = $ ( "settingsMsg" ) ; msg . hidden = true ;
try {
const servers = $ ( "setServers" ) . value . split ( /\n+/ ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
state = await S . invoke ( "setSettings" , { accountPath : $ ( "setPath" ) . value , servers : state . customServers || servers . join ( ) !== ( state . servers || [ ] ) . join ( ) ? servers : undefined } ) ;
settingsFilled = false ; fillSettings ( ) ; render ( ) ;
flash ( $ ( "applySettings" ) , "Applied" ) ;
} catch ( e ) { msg . textContent = cleanErr ( e ) ; msg . hidden = false ; }
} ) ;
$ ( "resetServers" ) . addEventListener ( "click" , async ( ) => {
try { state = await S . invoke ( "setSettings" , { servers : [ ] } ) ; settingsFilled = false ; fillSettings ( ) ; render ( ) ; } catch ( e ) { $ ( "settingsMsg" ) . textContent = cleanErr ( e ) ; $ ( "settingsMsg" ) . hidden = false ; }
} ) ;
$ ( "showXpub" ) . addEventListener ( "click" , async ( ) => {
try { const r = await S . invoke ( "recovery" , { } ) ; $ ( "recovery" ) . innerHTML = recoveryHtml ( r ) ; } catch ( e ) { $ ( "recovery" ) . textContent = cleanErr ( e ) ; }
} ) ;
$ ( "showXprv" ) . addEventListener ( "click" , async ( ) => {
try { const r = await S . invoke ( "recovery" , { reveal : true } ) ; $ ( "recovery" ) . innerHTML = recoveryHtml ( r ) ; } catch ( e ) { $ ( "recovery" ) . textContent = cleanErr ( e ) ; }
} ) ;
function recoveryHtml ( r ) {
let h = ` <div class="lbl">Account path</div><div class="mono"> ${ esc ( r . accountPath ) } </div><div class="lbl">Account xpub</div><div class="mono"> ${ esc ( r . xpub ) } </div> ` ;
if ( r . xprv ) h += ` <div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)"> ${ esc ( r . xprv ) } </div> ` ;
return h ;
}
const cleanErr = ( e ) => String ( e ? . message || e ) . replace ( /^Error invoking remote method '[^']+': Error: / , "" ) ;
// Wipe a revealed key when the user leaves the Settings tab.
document . querySelectorAll ( "nav button" ) . forEach ( ( b ) => b . addEventListener ( "click" , ( ) => { if ( b . dataset . tab !== "settings" ) $ ( "recovery" ) . innerHTML = "" ; } ) ) ;
// ---- boot ------------------------------------------------------------------
S . on ( "state" , ( s ) => { state = s ; render ( ) ; } ) ;
( async ( ) => {
try { state = await S . invoke ( "state" ) ; render ( ) ; }
catch ( e ) { $ ( "gate" ) . hidden = false ; $ ( "gate" ) . innerHTML = ` <div class="big">⚠</div><div> ${ esc ( cleanErr ( e ) ) } </div> ` ; }
} ) ( ) ;