2026-09-08 18:17:25 +02:00
// Aegis — multi-chain wallet bundled with Theseus. activate() runs in the
// main process; every wallet's key material lives here, in memory, and is
// re-derived from the password vault on every launch. Nothing secret is ever
// written to disk or logged.
//
// A single addon can hold many wallets — one per {chain, network}, or several
// sub-accounts of the same chain — and each wallet is backed by its own
// vault-derived 32-byte HKDF root. Chains today: BCH, Tron mainnet, Tron
// Nile testnet. Adding a fourth chain is a new adapter file under lib/ and
// an entry in the CHAIN_REGISTRY below.
//
// Back-compat: the addon id stays "bchwallet" (the manifest label became
// Aegis, but the id gates the vault-derive namespace and older vaults have
// funds against it). The legacy BCH default wallet uses purpose
// "bchwallet/mainnet/0" — byte-identical to the pre-multi-wallet build —
// so on-disk funds are untouched. See memory bchwallet-vault-root-derivation.
const path = require ( "node:path" ) ;
const fs = require ( "node:fs" ) ;
const LEGACY _BCH _PURPOSE = "bchwallet/mainnet/0" ;
const LEGACY _BCH _WALLET _ID = "bch-default" ;
let ctx = null ;
// ---- deps -------------------------------------------------------------------
async function loadDeps ( api ) {
const { secp256k1 } = await api . import ( "@noble/curves/secp256k1.js" ) ;
const { ed25519 } = await api . import ( "@noble/curves/ed25519.js" ) ;
const { sha256 } = await api . import ( "@noble/hashes/sha2.js" ) ;
const { ripemd160 } = await api . import ( "@noble/hashes/legacy.js" ) ;
const { keccak _256 } = await api . import ( "@noble/hashes/sha3.js" ) ;
const { blake2b } = await api . import ( "@noble/hashes/blake2.js" ) ;
const { HDKey } = await api . import ( "@scure/bip32" ) ;
const WebSocket = api . require ( "ws" ) ;
const cashaddr = require ( "./lib/cashaddr.js" ) ;
const keysLib = require ( "./lib/keys.js" ) ( { HDKey , secp256k1 , sha256 , ripemd160 , cashaddr } ) ;
const tx = require ( "./lib/tx.js" ) ( { sha256 } ) ;
const electrum = require ( "./lib/electrum.js" ) ( { WebSocket , log : ( ... a ) => api . log ( "electrum" , ... a ) } ) ;
const base58check = require ( "./lib/base58check.js" ) ( { sha256 } ) ;
const bchAdapter = require ( "./lib/chain-bch.js" ) ( {
HDKey , secp256k1 , sha256 , ripemd160 , cashaddr , keysLib , tx , electrum , WebSocket ,
} ) ;
const tronAdapter = require ( "./lib/chain-tron.js" ) ( {
HDKey , secp256k1 , sha256 , keccak _256 , base58check ,
} ) ;
const siaAdapter = require ( "./lib/chain-sia.js" ) ( { ed25519 , blake2b } ) ;
const ethAdapter = require ( "./lib/chain-eth.js" ) ( { HDKey , secp256k1 , keccak _256 } ) ;
const eip712 = require ( "./lib/eip712.js" ) ( { keccak _256 } ) ;
// Solana uses the raw base58 alphabet (no checksum), which lives inside
// base58check as encodeBase58 / decodeBase58 — expose them under a
// `{encode, decode}` shape the SOL adapter reads from.
const solBase58 = { encode : base58check . encodeBase58 , decode : base58check . decodeBase58 } ;
const solAdapter = require ( "./lib/chain-sol.js" ) ( { ed25519 , base58 : solBase58 , sha256 } ) ;
// DGB delegates address derivation + PSBT to the vendored @dgb-wallet/*
// packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib,
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
// api.require from the Theseus dependency tree.
const { pathToFileURL } = require ( "node:url" ) ;
const dgbCore = await import ( pathToFileURL ( path . join ( api . folder , "lib/dgb/core/index.js" ) ) . href ) ;
const dgbPsbt = await import ( pathToFileURL ( path . join ( api . folder , "lib/dgb/psbt/index.js" ) ) . href ) ;
const bitcoinjs = api . require ( "bitcoinjs-lib" ) ;
const { BIP32Factory } = api . require ( "bip32" ) ;
const { ECPairFactory } = api . require ( "ecpair" ) ;
const ecc = api . require ( "@bitcoinerlab/secp256k1" ) ;
const dgbAdapter = require ( "./lib/chain-dgb.js" ) ( {
dgbCore , dgbPsbt , bitcoinjs ,
bip32Factory : BIP32Factory , ecpairFactory : ECPairFactory , ecc ,
sha256 , electrum ,
} ) ;
const btcAdapter = require ( "./lib/chain-btc.js" ) ( {
bitcoinjs , bip32Factory : BIP32Factory , ecpairFactory : ECPairFactory , ecc ,
sha256 , electrum ,
} ) ;
return { HDKey , secp256k1 , ed25519 , sha256 , ripemd160 , keccak _256 , blake2b ,
cashaddr , keysLib , tx , electrum , base58check ,
bchAdapter , tronAdapter , siaAdapter , dgbAdapter , ethAdapter , solAdapter , btcAdapter ,
dgbCore , dgbPsbt , bitcoinjs , ecc , eip712 } ;
}
// ---- servers ---------------------------------------------------------------
function bchDefaultServers ( api ) {
try { return JSON . parse ( fs . readFileSync ( path . join ( api . folder , "electrum-servers.json" ) , "utf8" ) ) ; }
catch { return [ ] ; }
}
function bchServerList ( api ) {
const custom = api . storage . get ( "servers" , null ) ;
return Array . isArray ( custom ) && custom . length ? custom : bchDefaultServers ( api ) ;
}
// ---- chain registry --------------------------------------------------------
// Two-level structure so the panel can present coin-then-network as separate
// picks. `coin` fields are chain-wide; `networks[<id>]` fields override or
// add to them per-network. `logo` is the SVG key panel.js draws from.
const COINS = {
bch : {
chain : "bch" ,
label : "Bitcoin Cash" ,
short : "BCH" ,
ticker : "BCH" ,
decimals : 8 ,
color : "#0ac18e" ,
logo : "bch" ,
supportsMessageSign : true ,
supportsPageInject : true , // window.bitcoincash on *.x pages
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet" , testnet : false ,
// Legacy default wallet uses the flat "bchwallet/mainnet/0" purpose;
// new BCH mainnet sub-accounts start at index 1 under the /bch/ prefix.
purposePrefix : "bchwallet/bch/" ,
startIndex : 1 ,
} ,
chipnet : {
id : "chipnet" , label : "Chipnet testnet" , testnet : true ,
purposePrefix : "bchwallet/bch/chipnet/" ,
startIndex : 0 ,
} ,
} ,
} ,
trx : {
chain : "trx" ,
label : "Tron" ,
short : "TRX" ,
ticker : "TRX" ,
decimals : 6 ,
color : "#ff060a" ,
logo : "trx" ,
supportsMessageSign : true ,
supportsPageInject : true , // window.tronWeb everywhere
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet" , testnet : false ,
purposePrefix : "bchwallet/trx/mainnet/" , startIndex : 0 ,
} ,
nile : {
id : "nile" , label : "Nile testnet" , testnet : true ,
purposePrefix : "bchwallet/trx/nile/" , startIndex : 0 ,
} ,
} ,
} ,
sc : {
chain : "sc" ,
label : "Siacoin" ,
short : "SC" ,
ticker : "SC" ,
decimals : 24 ,
color : "#20be82" ,
logo : "sc" ,
supportsMessageSign : true ,
supportsPageInject : false ,
// First SC wallet in Aegis reuses the legacy standalone-siawallet purpose
// so pre-Aegis funds carry over automatically (addon.json declares
// `absorbs: ["siawallet"]` to allow the derivation). Second+ use the new
// Aegis-namespaced prefix.
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet" , testnet : false ,
purposePrefix : "bchwallet/sc/mainnet/" , startIndex : 1 ,
legacyFirstPurpose : "siawallet/mainnet/0" ,
} ,
} ,
} ,
dgb : {
chain : "dgb" ,
label : "DigiByte" ,
short : "DGB" ,
ticker : "DGB" ,
decimals : 8 ,
color : "#0066cc" ,
logo : "dgb" ,
supportsMessageSign : true ,
supportsPageInject : false ,
// BIP44/49/84/86 address families — the picker lives in the DGB
// settings block. Default is BIP84 (dgb1q…), which matches modern
// DGB Core, DigiByte-Go, and the SilentCode web-wallet. `coinType`
// per chain feeds the path builder below.
addressFamilies : [
{ id : "bip84" , purpose : 84 , label : "Native SegWit (dgb1q…)" } ,
{ id : "bip86" , purpose : 86 , label : "Taproot (dgb1p…)" } ,
{ id : "bip49" , purpose : 49 , label : "Wrapped SegWit (S…)" } ,
{ id : "bip44" , purpose : 44 , label : "Legacy P2PKH (D…)" } ,
] ,
coinType : 20 ,
defaultPurpose : 84 ,
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet" , testnet : false ,
purposePrefix : "bchwallet/dgb/mainnet/" , startIndex : 0 ,
} ,
} ,
} ,
eth : {
chain : "eth" ,
label : "Ethereum" ,
short : "ETH" ,
ticker : "ETH" ,
decimals : 18 ,
color : "#627eea" ,
logo : "eth" ,
supportsMessageSign : true ,
supportsPageInject : false , // EIP-1193 provider is a follow-up
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet" , testnet : false ,
purposePrefix : "bchwallet/eth/mainnet/" , startIndex : 0 ,
} ,
sepolia : {
id : "sepolia" , label : "Sepolia testnet" , testnet : true ,
purposePrefix : "bchwallet/eth/sepolia/" , startIndex : 0 ,
} ,
} ,
} ,
sol : {
chain : "sol" ,
label : "Solana" ,
short : "SOL" ,
ticker : "SOL" ,
decimals : 9 ,
color : "#9945ff" ,
logo : "sol" ,
supportsMessageSign : true ,
supportsPageInject : false ,
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet-beta" , testnet : false ,
purposePrefix : "bchwallet/sol/mainnet/" , startIndex : 0 ,
} ,
devnet : {
id : "devnet" , label : "Devnet" , testnet : true ,
purposePrefix : "bchwallet/sol/devnet/" , startIndex : 0 ,
} ,
} ,
} ,
btc : {
chain : "btc" ,
label : "Bitcoin" ,
short : "BTC" ,
ticker : "BTC" ,
decimals : 8 ,
color : "#f7931a" ,
logo : "btc" ,
supportsMessageSign : true ,
supportsPageInject : false ,
// BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and
// tb1q… / tb1p… / 2… / m/n… on testnet3 + signet. Coin type shifts
// per network (0 for mainnet, 1 for both testnet3 and signet — SLIP-44
// treats every Bitcoin testnet as coin type 1).
addressFamilies : [
{ id : "bip84" , purpose : 84 , label : "Native SegWit (bc1q… / tb1q…)" } ,
{ id : "bip86" , purpose : 86 , label : "Taproot (bc1p… / tb1p…)" } ,
{ id : "bip49" , purpose : 49 , label : "Wrapped SegWit (3… / 2…)" } ,
{ id : "bip44" , purpose : 44 , label : "Legacy P2PKH (1… / m…, n…)" } ,
] ,
coinType : { mainnet : 0 , testnet : 1 , signet : 1 } ,
defaultPurpose : 84 ,
networks : {
mainnet : {
id : "mainnet" , label : "Mainnet" , testnet : false ,
purposePrefix : "bchwallet/btc/mainnet/" , startIndex : 0 ,
} ,
testnet : {
id : "testnet" , label : "Testnet3" , testnet : true ,
purposePrefix : "bchwallet/btc/testnet/" , startIndex : 0 ,
} ,
signet : {
id : "signet" , label : "Signet" , testnet : true ,
purposePrefix : "bchwallet/btc/signet/" , startIndex : 0 ,
} ,
} ,
} ,
} ;
function chainKey ( chain , network ) { return ` ${ chain } : ${ network } ` ; }
// Coin type per (chain, network). A number literal on the COINS entry
// (DGB uses a single 20) or a per-network object ({mainnet: 0, testnet: 1}
// for BTC). Returns null when the chain doesn't declare a family picker.
function coinTypeFor ( c , network ) {
if ( ! c || c . coinType == null ) return null ;
return typeof c . coinType === "number" ? c . coinType : ( c . coinType [ network ] ? ? null ) ;
}
// Full derivation paths per family for a given (chain, network) — expands
// the family list on the fly so each picker knows exactly which path a
// pick would produce.
function addressFamiliesFor ( c , network ) {
if ( ! c || ! c . addressFamilies ) return null ;
const ct = coinTypeFor ( c , network ) ;
if ( ct == null ) return c . addressFamilies ;
return c . addressFamilies . map ( ( f ) => ( {
... f ,
defaultAccountPath : ` m/ ${ f . purpose } '/ ${ ct } '/0' ` ,
} ) ) ;
}
function defaultAccountPathFor ( c , network ) {
const ct = coinTypeFor ( c , network ) ;
if ( ct == null || c . defaultPurpose == null ) return null ;
return ` m/ ${ c . defaultPurpose } '/ ${ ct } '/0' ` ;
}
// Custom EVM chains (EIP-3085) live in api.storage under `customEthChains`.
// Structured as { [chainId]: {chainId, chainName, rpcUrl, explorerTx,
// explorerAddr, ticker, addedAt, addedByOrigin} }. They're not in COINS at
// module-load time — we synthesize a chainMeta / mount entry from storage
// so wallet_addEthereumChain can register new networks at runtime without
// a Theseus restart.
const CUSTOM _ETH _PREFIX = "custom-" ;
function customEthChains ( api ) {
const raw = api . storage . get ( "customEthChains" , { } ) ;
return raw && typeof raw === "object" ? raw : { } ;
}
function customEthNetworkEntry ( api , network ) {
if ( ! network || ! network . startsWith ( CUSTOM _ETH _PREFIX ) ) return null ;
const chainId = Number ( network . slice ( CUSTOM _ETH _PREFIX . length ) ) ;
if ( ! Number . isFinite ( chainId ) ) return null ;
const all = customEthChains ( api ) ;
const cfg = all [ String ( chainId ) ] ;
if ( ! cfg ) return null ;
return {
id : network ,
label : cfg . chainName || ` EVM # ${ chainId } ` ,
chainId ,
defaultRpc : cfg . rpcUrl ,
explorerTx : cfg . explorerTx ,
explorerAddr : cfg . explorerAddr ,
ticker : cfg . ticker || "ETH" ,
faucet : null ,
} ;
}
function chainMeta ( chain , network ) {
// ETH custom-network fallback for EIP-3085 chains.
if ( chain === "eth" && String ( network || "" ) . startsWith ( CUSTOM _ETH _PREFIX ) ) {
const cfg = customEthNetworkEntry ( ctx ? . api , network ) ;
if ( ! cfg ) return null ;
return {
chain : "eth" , network : cfg . id ,
label : "Ethereum · " + cfg . label ,
short : cfg . ticker , ticker : cfg . ticker , decimals : 18 ,
color : COINS . eth . color , logo : COINS . eth . logo ,
coinLabel : cfg . label , networkLabel : ` chainId ${ cfg . chainId } ` ,
testnet : false ,
purposePrefix : ` bchwallet/eth/ ${ cfg . id } / ` , startIndex : 0 ,
supportsMessageSign : true , supportsPageInject : false ,
addressFamilies : null , defaultAccountPath : null ,
isCustom : true , chainId : cfg . chainId ,
} ;
}
const c = COINS [ chain ] ; const n = c && c . networks [ network ] ;
if ( ! c || ! n ) return null ;
return {
chain : c . chain , network : n . id ,
label : c . label + " · " + n . label , short : c . short , ticker : c . ticker , decimals : c . decimals ,
color : c . color , logo : c . logo , coinLabel : c . label , networkLabel : n . label , testnet : ! ! n . testnet ,
purposePrefix : n . purposePrefix , startIndex : n . startIndex ,
supportsMessageSign : ! ! c . supportsMessageSign , supportsPageInject : ! ! c . supportsPageInject ,
addressFamilies : addressFamiliesFor ( c , network ) ,
defaultAccountPath : defaultAccountPathFor ( c , network ) ,
} ;
}
function coinsForPanel ( ) {
return Object . values ( COINS ) . map ( ( c ) => ( {
chain : c . chain , label : c . label , short : c . short , ticker : c . ticker , color : c . color , logo : c . logo , decimals : c . decimals ,
networks : Object . values ( c . networks ) . map ( ( n ) => ( { id : n . id , label : n . label , testnet : ! ! n . testnet } ) ) ,
} ) ) ;
}
// ---- wallet list ------------------------------------------------------------
function readWallets ( api ) {
const raw = api . storage . get ( "wallets" , null ) ;
return Array . isArray ( raw ) ? raw : null ;
}
function writeWallets ( api , list ) { api . storage . set ( "wallets" , list ) ; }
// Bring pre-multi-wallet storage forward: create the legacy BCH default entry
// and rehome its receiveCursor / txCache under the new per-wallet subkey.
function migrateLegacyStorage ( api ) {
if ( readWallets ( api ) ) return ; // already multi-wallet
const legacyAccountPath = String ( api . storage . get ( "accountPath" , "" ) || "" ) . trim ( ) || "m/44'/145'/0'" ;
const wallets = [ {
id : LEGACY _BCH _WALLET _ID ,
label : "BCH — main" ,
chain : "bch" ,
network : "mainnet" ,
purpose : LEGACY _BCH _PURPOSE ,
accountPath : legacyAccountPath ,
isDefault : true ,
isLegacy : true ,
createdAt : 0 ,
} ] ;
writeWallets ( api , wallets ) ;
api . storage . set ( "selectedWalletId" , LEGACY _BCH _WALLET _ID ) ;
// Move per-wallet state under the scoped prefix used by chain-bch.js.
const prefix = ` wallets/ ${ LEGACY _BCH _WALLET _ID } / ` ;
for ( const legacyKey of [ "receiveCursor" , "txCache" ] ) {
const v = api . storage . get ( legacyKey , null ) ;
if ( v !== null && api . storage . get ( prefix + legacyKey , null ) === null ) {
api . storage . set ( prefix + legacyKey , v ) ;
}
}
api . log ( "migrated legacy BCH wallet into multi-wallet layout" ) ;
}
function nextIndex ( wallets , meta ) {
let max = meta . startIndex - 1 ;
for ( const w of wallets ) {
if ( chainKey ( w . chain , w . network ) !== chainKey ( meta . chain , meta . network ) ) continue ;
if ( w . isLegacy ) continue ;
const m = /\/(\d+)$/ . exec ( w . purpose || "" ) ;
const n = m ? Number ( m [ 1 ] ) : NaN ;
if ( Number . isFinite ( n ) && n > max ) max = n ;
}
return max + 1 ;
}
function makeWalletId ( meta , index ) {
const n = String ( meta . network ) . replace ( /[^a-z0-9]/gi , "" ) ;
return ` ${ meta . chain } - ${ n } - ${ index } ` ;
}
function autoLabel ( meta , wallets ) {
const same = wallets . filter ( ( w ) => chainKey ( w . chain , w . network ) === chainKey ( meta . chain , meta . network ) ) ;
if ( ! same . length ) return meta . short ;
return ` ${ meta . short } # ${ same . length + 1 } ` ;
}
// ---- runtime wallet map -----------------------------------------------------
// A "runtime" is a mounted wallet: its adapter instance plus phase + error.
// activate() derives all of them in parallel once the vault unlocks.
async function mountAllWallets ( ) {
const c = ctx ;
const walletList = readWallets ( c . api ) || [ ] ;
for ( const w of walletList ) {
if ( ! c . runtimes . has ( w . id ) ) c . runtimes . set ( w . id , { entry : w , phase : "locked" , error : null , adapter : null } ) ;
}
emitState ( ) ;
await Promise . all ( walletList . map ( ( w ) => mountWallet ( w ) ) ) ;
}
async function mountWallet ( entry ) {
const c = ctx ;
const rt = c . runtimes . get ( entry . id ) || { entry , phase : "locked" , error : null , adapter : null } ;
rt . entry = entry ;
rt . phase = "locked" ; rt . error = null ;
c . runtimes . set ( entry . id , rt ) ;
emitState ( ) ;
let root ;
try {
root = await c . api . vault . derive ( entry . purpose ) ;
} catch ( e ) {
const msg = e ? . message || String ( e ) ;
rt . phase = /not set up/i . test ( msg ) ? "nosetup" : "error" ;
rt . error = msg ;
emitState ( ) ;
return ;
}
if ( ctx !== c ) return ;
try {
let adapter ;
if ( entry . chain === "bch" ) {
// Mainnet still honors the user-set custom electrum list; chipnet uses
// adapter-embedded defaults (no per-network custom list in this rev).
const servers = entry . network === "mainnet" ? bchServerList ( c . api ) : undefined ;
adapter = new c . d . bchAdapter . BchWallet ( root , {
walletId : entry . id ,
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
network : entry . network ,
servers ,
accountPath : entry . accountPath ,
} ) ;
} else if ( entry . chain === "trx" ) {
adapter = new c . d . tronAdapter . TronWallet ( root , entry . network , {
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
} ) ;
adapter . schedulePoll ( 20_000 ) ;
} else if ( entry . chain === "sc" ) {
// walletdUrl is per-Sia-wallet (each sub-account may point at a
// different node) and stored under the scoped wallets/<id>/walletdUrl
// key. Empty = the panel shows a "point me at walletd" gate.
const walletdUrl = String ( c . api . storage . get ( ` wallets/ ${ entry . id } /walletdUrl ` , "" ) || "" ) ;
adapter = new c . d . siaAdapter . SiaWallet ( root , {
walletId : entry . id ,
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
walletdUrl ,
} ) ;
if ( walletdUrl ) adapter . startPolling ( ) ;
} else if ( entry . chain === "dgb" ) {
adapter = new c . d . dgbAdapter . DgbWallet ( root , {
walletId : entry . id ,
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
accountPath : entry . accountPath ,
} ) ;
} else if ( entry . chain === "eth" ) {
const rpcUrl = String ( c . api . storage . get ( ` wallets/ ${ entry . id } /rpcUrl ` , "" ) || "" ) ;
// Custom EIP-3085 chains resolve their config from storage rather than
// the built-in NETWORKS map.
const customNetwork = customEthNetworkEntry ( c . api , entry . network ) ;
adapter = new c . d . ethAdapter . EthWallet ( root , entry . network , {
walletId : entry . id ,
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
rpcUrl ,
customNetwork ,
} ) ;
adapter . schedulePoll ( 20_000 ) ;
} else if ( entry . chain === "sol" ) {
const rpcUrl = String ( c . api . storage . get ( ` wallets/ ${ entry . id } /rpcUrl ` , "" ) || "" ) ;
adapter = new c . d . solAdapter . SolWallet ( root , entry . network , {
walletId : entry . id ,
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
rpcUrl ,
} ) ;
adapter . schedulePoll ( 20_000 ) ;
} else if ( entry . chain === "btc" ) {
adapter = new c . d . btcAdapter . BtcWallet ( root , entry . network , {
walletId : entry . id ,
storage : c . api . storage ,
log : ( ... a ) => c . api . log ( ` [ ${ entry . id } ] ` , ... a ) ,
onChange : ( ) => emitStateForWallet ( entry . id ) ,
accountPath : entry . accountPath ,
} ) ;
} else {
throw new Error ( ` unknown chain ${ entry . chain } ` ) ;
}
rt . adapter = adapter ;
rt . phase = "ready" ;
// Kick a first fetch. Errors here don't fail the mount — the panel shows
// them per-wallet via snapshot.error.
adapter . refresh ( true ) . catch ( ( e ) => c . api . log ( ` [ ${ entry . id } ] initial refresh: ` , e ? . message || e ) ) ;
emitStateForWallet ( entry . id ) ;
} catch ( e ) {
rt . phase = "error" ;
rt . error = e ? . message || String ( e ) ;
emitState ( ) ;
} finally {
// Wipe the root buffer — the adapter has already turned it into keys.
if ( root ) try { root . fill ( 0 ) ; } catch { }
}
}
function unmountWallet ( walletId ) {
const rt = ctx . runtimes . get ( walletId ) ;
if ( rt && rt . adapter ) { try { rt . adapter . dispose ( ) ; } catch { } }
ctx . runtimes . delete ( walletId ) ;
}
// ---- state / snapshot -------------------------------------------------------
function selectedWalletId ( ) {
const list = readWallets ( ctx . api ) || [ ] ;
if ( ! list . length ) return null ;
const saved = String ( ctx . api . storage . get ( "selectedWalletId" , "" ) || "" ) ;
if ( saved && list . some ( ( w ) => w . id === saved ) ) return saved ;
const dflt = list . find ( ( w ) => w . isDefault ) || list [ 0 ] ;
return dflt . id ;
}
function walletEntries ( ) { return readWallets ( ctx . api ) || [ ] ; }
function overallPhase ( ) {
// If ANY wallet is nosetup, treat the whole addon as nosetup — the user
// hasn't unlocked / set up the vault, so no wallet can work.
const runtimes = [ ... ctx . runtimes . values ( ) ] ;
if ( ! runtimes . length ) return "locked" ;
if ( runtimes . some ( ( r ) => r . phase === "nosetup" ) ) return "nosetup" ;
if ( runtimes . some ( ( r ) => r . phase === "locked" ) ) return "locked" ;
return "ready" ;
}
function walletSummary ( w ) {
const meta = chainMeta ( w . chain , w . network ) ;
const rt = ctx . runtimes . get ( w . id ) ;
const snap = rt && rt . adapter ? rt . adapter . snapshot ( ) : null ;
return {
id : w . id , label : w . label , chain : w . chain , network : w . network , isDefault : ! ! w . isDefault , isLegacy : ! ! w . isLegacy ,
logo : meta ? . logo || null , color : meta ? . color || "#888" ,
coinLabel : meta ? . coinLabel || w . chain , networkLabel : meta ? . networkLabel || w . network , testnet : ! ! meta ? . testnet ,
ticker : meta ? . ticker || "?" , short : meta ? . short || w . chain , decimals : meta ? . decimals || 8 ,
address : snap ? . address || null ,
balance : snap ? . balance || { confirmed : 0 , unconfirmed : 0 } ,
phase : rt ? . phase || "locked" ,
error : rt ? . error || null ,
} ;
}
function snapshotForSelected ( ) {
const id = selectedWalletId ( ) ;
if ( ! id ) return { phase : "empty" } ;
const rt = ctx . runtimes . get ( id ) ;
const entry = walletEntries ( ) . find ( ( w ) => w . id === id ) ;
const meta = entry ? chainMeta ( entry . chain , entry . network ) : null ;
const base = {
walletId : id ,
label : entry ? . label ,
chain : entry ? . chain ,
network : entry ? . network ,
isLegacy : ! ! entry ? . isLegacy ,
meta : meta ? {
logo : meta . logo , color : meta . color , short : meta . short , ticker : meta . ticker , decimals : meta . decimals ,
coinLabel : meta . coinLabel , networkLabel : meta . networkLabel , testnet : meta . testnet ,
addressFamilies : meta . addressFamilies , defaultAccountPath : meta . defaultAccountPath ,
} : null ,
supportsMessageSign : ! ! meta ? . supportsMessageSign ,
phase : rt ? . phase || "locked" ,
error : rt ? . error || null ,
} ;
if ( rt && rt . adapter ) Object . assign ( base , rt . adapter . snapshot ( ) ) ;
return base ;
}
function fullState ( ) {
return {
overallPhase : overallPhase ( ) ,
selectedWalletId : selectedWalletId ( ) ,
wallets : walletEntries ( ) . map ( walletSummary ) ,
selected : snapshotForSelected ( ) ,
bchServers : {
list : bchServerList ( ctx . api ) ,
custom : Array . isArray ( ctx . api . storage . get ( "servers" , null ) ) ,
} ,
coins : coinsForPanel ( ) ,
} ;
}
function emitState ( ) { try { ctx . api . emit ( "state" , fullState ( ) ) ; } catch { } }
function emitStateForWallet ( id ) {
// Any wallet change fans out to the panel with the full state so the
// wallet list balances update in the header too.
if ( ! ctx || ! ctx . runtimes . has ( id ) ) return ;
emitState ( ) ;
}
// ---- panel messages ---------------------------------------------------------
function requireWallet ( id ) {
const rt = ctx . runtimes . get ( id ) ;
if ( ! rt || rt . phase !== "ready" || ! rt . adapter ) throw new Error ( "wallet is not ready (vault locked?)" ) ;
return rt ;
}
function requireSelected ( ) { return requireWallet ( selectedWalletId ( ) ) ; }
function fromPanel ( m ) { if ( ! m || m . from !== "panel" ) throw new Error ( "panel-only message" ) ; }
function fromPage ( m ) {
if ( ! m || m . from !== "page" || ! m . origin ) throw new Error ( "page-only message" ) ;
return m . origin ;
}
const fmtBch = ( sats ) => ( Number ( sats ) / 1e8 ) . toFixed ( 8 ) . replace ( /(\.\d*?[1-9])0+$|\.0+$/ , "$1" ) ;
const fmtTrx = ( sun ) => ( Number ( sun ) / 1e6 ) . toFixed ( 6 ) . replace ( /(\.\d*?[1-9])0+$|\.0+$/ , "$1" ) ;
function fmtValue ( units , decimals ) {
const n = Number ( units ) / Math . pow ( 10 , decimals ) ;
return n . toFixed ( decimals ) . replace ( /(\.\d*?[1-9])0+$|\.0+$/ , "$1" ) ;
}
// BigInt-safe display for SPL token amounts (raw units in u64 strings).
function fmtTokenAmount ( rawStr , decimals ) {
const s = String ( rawStr || "0" ) ;
const neg = s . startsWith ( "-" ) ;
const abs = neg ? s . slice ( 1 ) : s ;
const d = Number ( decimals ) || 0 ;
if ( d === 0 ) return ( neg ? "-" : "" ) + abs ;
const pad = abs . padStart ( d + 1 , "0" ) ;
const whole = pad . slice ( 0 , pad . length - d ) ;
const frac = pad . slice ( pad . length - d ) . replace ( /0+$/ , "" ) ;
return ( neg ? "-" : "" ) + whole + ( frac ? "." + frac : "" ) ;
}
function registerPanelMessages ( api ) {
api . onMessage ( "state" , ( _p , m ) => { fromPanel ( m ) ; return fullState ( ) ; } ) ;
api . onMessage ( "selectWallet" , ( p , m ) => {
fromPanel ( m ) ;
const id = String ( p && p . id || "" ) ;
if ( ! walletEntries ( ) . some ( ( w ) => w . id === id ) ) throw new Error ( "unknown wallet" ) ;
api . storage . set ( "selectedWalletId" , id ) ;
emitState ( ) ;
return fullState ( ) ;
} ) ;
api . onMessage ( "addWallet" , async ( p , m ) => {
fromPanel ( m ) ;
const chain = String ( p && p . chain || "" ) ;
const network = String ( p && p . network || "" ) ;
const meta = chainMeta ( chain , network ) ;
if ( ! meta ) throw new Error ( "unknown chain/network" ) ;
const list = walletEntries ( ) . slice ( ) ;
// If this coin+network declares a `legacyFirstPurpose` (SC does, to
// recover funds from the standalone siawallet addon) and no wallet of
// this coin+network exists yet, use that purpose verbatim. The bump
// to the /aegis-namespaced/ prefix only starts on the second sub-account.
const netMeta = COINS [ chain ] ? . networks ? . [ network ] || { } ;
const existingForCoin = list . filter ( ( w ) => w . chain === chain && w . network === network && ! w . isLegacy ) ;
let purpose , isLegacy = false ;
if ( netMeta . legacyFirstPurpose && existingForCoin . length === 0
&& ! list . some ( ( w ) => w . purpose === netMeta . legacyFirstPurpose ) ) {
purpose = netMeta . legacyFirstPurpose ;
isLegacy = true ;
} else {
const index = nextIndex ( list , meta ) ;
purpose = meta . purposePrefix + index ;
}
const idIndex = nextIndex ( list , meta ) ;
const id = makeWalletId ( meta , idIndex ) ;
if ( list . some ( ( w ) => w . id === id || w . purpose === purpose ) ) throw new Error ( "duplicate wallet" ) ;
const label = String ( p && p . label || "" ) . trim ( ) || autoLabel ( meta , list ) ;
const entry = { id , label , chain , network , purpose , isLegacy , createdAt : Date . now ( ) } ;
list . push ( entry ) ;
writeWallets ( api , list ) ;
api . storage . set ( "selectedWalletId" , id ) ;
ctx . runtimes . set ( id , { entry , phase : "locked" , error : null , adapter : null } ) ;
emitState ( ) ;
await mountWallet ( entry ) ;
return fullState ( ) ;
} ) ;
api . onMessage ( "removeWallet" , ( p , m ) => {
fromPanel ( m ) ;
const id = String ( p && p . id || "" ) ;
const list = walletEntries ( ) ;
const entry = list . find ( ( w ) => w . id === id ) ;
if ( ! entry ) throw new Error ( "unknown wallet" ) ;
if ( entry . isDefault ) throw new Error ( "the default wallet cannot be removed" ) ;
const next = list . filter ( ( w ) => w . id !== id ) ;
writeWallets ( api , next ) ;
if ( selectedWalletId ( ) === id ) api . storage . set ( "selectedWalletId" , next [ 0 ] ? . id || "" ) ;
unmountWallet ( id ) ;
// Drop per-wallet storage subtree.
const all = api . storage . all ? api . storage . all ( ) : { } ;
const prefix = ` wallets/ ${ id } / ` ;
for ( const k of Object . keys ( all ) ) if ( k . startsWith ( prefix ) ) api . storage . set ( k , null ) ;
emitState ( ) ;
return fullState ( ) ;
} ) ;
api . onMessage ( "renameWallet" , ( p , m ) => {
fromPanel ( m ) ;
const id = String ( p && p . id || "" ) ;
const label = String ( p && p . label || "" ) . trim ( ) . slice ( 0 , 60 ) ;
if ( ! label ) throw new Error ( "label required" ) ;
const list = walletEntries ( ) ;
const entry = list . find ( ( w ) => w . id === id ) ;
if ( ! entry ) throw new Error ( "unknown wallet" ) ;
entry . label = label ;
writeWallets ( api , list ) ;
emitState ( ) ;
return fullState ( ) ;
} ) ;
api . onMessage ( "refresh" , async ( _p , m ) => { fromPanel ( m ) ; const rt = requireSelected ( ) ; await rt . adapter . refresh ( true ) ; return snapshotForSelected ( ) ; } ) ;
api . onMessage ( "nextAddress" , ( _p , m ) => {
fromPanel ( m ) ;
const rt = requireSelected ( ) ;
if ( rt . entry . chain !== "bch" ) throw new Error ( "only BCH wallets have multiple receive addresses" ) ;
rt . adapter . nextAddress ( ) ;
return snapshotForSelected ( ) ;
} ) ;
api . onMessage ( "openUrl" , ( p , m ) => { fromPanel ( m ) ; api . openTab ( String ( p && p . url || "" ) ) ; return true ; } ) ;
api . onMessage ( "setBchServers" , ( p , m ) => {
fromPanel ( m ) ;
const patch = p || { } ;
if ( "servers" in patch ) {
const list = Array . isArray ( patch . servers ) ? patch . servers . map ( ( s ) => String ( s ) . trim ( ) ) . filter ( Boolean ) : [ ] ;
for ( const s of list ) if ( ! /^wss?:\/\/[^/\s]+$/i . test ( s ) ) throw new Error ( ` server must be ws(s)://host:port — got ${ s } ` ) ;
api . storage . set ( "servers" , list . length ? list : null ) ;
// Push the new server list into every mounted BCH wallet.
for ( const rt of ctx . runtimes . values ( ) ) {
if ( rt . entry . chain === "bch" && rt . adapter ) rt . adapter . setServers ( bchServerList ( api ) ) ;
}
}
return fullState ( ) ;
} ) ;
api . onMessage ( "setAccountPath" , ( p , m ) => {
fromPanel ( m ) ;
const patch = p || { } ;
const id = String ( patch . id || selectedWalletId ( ) ) ;
const entry = walletEntries ( ) . find ( ( w ) => w . id === id ) ;
if ( ! entry ) throw new Error ( "unknown wallet" ) ;
if ( ! [ "bch" , "dgb" , "btc" ] . includes ( entry . chain ) ) throw new Error ( "account path is a BCH/BTC/DGB-only setting" ) ;
const v = String ( patch . accountPath || "" ) . trim ( ) ;
if ( v && ! /^m(\/\d+'?)+$/ . test ( v ) ) throw new Error ( "derivation path must look like m/84'/0'/0'" ) ;
const list = walletEntries ( ) ;
const idx = list . findIndex ( ( w ) => w . id === id ) ;
// Per-network default: BTC/DGB come from the registry helper, BCH keeps
// its historical m/44'/145'/0'.
const dflt = entry . chain === "bch"
? "m/44'/145'/0'"
: ( defaultAccountPathFor ( COINS [ entry . chain ] , entry . network ) || "m/84'/0'/0'" ) ;
list [ idx ] = { ... list [ idx ] , accountPath : v || dflt } ;
writeWallets ( api , list ) ;
const rt = ctx . runtimes . get ( id ) ;
if ( rt && rt . adapter ) { try { rt . adapter . dispose ( ) ; } catch { } rt . adapter = null ; rt . phase = "locked" ; }
mountWallet ( list [ idx ] ) ;
return fullState ( ) ;
} ) ;
// ETH/SOL: per-wallet RPC URL, live-swap without key rebuild.
api . onMessage ( "setRpcUrl" , ( p , m ) => {
fromPanel ( m ) ;
const patch = p || { } ;
const id = String ( patch . id || selectedWalletId ( ) ) ;
const entry = walletEntries ( ) . find ( ( w ) => w . id === id ) ;
if ( ! entry ) throw new Error ( "unknown wallet" ) ;
if ( entry . chain !== "eth" && entry . chain !== "sol" ) throw new Error ( "RPC URL is an ETH/SOL setting" ) ;
const v = String ( patch . rpcUrl || "" ) . trim ( ) ;
if ( v && ! /^https?:\/\/[^\s]+$/i . test ( v ) ) throw new Error ( "RPC URL must start with http:// or https://" ) ;
api . storage . set ( ` wallets/ ${ id } /rpcUrl ` , v ) ;
const rt = ctx . runtimes . get ( id ) ;
if ( rt && rt . adapter && typeof rt . adapter . setRpcUrl === "function" ) {
rt . adapter . setRpcUrl ( v ) ;
rt . adapter . refresh ( ) . catch ( ( e ) => api . log ( ` [ ${ id } ] refresh: ` , e ? . message || e ) ) ;
}
emitState ( ) ;
return fullState ( ) ;
} ) ;
// Sia-only: per-wallet walletd URL. Live: pushes the URL into the adapter
// without rebuilding the keys (the seed stays derived from the same
// vault path — only the node the wallet talks to changes).
api . onMessage ( "setWalletdUrl" , ( p , m ) => {
fromPanel ( m ) ;
const patch = p || { } ;
const id = String ( patch . id || selectedWalletId ( ) ) ;
const entry = walletEntries ( ) . find ( ( w ) => w . id === id ) ;
if ( ! entry ) throw new Error ( "unknown wallet" ) ;
if ( entry . chain !== "sc" ) throw new Error ( "walletd URL is a Sia-only setting" ) ;
const v = String ( patch . walletdUrl || "" ) . trim ( ) ;
if ( v && ! /^https?:\/\/[^\s]+$/i . test ( v ) ) throw new Error ( "walletd URL must start with http:// or https://" ) ;
api . storage . set ( ` wallets/ ${ id } /walletdUrl ` , v ) ;
const rt = ctx . runtimes . get ( id ) ;
if ( rt && rt . adapter ) {
rt . adapter . setWalletdUrl ( v ) ;
if ( v ) rt . adapter . startPolling ( ) ;
rt . adapter . refresh ( true ) . catch ( ( e ) => api . log ( ` [ ${ id } ] refresh: ` , e ? . message || e ) ) ;
}
emitState ( ) ;
return fullState ( ) ;
} ) ;
// Live plan preview for the selected wallet.
api . onMessage ( "planSend" , async ( p , m ) => {
fromPanel ( m ) ;
const rt = requireSelected ( ) ;
const plan = await Promise . resolve ( rt . adapter . plan ( p || { } ) ) ;
return describePlan ( plan , rt . entry . chain , rt . entry . network ) ;
} ) ;
// SPL token plan/send — only meaningful when the selected wallet is SOL.
api . onMessage ( "planTokenSend" , async ( p , m ) => {
fromPanel ( m ) ;
const rt = requireSelected ( ) ;
if ( rt . entry . chain !== "sol" || typeof rt . adapter . planTokenTransfer !== "function" ) {
throw new Error ( "token send is a Solana-only flow" ) ;
}
const plan = await rt . adapter . planTokenTransfer ( p || { } ) ;
return {
recipients : plan . recipients , fee : String ( plan . fee ) , feeRate : String ( plan . feeRate ) ,
inputs : 0 , change : "0" , total : plan . total , mint : plan . mint , decimals : plan . decimals ,
} ;
} ) ;
api . onMessage ( "sendToken" , async ( p , m ) => {
fromPanel ( m ) ;
const rt = requireSelected ( ) ;
if ( rt . entry . chain !== "sol" ) throw new Error ( "token send is Solana-only" ) ;
const plan = await rt . adapter . planTokenTransfer ( p || { } ) ;
const meta = chainMeta ( "sol" , rt . entry . network ) ;
const tokenInfo = ( rt . adapter . snapshot ( ) . tokens || [ ] ) . find ( ( t ) => t . mint === plan . mint ) || { } ;
const rows = [
{ label : "To" , value : plan . recipients [ 0 ] . to , mono : true } ,
{ label : "Amount" , value : ` ${ fmtTokenAmount ( plan . recipients [ 0 ] . value , plan . decimals ) } ${ tokenInfo . symbol || "token" } ` , strong : true } ,
{ label : "Mint" , value : plan . mint , mono : true } ,
{ label : "Network fee" , value : ` ~ ${ fmtValue ( Number ( plan . fee ) , meta . decimals ) } SOL ${ ( plan . _spl && plan . _spl . destExists === false ) ? " (includes new token account rent)" : "" } ` } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — Solana · ${ rt . entry . network } ` } ,
] ;
const pick = await api . approvalModal ( {
title : ` Send ${ tokenInfo . symbol || "SPL token" } ? ` ,
origin : "Aegis wallet panel" ,
rows ,
actions : [ { id : "send" , label : "Send" , primary : true } ] ,
} ) ;
if ( pick !== "send" ) throw new Error ( "cancelled" ) ;
return rt . adapter . signAndBroadcastToken ( plan ) ;
} ) ;
// Execute a send with approval overlay.
api . onMessage ( "send" , async ( p , m ) => {
fromPanel ( m ) ;
const rt = requireSelected ( ) ;
const plan = await Promise . resolve ( rt . adapter . plan ( p || { } ) ) ;
const d = describePlan ( plan , rt . entry . chain , rt . entry . network ) ;
const meta = chainMeta ( rt . entry . chain , rt . entry . network ) ;
const rows = [
{ label : "To" , value : d . recipients [ 0 ] . to , mono : true } ,
{ label : "Amount" , value : ` ${ fmtValue ( d . recipients [ 0 ] . value , meta . decimals ) } ${ meta . ticker } ` , strong : true } ,
{ label : "Fee" , value : rt . entry . chain === "bch" ? ` ${ plan . fee } sat ( ${ plan . feeRate } sat/B) ` : ` ${ fmtValue ( plan . fee , meta . decimals ) } ${ meta . ticker } ` } ,
{ label : "Total" , value : ` ${ fmtValue ( d . total , meta . decimals ) } ${ meta . ticker } ` } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — ${ meta . coinLabel } · ${ meta . networkLabel } ` } ,
] ;
const pick = await api . approvalModal ( {
title : ` Send ${ meta . ticker } ? ` ,
origin : "Aegis wallet panel" ,
rows ,
actions : [ { id : "send" , label : "Send" , primary : true } ] ,
} ) ;
if ( pick !== "send" ) throw new Error ( "cancelled" ) ;
return rt . adapter . signAndBroadcast ( plan ) ;
} ) ;
api . onMessage ( "recovery" , async ( p , m ) => {
fromPanel ( m ) ;
const id = String ( p && p . id || selectedWalletId ( ) ) ;
const rt = requireWallet ( id ) ;
if ( typeof rt . adapter . recovery !== "function" ) throw new Error ( "this chain does not expose recovery details" ) ;
const r = rt . adapter . recovery ( ) ;
const out = { accountPath : r . accountPath , xpub : r . xpub , purpose : rt . entry . purpose } ;
if ( p && p . reveal ) {
// Sia's "xprv" is really the 32-byte wallet seed (walletd KeyFromSeed);
// BCH/DGB's is the account xprv. Warning copy fits both.
const pick = await api . approvalModal ( {
title : rt . entry . chain === "sc" ? "Reveal the wallet seed?" : "Reveal the account private key?" ,
origin : "Aegis wallet panel" ,
body : "Anyone holding this can spend every coin in this wallet. It stays on screen until you close the Settings tab." ,
actions : [ { id : "reveal" , label : "Reveal" , danger : true } ] ,
} ) ;
if ( pick === "reveal" ) out . xprv = r . xprv ;
}
return out ;
} ) ;
api . onMessage ( "permissions" , ( _p , m ) => { fromPanel ( m ) ; return permissions ( api ) ; } ) ;
api . onMessage ( "revoke" , ( p , m ) => {
fromPanel ( m ) ;
const perms = permissions ( api ) ;
delete perms [ String ( p && p . origin || "" ) ] ;
api . storage . set ( "permissions" , perms ) ;
return perms ;
} ) ;
}
// One "describePlan" is enough for both chains because plan() returns a
// common shape: {recipients:[{to,value}], fee, feeRate, total, inputs:[]…}.
function describePlan ( plan ) {
const sent = plan . recipients . reduce ( ( a , r ) => a + r . value , 0 ) ;
return {
recipients : plan . recipients , fee : plan . fee , feeRate : plan . feeRate ,
inputs : ( plan . inputs || [ ] ) . length , change : plan . change || null ,
total : plan . total != null ? plan . total : ( sent + plan . fee ) ,
} ;
}
// ---- dapp bridges (page → activate()) --------------------------------------
// Permission model stays the same as the single-wallet build for BCH:
// { [origin]: { readAddress:true, sendTx:{capSats,usedSats,grantedAt},
// trx: { readAddress:true, network } } }
// The BCH bridge always talks to the LEGACY default BCH wallet (the .x pages
// pre-date multi-wallet and cannot pick between them). The Tron bridge talks
// to the currently-SELECTED Tron wallet; if none is selected, requests fail.
const BCH _ALLOWANCES = [ 100000 , 1000000 , 10000000 ] ; // 0.001, 0.01, 0.1 BCH
const pendingByOrigin = new Set ( ) ;
function permissions ( api ) { const p = api . storage . get ( "permissions" , { } ) ; return p && typeof p === "object" ? p : { } ; }
async function withOriginLock ( origin , fn ) {
if ( pendingByOrigin . has ( origin ) ) throw new Error ( "a wallet request from this site is already waiting for approval" ) ;
pendingByOrigin . add ( origin ) ;
try { return await fn ( ) ; } finally { pendingByOrigin . delete ( origin ) ; }
}
// Only .x sites (BCNR-native TLD) get the BCH bridge, matching the pre-
// multi-wallet gate. Widening the manifest to https://*/* makes the Tron
// bridge available everywhere; the BCH side enforces its narrower rule
// inside the handlers.
function isBchOrigin ( origin ) {
try { const h = new URL ( origin ) . hostname ; return /\.x$/ . test ( h ) ; }
catch { return false ; }
}
function legacyBchRuntime ( ) {
const rt = ctx . runtimes . get ( LEGACY _BCH _WALLET _ID ) ;
if ( ! rt || rt . phase !== "ready" || ! rt . adapter ) throw new Error ( "wallet is not ready (vault locked?)" ) ;
return rt ;
}
// The Tron bridge routes to the currently-selected wallet if it is Tron;
// otherwise it looks for the first ready Tron wallet on the selected network
// hint; else rejects with "no tron wallet".
function activeTronRuntime ( ) {
const selId = selectedWalletId ( ) ;
const selRt = selId && ctx . runtimes . get ( selId ) ;
if ( selRt && selRt . entry . chain === "trx" && selRt . phase === "ready" ) return selRt ;
for ( const rt of ctx . runtimes . values ( ) ) if ( rt . entry . chain === "trx" && rt . phase === "ready" ) return rt ;
throw new Error ( "no Tron wallet available — add one in the Aegis sidebar" ) ;
}
function registerPageMessages ( api ) {
// ---- BCH bridge (unchanged behavior; wallet source is legacy default) ----
api . onMessage ( "getAddress" , async ( _p , m ) => {
const origin = fromPage ( m ) ;
if ( ! isBchOrigin ( origin ) ) throw new Error ( "this site is not on a Bitcoin Cash origin" ) ;
const rt = legacyBchRuntime ( ) ;
const perms = permissions ( api ) ;
if ( perms [ origin ] && perms [ origin ] . readAddress ) return rt . adapter . current ( ) . address ;
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Share your Bitcoin Cash address?" ,
origin ,
body : "The site will see your current receiving address and can look up its balance and history on the public chain." ,
rows : [ { label : "Address" , value : rt . adapter . current ( ) . address , mono : true } ] ,
actions : [ { id : "allow" , label : "Share" , primary : true } ] ,
checkbox : { id : "always" , label : "Always allow this site to see my address" } ,
} ) ;
if ( ! pick . startsWith ( "allow" ) ) throw new Error ( "user rejected" ) ;
if ( pick === "allow+always" ) { perms [ origin ] = { ... ( perms [ origin ] || { } ) , readAddress : true } ; api . storage . set ( "permissions" , perms ) ; emitState ( ) ; }
return rt . adapter . current ( ) . address ;
} ) ;
} ) ;
api . onMessage ( "signAndSend" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! isBchOrigin ( origin ) ) throw new Error ( "this site is not on a Bitcoin Cash origin" ) ;
const rt = legacyBchRuntime ( ) ;
return withOriginLock ( origin , async ( ) => {
let plan ;
try { plan = rt . adapter . plan ( p || { } ) ; }
catch ( e ) { throw new Error ( /insufficient funds|too small/i . test ( e ? . message ) ? "insufficient funds" : e ? . message || String ( e ) ) ; }
const d = describePlan ( plan ) ;
if ( d . recipients . length > 8 ) throw new Error ( "too many outputs" ) ;
const perms = permissions ( api ) ;
const budget = perms [ origin ] && perms [ origin ] . sendTx ;
const remaining = budget ? Math . max ( 0 , ( budget . capSats | 0 ) - ( budget . usedSats | 0 ) ) : 0 ;
if ( budget && d . total <= remaining ) {
const r = await rt . adapter . signAndBroadcast ( plan ) ;
budget . usedSats = ( budget . usedSats | 0 ) + d . total ;
api . storage . set ( "permissions" , perms ) ;
emitState ( ) ;
api . log ( ` silent send ${ d . total } sat for ${ origin } , ${ remaining - d . total } sat of allowance left ` ) ;
return { txid : r . txid } ;
}
const rows = d . recipients . map ( ( r , i ) => ( { label : d . recipients . length > 1 ? ` To # ${ i + 1 } ` : "To" , value : r . to , mono : true } ) ) ;
rows . push ( { label : "Amount" , value : fmtBch ( d . recipients . reduce ( ( a , r ) => a + r . value , 0 ) ) + " BCH" , strong : true } ) ;
rows . push ( { label : "Fee" , value : ` ${ plan . fee } sat ( ${ plan . feeRate } sat/B) ` } ) ;
rows . push ( { label : "Total" , value : fmtBch ( d . total ) + " BCH" } ) ;
const pick = await api . approvalModal ( {
title : "Send Bitcoin Cash?" ,
origin ,
body : budget
? ` This payment is over what is left of the site's allowance ( ${ fmtBch ( remaining ) } BCH). Check the address and amount. `
: "This site is asking your wallet to pay. Check the address and amount." ,
rows ,
actions : [ { id : "send" , label : "Send" , primary : true } ] ,
select : {
id : "cap" , label : "Afterwards" ,
options : [ { value : "" , label : "ask every time" } , ... BCH _ALLOWANCES . map ( ( s ) => ( { value : String ( s ) , label : ` allow up to ${ fmtBch ( s ) } BCH more without asking ` } ) ) ] ,
} ,
} ) ;
const [ action , ... flags ] = pick . split ( "+" ) ;
if ( action !== "send" ) throw new Error ( "user rejected" ) ;
const cap = flags . find ( ( f ) => f . startsWith ( "cap=" ) ) ;
const capSats = cap ? Number ( cap . slice ( 4 ) ) : 0 ;
if ( BCH _ALLOWANCES . includes ( capSats ) ) {
perms [ origin ] = { ... ( perms [ origin ] || { } ) , sendTx : { capSats , usedSats : 0 , grantedAt : Date . now ( ) } } ;
api . storage . set ( "permissions" , perms ) ;
} else if ( budget ) {
delete perms [ origin ] . sendTx ;
api . storage . set ( "permissions" , perms ) ;
}
emitState ( ) ;
const r = await rt . adapter . signAndBroadcast ( plan ) ;
return { txid : r . txid } ;
} ) ;
} ) ;
api . onMessage ( "signMessage" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! isBchOrigin ( origin ) ) throw new Error ( "this site is not on a Bitcoin Cash origin" ) ;
const rt = legacyBchRuntime ( ) ;
const message = String ( p && p . message != null ? p . message : "" ) ;
if ( message . length > 4096 ) throw new Error ( "message too long" ) ;
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Sign a message?" ,
origin ,
body : "Signing proves you control the address below. It moves no coins." ,
rows : [
{ label : "Message" , value : message . length > 400 ? message . slice ( 0 , 400 ) + "…" : message , mono : true } ,
{ label : "Address" , value : rt . adapter . current ( ) . address , mono : true } ,
] ,
actions : [ { id : "sign" , label : "Sign" , primary : true } ] ,
} ) ;
if ( pick !== "sign" ) throw new Error ( "user rejected" ) ;
return rt . adapter . signMessage ( message ) ;
} ) ;
} ) ;
// ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api . onMessage ( "trx.requestAccounts" , async ( _p , m ) => {
const origin = fromPage ( m ) ;
const rt = activeTronRuntime ( ) ;
const perms = permissions ( api ) ;
const alreadyOK = perms [ origin ] && perms [ origin ] . trx && perms [ origin ] . trx . readAddress ;
const snap = rt . adapter . snapshot ( ) ;
if ( alreadyOK ) return { code : 200 , address : snap . address , network : snap . network } ;
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Connect this site to your Tron wallet?" ,
origin ,
body : "The site will see this address and can build transactions for you to sign." ,
rows : [
{ label : "Address" , value : snap . address , mono : true } ,
{ label : "Network" , value : snap . network === "nile" ? "Nile testnet" : "Tron mainnet" } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — Tron · ${ snap . network === "nile" ? "Nile testnet" : "Mainnet" } ` } ,
] ,
actions : [ { id : "allow" , label : "Connect" , primary : true } ] ,
checkbox : { id : "always" , label : "Always allow this site to see this address" } ,
} ) ;
if ( ! pick . startsWith ( "allow" ) ) throw new Error ( "user rejected" ) ;
if ( pick === "allow+always" ) {
perms [ origin ] = { ... ( perms [ origin ] || { } ) , trx : { readAddress : true , network : snap . network } } ;
api . storage . set ( "permissions" , perms ) ;
emitState ( ) ;
}
return { code : 200 , address : snap . address , network : snap . network } ;
} ) ;
} ) ;
api . onMessage ( "trx.getAccount" , ( _p , m ) => {
const origin = fromPage ( m ) ;
const perms = permissions ( api ) ;
if ( ! ( perms [ origin ] && perms [ origin ] . trx && perms [ origin ] . trx . readAddress ) ) throw new Error ( "not connected — call tron_requestAccounts first" ) ;
const rt = activeTronRuntime ( ) ;
const snap = rt . adapter . snapshot ( ) ;
return { address : snap . address , network : snap . network } ;
} ) ;
// Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb).
// The wallet never guesses the intent — the approval overlay shows the
// decoded contract type and destination when it can, and always the txID.
api . onMessage ( "trx.signTransaction" , async ( p , m ) => {
const origin = fromPage ( m ) ;
const rt = activeTronRuntime ( ) ;
const perms = permissions ( api ) ;
if ( ! ( perms [ origin ] && perms [ origin ] . trx && perms [ origin ] . trx . readAddress ) ) throw new Error ( "not connected — call tron_requestAccounts first" ) ;
const tx = p && p . transaction ;
if ( ! tx || typeof tx !== "object" || ! tx . raw _data _hex || ! tx . raw _data ) throw new Error ( "bad transaction" ) ;
return withOriginLock ( origin , async ( ) => {
const contract = ( tx . raw _data . contract || [ ] ) [ 0 ] ;
const type = contract ? . type || "Contract" ;
const rows = [ { label : "Type" , value : type } , { label : "Tx ID" , value : tx . txID || "(unset)" , mono : true } ] ;
if ( type === "TransferContract" ) {
const v = contract . parameter ? . value || { } ;
try {
const to = v . to _address ? ctx . d . tronAdapter . hexToAddress ( v . to _address ) : ( v . to _address || "" ) ;
const amount = Number ( v . amount || 0 ) ;
rows . splice ( 1 , 0 , { label : "To" , value : to , mono : true } , { label : "Amount" , value : ` ${ fmtTrx ( amount ) } TRX ` , strong : true } ) ;
} catch { }
}
rows . push ( { label : "Wallet" , value : ` ${ rt . entry . label } — Tron · ${ rt . adapter . snapshot ( ) . network === "nile" ? "Nile testnet" : "Mainnet" } ` } ) ;
const pick = await api . approvalModal ( {
title : "Sign a Tron transaction?" ,
origin ,
body : "The site built this transaction. Check the type, amount, and destination before signing." ,
rows ,
actions : [ { id : "sign" , label : "Sign" , primary : true } ] ,
} ) ;
if ( pick !== "sign" ) throw new Error ( "user rejected" ) ;
const sig = rt . adapter . signRawData ( tx . raw _data _hex ) ;
const signed = { ... tx , signature : [ sig ] } ;
return signed ;
} ) ;
} ) ;
api . onMessage ( "trx.sendRawTransaction" , async ( p , m ) => {
const origin = fromPage ( m ) ;
const rt = activeTronRuntime ( ) ;
const perms = permissions ( api ) ;
if ( ! ( perms [ origin ] && perms [ origin ] . trx && perms [ origin ] . trx . readAddress ) ) throw new Error ( "not connected — call tron_requestAccounts first" ) ;
const signedTx = p && p . transaction ;
if ( ! signedTx || ! signedTx . raw _data _hex || ! Array . isArray ( signedTx . signature ) ) throw new Error ( "bad signed tx" ) ;
// No approval here — broadcasting a *signed* tx does not add any risk
// the sign step didn't already carry. Sites that don't want an extra
// network round-trip pass {broadcast:true} to sign; we support both.
return rt . adapter . broadcastSignedTx ( signedTx ) ;
} ) ;
api . onMessage ( "trx.signMessageV2" , async ( p , m ) => {
const origin = fromPage ( m ) ;
const rt = activeTronRuntime ( ) ;
const perms = permissions ( api ) ;
if ( ! ( perms [ origin ] && perms [ origin ] . trx && perms [ origin ] . trx . readAddress ) ) throw new Error ( "not connected — call tron_requestAccounts first" ) ;
const message = String ( p && p . message != null ? p . message : "" ) ;
if ( message . length > 4096 ) throw new Error ( "message too long" ) ;
return withOriginLock ( origin , async ( ) => {
const snap = rt . adapter . snapshot ( ) ;
const pick = await api . approvalModal ( {
title : "Sign a Tron message?" ,
origin ,
body : "Signing proves you control this address. It moves no coins." ,
rows : [
{ label : "Message" , value : message . length > 400 ? message . slice ( 0 , 400 ) + "…" : message , mono : true } ,
{ label : "Address" , value : snap . address , mono : true } ,
] ,
actions : [ { id : "sign" , label : "Sign" , primary : true } ] ,
} ) ;
if ( pick !== "sign" ) throw new Error ( "user rejected" ) ;
return rt . adapter . signMessageV2 ( message ) ;
} ) ;
} ) ;
// ---- Ethereum (EIP-1193) ---------------------------------------------
// Routes the same way as Tron: pick the currently-selected ETH wallet if
// any; else the first ready ETH wallet. Chain switches happen at the
// wallet-picker level, not here — dapps that call wallet_switchEthereumChain
// get a friendly "switch wallet in the Aegis sidebar" error.
function activeEthRuntime ( ) {
const selId = selectedWalletId ( ) ;
const selRt = selId && ctx . runtimes . get ( selId ) ;
if ( selRt && selRt . entry . chain === "eth" && selRt . phase === "ready" ) return selRt ;
for ( const rt of ctx . runtimes . values ( ) ) if ( rt . entry . chain === "eth" && rt . phase === "ready" ) return rt ;
throw new Error ( "no Ethereum wallet available — add one in the Aegis sidebar" ) ;
}
function ethConnectedFor ( origin ) {
const p = permissions ( api ) [ origin ] ;
return ! ! ( p && p . eth && p . eth . readAddress ) ;
}
api . onMessage ( "eth.requestAccounts" , async ( _p , m ) => {
const origin = fromPage ( m ) ;
const rt = activeEthRuntime ( ) ;
const snap = rt . adapter . snapshot ( ) ;
const chainIdHex = "0x" + Number ( snap . chainId ) . toString ( 16 ) ;
const networkVersion = String ( snap . chainId ) ;
const perms = permissions ( api ) ;
if ( perms [ origin ] && perms [ origin ] . eth && perms [ origin ] . eth . readAddress ) {
return { address : snap . address , chainIdHex , networkVersion } ;
}
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Connect this site to your Ethereum wallet?" ,
origin ,
body : "The site will see this address and can build transactions for you to sign." ,
rows : [
{ label : "Address" , value : snap . address , mono : true } ,
{ label : "Network" , value : snap . network === "mainnet" ? "Ethereum mainnet" : "Sepolia testnet" } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — Ethereum · ${ snap . network } ` } ,
] ,
actions : [ { id : "allow" , label : "Connect" , primary : true } ] ,
checkbox : { id : "always" , label : "Always allow this site to see this address" } ,
} ) ;
if ( ! pick . startsWith ( "allow" ) ) throw new Error ( "user rejected" ) ;
if ( pick === "allow+always" ) {
perms [ origin ] = { ... ( perms [ origin ] || { } ) , eth : { readAddress : true , chainId : snap . chainId } } ;
api . storage . set ( "permissions" , perms ) ;
emitState ( ) ;
}
return { address : snap . address , chainIdHex , networkVersion } ;
} ) ;
} ) ;
api . onMessage ( "eth.personalSign" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! ethConnectedFor ( origin ) ) throw new Error ( "not connected — call eth_requestAccounts first" ) ;
const rt = activeEthRuntime ( ) ;
const message = String ( p && p . message != null ? p . message : "" ) ;
if ( message . length > 4096 ) throw new Error ( "message too long" ) ;
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Sign an Ethereum message?" ,
origin ,
body : "Signing proves you control this address. It moves no ETH." ,
rows : [
{ label : "Message" , value : message . length > 400 ? message . slice ( 0 , 400 ) + "…" : message , mono : true } ,
{ label : "Address" , value : rt . adapter . snapshot ( ) . address , mono : true } ,
] ,
actions : [ { id : "sign" , label : "Sign" , primary : true } ] ,
} ) ;
if ( pick !== "sign" ) throw new Error ( "user rejected" ) ;
return rt . adapter . signMessage ( message ) ;
} ) ;
} ) ;
api . onMessage ( "eth.sendTransaction" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! ethConnectedFor ( origin ) ) throw new Error ( "not connected — call eth_requestAccounts first" ) ;
const rt = activeEthRuntime ( ) ;
const tx = ( p && p . tx ) || { } ;
if ( ! tx . to ) throw new Error ( "tx.to required" ) ;
// MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei;
// convert to numbers/bigints Aegis's own plan() understands.
const valueWei = tx . value ? BigInt ( tx . value ) . toString ( ) : "0" ;
return withOriginLock ( origin , async ( ) => {
const snap = rt . adapter . snapshot ( ) ;
const plan = await rt . adapter . plan ( { to : tx . to , amount : valueWei , sendMax : false } ) ;
const meta = chainMeta ( "eth" , rt . entry . network ) ;
const pick = await api . approvalModal ( {
title : "Send Ethereum transaction?" ,
origin ,
body : tx . data && tx . data !== "0x" ? "This transaction carries call data (a contract call). Check the destination + value carefully." : "This site is asking your wallet to send ETH." ,
rows : [
{ label : "To" , value : plan . recipients [ 0 ] . to , mono : true } ,
{ label : "Amount" , value : ` ${ fmtValue ( plan . recipients [ 0 ] . value , meta . decimals ) } ETH ` , strong : true } ,
{ label : "Fee (est.)" , value : ` ${ fmtValue ( plan . fee , meta . decimals ) } ETH ` } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — Ethereum · ${ snap . network } ` } ,
] ,
actions : [ { id : "send" , label : "Send" , primary : true } ] ,
} ) ;
if ( pick !== "send" ) throw new Error ( "user rejected" ) ;
const r = await rt . adapter . signAndBroadcast ( plan ) ;
return { txid : r . txid } ;
} ) ;
} ) ;
api . onMessage ( "eth.signTypedData" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! ethConnectedFor ( origin ) ) throw new Error ( "not connected — call eth_requestAccounts first" ) ;
const rt = activeEthRuntime ( ) ;
// Dapps send typedData as either a JSON string (older MetaMask spec) or
// an object (v4). Accept both; the encoder wants an object.
let td = p && p . typedData ;
if ( typeof td === "string" ) { try { td = JSON . parse ( td ) ; } catch ( e ) { throw new Error ( "typedData: JSON parse failed: " + e . message ) ; } }
if ( ! td || typeof td !== "object" ) throw new Error ( "typedData required" ) ;
// Compute the digest first — if the encoder rejects the input the user
// never sees an approval overlay for a broken payload.
let digest ;
try { digest = ctx . d . eip712 . digest ( td ) ; }
catch ( e ) { throw new Error ( "EIP-712 encode failed: " + e . message ) ; }
// Approval overlay: show domain (name + chain), primary type, and a
// truncated JSON preview of the message so the user has a fighting
// chance to spot phishing.
const dom = td . domain || { } ;
const domainSummary = [ dom . name , dom . version && ` v ${ dom . version } ` , dom . chainId && ` chain ${ dom . chainId } ` ] . filter ( Boolean ) . join ( " · " ) || "(no domain)" ;
const messagePreview = JSON . stringify ( td . message , null , 2 ) ;
const preview = messagePreview . length > 600 ? messagePreview . slice ( 0 , 600 ) + "…" : messagePreview ;
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Sign typed data (EIP-712)?" ,
origin ,
body : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell." ,
rows : [
{ label : "Domain" , value : domainSummary } ,
{ label : "Primary type" , value : String ( td . primaryType || "" ) } ,
{ label : "Message" , value : preview , mono : true } ,
{ label : "Address" , value : rt . adapter . snapshot ( ) . address , mono : true } ,
] ,
actions : [ { id : "sign" , label : "Sign" , primary : true } ] ,
} ) ;
if ( pick !== "sign" ) throw new Error ( "user rejected" ) ;
return rt . adapter . signTypedDataDigest ( digest ) ;
} ) ;
} ) ;
api . onMessage ( "eth.switchChain" , async ( p , m ) => {
fromPage ( m ) ;
const wantHex = String ( p && p . chainId || "" ) . toLowerCase ( ) ;
const wantId = Number ( wantHex ) ;
if ( ! Number . isFinite ( wantId ) || wantId <= 0 ) throw new Error ( "bad chainId" ) ;
// Find any wallet already on that chain and select it.
for ( const rt of ctx . runtimes . values ( ) ) {
if ( rt . entry . chain !== "eth" || rt . phase !== "ready" ) continue ;
const snap = rt . adapter . snapshot ( ) ;
if ( Number ( snap . chainId ) === wantId ) {
api . storage . set ( "selectedWalletId" , rt . entry . id ) ;
emitState ( ) ;
return null ;
}
}
// EIP-3326: throw the well-known "chain not added" code so dapps fall
// back to wallet_addEthereumChain.
const err = new Error ( ` Aegis: chainId ${ wantHex } is not added. Ask via wallet_addEthereumChain. ` ) ;
err . code = 4902 ;
throw err ;
} ) ;
// EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we
// persist the chain config and create a wallet on it under the same
// vault-derived key. Existing addresses on that chain remain visible on
// whatever wallet they were funded on — a chain add doesn't move any
// key material, just registers the network.
api . onMessage ( "eth.addChain" , async ( p , m ) => {
const origin = fromPage ( m ) ;
const spec = ( p && p . params ) || { } ;
const chainIdHex = String ( spec . chainId || "" ) . toLowerCase ( ) ;
const chainId = Number ( chainIdHex ) ;
if ( ! chainIdHex . startsWith ( "0x" ) || ! Number . isFinite ( chainId ) || chainId <= 0 ) {
throw new Error ( "wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')" ) ;
}
const chainName = String ( spec . chainName || "" ) . trim ( ) || ` EVM # ${ chainId } ` ;
const rpcUrls = Array . isArray ( spec . rpcUrls ) ? spec . rpcUrls . filter ( ( u ) => / ^ https ? : \ / \ //i.test(u)) : [];
const rpcUrl = rpcUrls [ 0 ] ;
if ( ! rpcUrl ) throw new Error ( "wallet_addEthereumChain: at least one https rpcUrls entry is required" ) ;
const explorerBase = Array . isArray ( spec . blockExplorerUrls ) && spec . blockExplorerUrls [ 0 ]
? String ( spec . blockExplorerUrls [ 0 ] ) . replace ( /\/+$/ , "" )
: null ;
const nc = spec . nativeCurrency || { } ;
const ticker = String ( nc . symbol || "ETH" ) . slice ( 0 , 6 ) . toUpperCase ( ) ;
// Reject if a wallet on this chain already exists — no-op success per
// EIP-3085 conventions.
const existing = walletEntries ( ) . find ( ( w ) => w . chain === "eth"
&& ( w . network === CUSTOM _ETH _PREFIX + chainId
|| ( chainMeta ( "eth" , w . network ) ? . chainId === chainId ) ) ) ;
if ( existing ) {
// Auto-connect the origin to this wallet — dapps expect the returned
// provider to be pointed at the added chain immediately.
const perms = permissions ( api ) ;
perms [ origin ] = { ... ( perms [ origin ] || { } ) , eth : { readAddress : true , chainId } } ;
api . storage . set ( "permissions" , perms ) ;
api . storage . set ( "selectedWalletId" , existing . id ) ;
emitState ( ) ;
return null ;
}
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Add an Ethereum chain?" ,
origin ,
body : "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs." ,
rows : [
{ label : "Chain name" , value : chainName } ,
{ label : "Chain ID" , value : ` ${ chainId } ( ${ chainIdHex } ) ` } ,
{ label : "Native ticker" , value : ticker } ,
{ label : "RPC" , value : rpcUrl , mono : true } ,
{ label : "Explorer" , value : explorerBase || "(none)" , mono : true } ,
] ,
actions : [ { id : "add" , label : "Add chain" , primary : true } ] ,
} ) ;
if ( pick !== "add" ) throw new Error ( "user rejected" ) ;
// Persist chain config + create a wallet on it.
const chains = customEthChains ( api ) ;
chains [ String ( chainId ) ] = {
chainId , chainName , rpcUrl ,
explorerTx : explorerBase ? explorerBase + "/tx/" : "" ,
explorerAddr : explorerBase ? explorerBase + "/address/" : "" ,
ticker , addedAt : Date . now ( ) , addedByOrigin : origin ,
} ;
api . storage . set ( "customEthChains" , chains ) ;
const network = CUSTOM _ETH _PREFIX + chainId ;
const meta = chainMeta ( "eth" , network ) ;
const list = walletEntries ( ) . slice ( ) ;
const index = nextIndex ( list , meta ) ;
const purpose = meta . purposePrefix + index ;
const id = makeWalletId ( meta , index ) ;
const label = ` ${ chainName } — ${ meta . short } ` ;
const entry = { id , label , chain : "eth" , network , purpose , createdAt : Date . now ( ) } ;
list . push ( entry ) ;
writeWallets ( api , list ) ;
api . storage . set ( "selectedWalletId" , id ) ;
// Grant the origin read access on this chain by default (they just
// approved adding it — implicit consent to also see the address).
const perms = permissions ( api ) ;
perms [ origin ] = { ... ( perms [ origin ] || { } ) , eth : { readAddress : true , chainId } } ;
api . storage . set ( "permissions" , perms ) ;
ctx . runtimes . set ( id , { entry , phase : "locked" , error : null , adapter : null } ) ;
emitState ( ) ;
await mountWallet ( entry ) ;
return null ;
} ) ;
} ) ;
// Cheap state peek — used by the main-world bridge right after a switch
// or add to emit accountsChanged / chainChanged without needing another
// approval overlay. Only returns the wallet the origin already sees.
api . onMessage ( "eth.state" , ( _p , m ) => {
const origin = fromPage ( m ) ;
if ( ! ethConnectedFor ( origin ) ) return { address : null , chainIdHex : "0x0" , networkVersion : "0" } ;
const rt = activeEthRuntime ( ) ;
const snap = rt . adapter . snapshot ( ) ;
return {
address : snap . address ,
chainIdHex : "0x" + Number ( snap . chainId ) . toString ( 16 ) ,
networkVersion : String ( snap . chainId ) ,
} ;
} ) ;
// Read passthrough: forward eth_getBalance / eth_call / etc. to the
// wallet's own configured RPC. Nothing here reveals the private key.
api . onMessage ( "eth.rpc" , async ( p , m ) => {
fromPage ( m ) ;
const rt = activeEthRuntime ( ) ;
const method = String ( p && p . method || "" ) ;
const params = ( p && p . params ) || [ ] ;
if ( ! /^eth_|^net_|^web3_/ . test ( method ) ) throw new Error ( "Aegis: only eth_/net_/web3_ read methods are passed through" ) ;
return rt . adapter . _client . call ( method , params ) ;
} ) ;
// ---- Solana (wallet-adapter) ------------------------------------------
function activeSolRuntime ( ) {
const selId = selectedWalletId ( ) ;
const selRt = selId && ctx . runtimes . get ( selId ) ;
if ( selRt && selRt . entry . chain === "sol" && selRt . phase === "ready" ) return selRt ;
for ( const rt of ctx . runtimes . values ( ) ) if ( rt . entry . chain === "sol" && rt . phase === "ready" ) return rt ;
throw new Error ( "no Solana wallet available — add one in the Aegis sidebar" ) ;
}
function solConnectedFor ( origin ) {
const p = permissions ( api ) [ origin ] ;
return ! ! ( p && p . sol && p . sol . readAddress ) ;
}
api . onMessage ( "sol.connect" , async ( _p , m ) => {
const origin = fromPage ( m ) ;
const rt = activeSolRuntime ( ) ;
const snap = rt . adapter . snapshot ( ) ;
if ( solConnectedFor ( origin ) ) return { address : snap . address , network : snap . network } ;
return withOriginLock ( origin , async ( ) => {
const pick = await api . approvalModal ( {
title : "Connect this site to your Solana wallet?" ,
origin ,
body : "The site will see this address and can build transactions for you to sign." ,
rows : [
{ label : "Address" , value : snap . address , mono : true } ,
{ label : "Network" , value : snap . network === "mainnet" ? "Mainnet-beta" : "Devnet" } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — Solana · ${ snap . network } ` } ,
] ,
actions : [ { id : "allow" , label : "Connect" , primary : true } ] ,
checkbox : { id : "always" , label : "Always allow this site to see this address" } ,
} ) ;
if ( ! pick . startsWith ( "allow" ) ) throw new Error ( "user rejected" ) ;
if ( pick === "allow+always" ) {
const perms = permissions ( api ) ;
perms [ origin ] = { ... ( perms [ origin ] || { } ) , sol : { readAddress : true , network : snap . network } } ;
api . storage . set ( "permissions" , perms ) ;
emitState ( ) ;
}
return { address : snap . address , network : snap . network } ;
} ) ;
} ) ;
api . onMessage ( "sol.signMessage" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! solConnectedFor ( origin ) ) throw new Error ( "not connected — call solana.connect first" ) ;
const rt = activeSolRuntime ( ) ;
const b64 = String ( p && p . messageB64 || "" ) ;
const bytes = Buffer . from ( b64 , "base64" ) ;
if ( bytes . length > 4096 ) throw new Error ( "message too long" ) ;
return withOriginLock ( origin , async ( ) => {
const preview = bytes . every ( ( c ) => c >= 0x20 && c < 0x7f ) ? bytes . toString ( "utf8" ) : ` < ${ bytes . length } bytes: 0x ${ bytes . toString ( "hex" ) . slice ( 0 , 60 ) } …> ` ;
const pick = await api . approvalModal ( {
title : "Sign a Solana message?" ,
origin ,
body : "Signing proves you control this address. It moves no SOL." ,
rows : [
{ label : "Message" , value : preview . length > 400 ? preview . slice ( 0 , 400 ) + "…" : preview , mono : true } ,
{ label : "Address" , value : rt . adapter . snapshot ( ) . address , mono : true } ,
] ,
actions : [ { id : "sign" , label : "Sign" , primary : true } ] ,
} ) ;
if ( pick !== "sign" ) throw new Error ( "user rejected" ) ;
return rt . adapter . signMessage ( bytes ) ;
} ) ;
} ) ;
// Dapp-built transaction. The main-world bridge passes the FULL wire
// (tx.serialize({requireAllSignatures:false, verifySignatures:false}))
// — signature slots the dapp already filled with partialSign() are
// preserved; our wallet only overwrites its own slot. That's the only
// way to sign multi-signer transactions the dapp has partially
// co-signed (co-signer sigs, ephemeral session keys, etc.).
api . onMessage ( "sol.signAndSend" , async ( p , m ) => {
const origin = fromPage ( m ) ;
if ( ! solConnectedFor ( origin ) ) throw new Error ( "not connected — call solana.connect first" ) ;
const rt = activeSolRuntime ( ) ;
const wireB64 = String ( p && p . wireB64 || "" ) ;
if ( ! wireB64 ) throw new Error ( "wireB64 required (full serialized transaction)" ) ;
const wire = new Uint8Array ( Buffer . from ( wireB64 , "base64" ) ) ;
// Parse: compact-u16(sigCount) || sig[0..64]*sigCount || message
let off = 0 ;
const readCompactU16 = ( ) => {
let n = 0 , shift = 0 ;
while ( true ) {
const b = wire [ off ++ ] ;
n |= ( b & 0x7f ) << shift ;
if ( ( b & 0x80 ) === 0 ) break ;
shift += 7 ;
if ( shift > 21 ) throw new Error ( "compact-u16 too long" ) ;
}
return n ;
} ;
const sigCount = readCompactU16 ( ) ;
if ( sigCount < 1 || sigCount > 32 ) throw new Error ( "bad signature count " + sigCount ) ;
const sigsStart = off ;
const messageStart = sigsStart + sigCount * 64 ;
if ( wire . length < messageStart ) throw new Error ( "truncated tx wire" ) ;
const messageBytes = wire . slice ( messageStart ) ;
// Parse the message enough to find our pubkey's index in the account
// list. Layout: header(3) || compactU16(keyCount) || key[32]*keyCount || …
if ( messageBytes . length < 3 + 1 + 32 ) throw new Error ( "message too short" ) ;
const numRequiredSigs = messageBytes [ 0 ] ;
let moff = 3 ;
const readKeyCount = ( ) => {
let n = 0 , shift = 0 ;
while ( true ) {
const b = messageBytes [ moff ++ ] ;
n |= ( b & 0x7f ) << shift ;
if ( ( b & 0x80 ) === 0 ) break ;
shift += 7 ;
}
return n ;
} ;
const keyCount = readKeyCount ( ) ;
if ( keyCount < 1 || keyCount > 64 ) throw new Error ( "bad account key count" ) ;
// Find our public key among the key list.
const ourPub = rt . adapter . _pub ;
let ourIndex = - 1 ;
for ( let i = 0 ; i < keyCount ; i ++ ) {
const key = messageBytes . subarray ( moff + i * 32 , moff + ( i + 1 ) * 32 ) ;
let eq = true ;
for ( let j = 0 ; j < 32 ; j ++ ) if ( key [ j ] !== ourPub [ j ] ) { eq = false ; break ; }
if ( eq ) { ourIndex = i ; break ; }
}
if ( ourIndex < 0 ) throw new Error ( "this wallet's key is not among the transaction's account keys" ) ;
if ( ourIndex >= numRequiredSigs ) throw new Error ( ` this wallet's key is not a required signer (index ${ ourIndex } , requiredSigs ${ numRequiredSigs } ) ` ) ;
return withOriginLock ( origin , async ( ) => {
const snap = rt . adapter . snapshot ( ) ;
const otherSigners = numRequiredSigs > 1 ? numRequiredSigs - 1 : 0 ;
const pick = await api . approvalModal ( {
title : "Sign + send a Solana transaction?" ,
origin ,
body : "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing." ,
rows : [
{ label : "Message size" , value : ` ${ messageBytes . length } bytes ` } ,
{ label : "Required signers" , value : otherSigners
? ` ${ numRequiredSigs } — you (slot # ${ ourIndex } ) + ${ otherSigners } other ${ otherSigners === 1 ? "" : "s" } `
: "1 — you" } ,
{ label : "Address" , value : snap . address , mono : true } ,
{ label : "Wallet" , value : ` ${ rt . entry . label } — Solana · ${ snap . network } ` } ,
] ,
actions : [ { id : "send" , label : "Sign & send" , primary : true } ] ,
} ) ;
if ( pick !== "send" ) throw new Error ( "user rejected" ) ;
// Sign the message and patch our slot. Any partial signatures already
// in the wire (from tx.partialSign()) at other slots are preserved.
const sigInfo = rt . adapter . signMessage ( messageBytes ) ;
const sigBytes = ctx . d . base58check . decodeBase58 ( sigInfo . signature ) ;
if ( sigBytes . length !== 64 ) throw new Error ( "bad ed25519 signature length" ) ;
const wireOut = new Uint8Array ( wire ) ; // copy so we don't mutate caller
wireOut . set ( sigBytes , sigsStart + ourIndex * 64 ) ;
const wireB58 = ctx . d . base58check . encodeBase58 ( wireOut ) ;
const txid = await rt . adapter . _client . call ( "sendTransaction" , [ wireB58 ] ) ;
if ( typeof txid !== "string" || ! txid . length ) throw new Error ( "bad txid from RPC: " + JSON . stringify ( txid ) ) ;
setTimeout ( ( ) => rt . adapter . refresh ( ) . catch ( ( ) => { } ) , 4000 ) ;
return { txid } ;
} ) ;
} ) ;
}
// ---- activate ---------------------------------------------------------------
module . exports = {
activate ( api ) {
2026-09-08 22:40:48 +02:00
// No explicit icon — inherit manifest.icon (branded shield data URI)
// so the toolbar dock button renders the aegis.x brand mark instead
// of a fallback emoji.
api . registerSidebarPanel ( { id : "main" , title : "Wallet" , page : "panel.html" } ) ;
2026-09-08 18:17:25 +02:00
const c = ctx = {
api ,
d : null ,
runtimes : new Map ( ) , // walletId -> { entry, phase, error, adapter }
} ;
migrateLegacyStorage ( api ) ;
registerPanelMessages ( api ) ;
registerPageMessages ( api ) ;
loadDeps ( api ) . then ( ( d ) => {
if ( ctx !== c ) return ;
c . d = d ;
return mountAllWallets ( ) ;
} ) . catch ( ( e ) => {
if ( ctx !== c ) return ;
api . log ( "startup failed:" , e ? . message ) ;
emitState ( ) ;
} ) ;
} ,
deactivate ( ) {
const c = ctx ; ctx = null ;
if ( ! c ) return ;
for ( const rt of c . runtimes . values ( ) ) {
try { rt . adapter && rt . adapter . dispose ( ) ; } catch { }
}
c . runtimes . clear ( ) ;
} ,
} ;