From c7c7e428c72cb2e93eb3f0a56f12edc1ba01c523 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 8 Sep 2026 18:17:25 +0200 Subject: [PATCH] Ship Theseus 0.3.28 5d15508b (Aegis update card + DevTools in tab sidebar + real favicons) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup 5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643 Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4 Bundled since 0.3.27: 32d4b75 - Aegis (bchwallet) gains its own update card in Settings > General beside Ariadne. Check for updates hits the same signed OTA endpoint the boot timer uses; Restart to apply appears when a signed newer version is staged. Uses the existing addons-check-updates + a new app-restart IPC. New Aegis versions ship without a Theseus release. 32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to the right of the tab (mode: 'right') instead of a detached window. Matches stock Chrome. Users who prefer detached can drag out via the DevTools own toolbar. b71c925 - Search-engine favicons in Settings > Search now use Google's /s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough hosts (Brave, Bing, Yandex, etc.) that half the list was falling through to the emoji placeholder. Deployed. Verified LIVE 0.3.28. --- addon.json | 15 + electrum-servers.json | 9 + index.js | 1654 ++++++++++++++++++++++++++++++++++ lib/base58check.js | 83 ++ lib/cashaddr.js | 102 +++ lib/chain-bch.js | 156 ++++ lib/chain-btc.js | 531 +++++++++++ lib/chain-dgb.js | 451 +++++++++ lib/chain-eth.js | 361 ++++++++ lib/chain-sia.js | 182 ++++ lib/chain-sol.js | 473 ++++++++++ lib/chain-tron.js | 353 ++++++++ lib/dgb/core/address.d.ts | 10 + lib/dgb/core/address.js | 53 ++ lib/dgb/core/descriptor.d.ts | 5 + lib/dgb/core/descriptor.js | 18 + lib/dgb/core/hd.d.ts | 8 + lib/dgb/core/hd.js | 24 + lib/dgb/core/index.d.ts | 6 + lib/dgb/core/index.js | 7 + lib/dgb/core/network.d.ts | 13 + lib/dgb/core/network.js | 74 ++ lib/dgb/core/package.json | 1 + lib/dgb/core/seed.d.ts | 4 + lib/dgb/core/seed.js | 17 + lib/dgb/core/wif.d.ts | 27 + lib/dgb/core/wif.js | 72 ++ lib/dgb/psbt/build.d.ts | 4 + lib/dgb/psbt/build.js | 56 ++ lib/dgb/psbt/index.d.ts | 3 + lib/dgb/psbt/index.js | 4 + lib/dgb/psbt/package.json | 1 + lib/dgb/psbt/sign.d.ts | 9 + lib/dgb/psbt/sign.js | 25 + lib/dgb/psbt/types.d.ts | 23 + lib/dgb/psbt/types.js | 2 + lib/eip712.js | 143 +++ lib/electrum.js | 131 +++ lib/keys.js | 66 ++ lib/sia/keys.js | 29 + lib/sia/sia.js | 142 +++ lib/sia/wallet.js | 201 +++++ lib/sia/walletd.js | 42 + lib/sol-spl.js | 220 +++++ lib/tx.js | 122 +++ lib/wallet.js | 243 +++++ panel.html | 374 ++++++++ panel.js | 836 +++++++++++++++++ qr.js | 218 +++++ wallet-inject.js | 506 +++++++++++ 50 files changed, 8109 insertions(+) create mode 100644 addon.json create mode 100644 electrum-servers.json create mode 100644 index.js create mode 100644 lib/base58check.js create mode 100644 lib/cashaddr.js create mode 100644 lib/chain-bch.js create mode 100644 lib/chain-btc.js create mode 100644 lib/chain-dgb.js create mode 100644 lib/chain-eth.js create mode 100644 lib/chain-sia.js create mode 100644 lib/chain-sol.js create mode 100644 lib/chain-tron.js create mode 100644 lib/dgb/core/address.d.ts create mode 100644 lib/dgb/core/address.js create mode 100644 lib/dgb/core/descriptor.d.ts create mode 100644 lib/dgb/core/descriptor.js create mode 100644 lib/dgb/core/hd.d.ts create mode 100644 lib/dgb/core/hd.js create mode 100644 lib/dgb/core/index.d.ts create mode 100644 lib/dgb/core/index.js create mode 100644 lib/dgb/core/network.d.ts create mode 100644 lib/dgb/core/network.js create mode 100644 lib/dgb/core/package.json create mode 100644 lib/dgb/core/seed.d.ts create mode 100644 lib/dgb/core/seed.js create mode 100644 lib/dgb/core/wif.d.ts create mode 100644 lib/dgb/core/wif.js create mode 100644 lib/dgb/psbt/build.d.ts create mode 100644 lib/dgb/psbt/build.js create mode 100644 lib/dgb/psbt/index.d.ts create mode 100644 lib/dgb/psbt/index.js create mode 100644 lib/dgb/psbt/package.json create mode 100644 lib/dgb/psbt/sign.d.ts create mode 100644 lib/dgb/psbt/sign.js create mode 100644 lib/dgb/psbt/types.d.ts create mode 100644 lib/dgb/psbt/types.js create mode 100644 lib/eip712.js create mode 100644 lib/electrum.js create mode 100644 lib/keys.js create mode 100644 lib/sia/keys.js create mode 100644 lib/sia/sia.js create mode 100644 lib/sia/wallet.js create mode 100644 lib/sia/walletd.js create mode 100644 lib/sol-spl.js create mode 100644 lib/tx.js create mode 100644 lib/wallet.js create mode 100644 panel.html create mode 100644 panel.js create mode 100644 qr.js create mode 100644 wallet-inject.js diff --git a/addon.json b/addon.json new file mode 100644 index 0000000..d824c36 --- /dev/null +++ b/addon.json @@ -0,0 +1,15 @@ +{ + "id": "bchwallet", + "name": "Aegis Wallet", + "version": "0.3.1", + "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", + "author": "Silent Mode", + "icon": "🛡", + "main": "index.js", + "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], + "absorbs": ["siawallet"], + "page-inject": { + "preload": "wallet-inject.js", + "origins": ["https://*/*"] + } +} diff --git a/electrum-servers.json b/electrum-servers.json new file mode 100644 index 0000000..a3681e3 --- /dev/null +++ b/electrum-servers.json @@ -0,0 +1,9 @@ +[ + "wss://bch.imaginary.cash:50004", + "wss://electrum.imaginary.cash:50004", + "wss://fulcrum.jettscythe.xyz:50004", + "wss://bch.loping.net:50004", + "wss://bch.soul-dev.com:50004", + "wss://cashnode.bch.ninja:50004", + "wss://blackie.c3-soft.com:50004" +] diff --git a/index.js b/index.js new file mode 100644 index 0000000..a4b05fa --- /dev/null +++ b/index.js @@ -0,0 +1,1654 @@ +// 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[]` 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//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) { + api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "🛡", page: "panel.html" }); + 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(); + }, +}; diff --git a/lib/base58check.js b/lib/base58check.js new file mode 100644 index 0000000..8cb4c8d --- /dev/null +++ b/lib/base58check.js @@ -0,0 +1,83 @@ +// Base58Check encode/decode for Tron addresses (0x41 || H160 || sha256d[:4]). +// Bitcoin-style base58 alphabet; the caller supplies the raw 21-byte payload +// (version byte first) so this module knows nothing about Tron itself. +const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const INDEX = new Int8Array(128).fill(-1); +for (let i = 0; i < ALPHABET.length; i++) INDEX[ALPHABET.charCodeAt(i)] = i; + +module.exports = function makeBase58Check({ sha256 }) { + function encodeBase58(bytes) { + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + // Convert base-256 → base-58 by repeated division. + const b58 = new Uint8Array(Math.ceil(bytes.length * 138 / 100 + 1)); + let length = 0; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + let j = 0; + for (let k = b58.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += (b58[k] << 8) >>> 0; + b58[k] = carry % 58; + carry = (carry / 58) | 0; + } + length = j; + } + // Skip leading zero-bytes in the base58 buffer, then prepend '1' per leading zero-byte in input. + let it = b58.length - length; + while (it < b58.length && b58[it] === 0) it++; + let out = ""; + for (let i = 0; i < zeros; i++) out += ALPHABET[0]; + for (; it < b58.length; it++) out += ALPHABET[b58[it]]; + return out; + } + + function decodeBase58(str) { + if (typeof str !== "string" || str.length === 0) throw new Error("base58: empty input"); + let zeros = 0; + while (zeros < str.length && str[zeros] === ALPHABET[0]) zeros++; + const out = new Uint8Array(Math.ceil(str.length * 733 / 1000 + 1)); + let length = 0; + for (let i = zeros; i < str.length; i++) { + const c = str.charCodeAt(i); + const val = c < 128 ? INDEX[c] : -1; + if (val < 0) throw new Error("base58: bad character " + JSON.stringify(str[i])); + let carry = val; + let j = 0; + for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += 58 * out[k]; + out[k] = carry & 0xff; + carry >>>= 8; + } + length = j; + } + let it = out.length - length; + while (it < out.length && out[it] === 0) it++; + const total = zeros + (out.length - it); + const decoded = new Uint8Array(total); + for (let i = 0; i < zeros; i++) decoded[i] = 0; + let p = zeros; + while (it < out.length) decoded[p++] = out[it++]; + return decoded; + } + + function encodeCheck(payload) { + const bytes = payload instanceof Uint8Array ? payload : Uint8Array.from(payload); + const check = sha256(sha256(bytes)).slice(0, 4); + const full = new Uint8Array(bytes.length + 4); + full.set(bytes, 0); + full.set(check, bytes.length); + return encodeBase58(full); + } + + function decodeCheck(str) { + const full = decodeBase58(str); + if (full.length < 5) throw new Error("base58check: too short"); + const payload = full.slice(0, full.length - 4); + const check = full.slice(full.length - 4); + const want = sha256(sha256(payload)).slice(0, 4); + for (let i = 0; i < 4; i++) if (check[i] !== want[i]) throw new Error("base58check: bad checksum"); + return payload; + } + + return { encodeBase58, decodeBase58, encodeCheck, decodeCheck }; +}; diff --git a/lib/cashaddr.js b/lib/cashaddr.js new file mode 100644 index 0000000..67390e1 --- /dev/null +++ b/lib/cashaddr.js @@ -0,0 +1,102 @@ +// CashAddr (BCH address format) encode/decode + legacy Base58Check decode, so +// the send form accepts whatever the user pastes but the UI only ever shows +// cashaddr. Pure JS, no deps — the polymod is tiny. +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const CHARSET_REV = Object.fromEntries([...CHARSET].map((c, i) => [c, i])); + +function polymod(values) { + let c = 1n; + for (const d of values) { + const c0 = c >> 35n; + c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d); + if (c0 & 0x01n) c ^= 0x98f2bc8e61n; + if (c0 & 0x02n) c ^= 0x79b76d99e2n; + if (c0 & 0x04n) c ^= 0xf33e5fb3c4n; + if (c0 & 0x08n) c ^= 0xae2eabe2a8n; + if (c0 & 0x10n) c ^= 0x1e4f43e470n; + } + return c ^ 1n; +} +const prefixExpand = (prefix) => [...prefix].map((c) => c.charCodeAt(0) & 0x1f).concat([0]); + +function convertBits(data, from, to, pad) { + let acc = 0, bits = 0; const out = []; + const maxv = (1 << to) - 1; + for (const v of data) { + acc = (acc << from) | v; bits += from; + while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } + } + if (pad) { if (bits > 0) out.push((acc << (to - bits)) & maxv); } + else if (bits >= from || ((acc << (to - bits)) & maxv)) throw new Error("cashaddr: bad padding"); + return out; +} + +// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit). +function encode(prefix, type, hash) { + if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported"); + const versionByte = (type << 3) | 0; // size bits 000 = 160 + const payload = convertBits([versionByte, ...hash], 8, 5, true); + const mod = polymod([...prefixExpand(prefix), ...payload, 0, 0, 0, 0, 0, 0, 0, 0]); + const checksum = []; + for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn)); + return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join(""); +} + +// Accepts "prefix:payload" or a bare payload (assumes defaultPrefix). +function decode(address, defaultPrefix = "bitcoincash") { + const raw = String(address).trim(); + if (raw !== raw.toLowerCase() && raw !== raw.toUpperCase()) throw new Error("cashaddr: mixed case"); + const s = raw.toLowerCase(); + const i = s.lastIndexOf(":"); + const prefix = i >= 0 ? s.slice(0, i) : defaultPrefix; + const payloadStr = i >= 0 ? s.slice(i + 1) : s; + if (!/^[a-z0-9]+$/.test(prefix) || payloadStr.length < 8) throw new Error("cashaddr: malformed"); + const values = [...payloadStr].map((c) => { + if (!(c in CHARSET_REV)) throw new Error("cashaddr: bad character"); + return CHARSET_REV[c]; + }); + if (polymod([...prefixExpand(prefix), ...values]) !== 0n) throw new Error("cashaddr: bad checksum"); + const data = convertBits(values.slice(0, -8), 5, 8, false); + const versionByte = data[0]; + const type = (versionByte >> 3) & 0x0f; + const size = [20, 24, 28, 32, 40, 48, 56, 64][versionByte & 0x07]; + const hash = Uint8Array.from(data.slice(1)); + if (hash.length !== size) throw new Error("cashaddr: hash size mismatch"); + return { prefix, type, hash }; +} + +// Legacy Base58Check (1... / 3... on mainnet). sha256 is injected so this +// file stays dependency-free. +const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +function decodeLegacy(address, sha256) { + const s = String(address).trim(); + let n = 0n; + for (const c of s) { + const v = B58.indexOf(c); + if (v < 0) throw new Error("base58: bad character"); + n = n * 58n + BigInt(v); + } + const bytes = []; + while (n > 0n) { bytes.unshift(Number(n & 0xffn)); n >>= 8n; } + for (const c of s) { if (c !== "1") break; bytes.unshift(0); } + if (bytes.length !== 25) throw new Error("base58: wrong length"); + const body = Uint8Array.from(bytes.slice(0, 21)); + const check = sha256(sha256(body)).slice(0, 4); + for (let k = 0; k < 4; k++) if (check[k] !== bytes[21 + k]) throw new Error("base58: bad checksum"); + const type = body[0] === 0x00 ? 0 : body[0] === 0x05 ? 1 : null; + if (type == null) throw new Error("base58: not a mainnet address"); + return { prefix: "bitcoincash", type, hash: body.slice(1) }; +} + +// Anything a user might paste -> { type, hash, cashaddr }. Rejects other +// prefixes so a chipnet address can never be paid on mainnet by accident. +function parseAny(input, sha256, prefix = "bitcoincash") { + const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:"); + if (!s) throw new Error("empty address"); + const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix); + if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`); + if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type"); + return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) }; +} + +module.exports = { encode, decode, decodeLegacy, parseAny }; diff --git a/lib/chain-bch.js b/lib/chain-bch.js new file mode 100644 index 0000000..cdddf39 --- /dev/null +++ b/lib/chain-bch.js @@ -0,0 +1,156 @@ +// BCH chain adapter — wraps the existing keys.js / wallet.js / tx.js / +// electrum.js / cashaddr.js code with the common adapter shape that the +// multi-wallet manager talks to. Every BCH wallet is one BIP32 account +// derived from its own 32-byte root (from api.vault.derive). +// +// Deps are handed in so index.js loads @noble/* once and shares them across +// every wallet, rather than each adapter dynamic-importing on its own. + +const BCH_NETWORKS = { + mainnet: { + id: "mainnet", + label: "Mainnet", + prefix: "bitcoincash", + defaultAccountPath: "m/44'/145'/0'", + explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", + explorerAddr: "https://blockchair.com/bitcoin-cash/address/", + defaultServers: [ + "wss://bch.imaginary.cash:50004", + "wss://cashnode.bch.ninja:50004", + "wss://electroncash.dk:50004", + "wss://fulcrum.jettscythe.xyz:50004", + ], + faucet: null, + }, + chipnet: { + id: "chipnet", + label: "Chipnet testnet", + prefix: "bchtest", + // BIP44 testnet coin type is 1 across every chain; the account is still 0. + defaultAccountPath: "m/44'/1'/0'", + explorerTx: "https://chipnet.imaginary.cash/tx/", + explorerAddr: "https://chipnet.imaginary.cash/address/", + defaultServers: [ + "wss://chipnet.imaginary.cash:50004", + "wss://chipnet.bch.ninja:50004", + ], + faucet: "https://tbch.googol.cash/", + }, +}; + +module.exports = function makeBchAdapter({ + HDKey, secp256k1, sha256, ripemd160, cashaddr, + keysLib, tx, electrum, WebSocket, +}) { + + // storage is the FULL api.storage. keyPrefix scopes every read/write under + // "wallets//…" so multiple BCH wallets don't stomp each other. + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + class BchWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + network = "mainnet", accountPath, + } = {}) { + if (!walletId) throw new Error("chain-bch: walletId required"); + const net = BCH_NETWORKS[network]; + if (!net) throw new Error(`chain-bch: unknown network ${network}`); + this.walletId = walletId; + this.chain = "bch"; + this.network = net.id; + this._net = net; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + // Servers: caller-provided override → user-set custom list (handled by + // index.js already, this is a fallback path) → the network's built-in + // defaults so a wallet always has somewhere to connect. + this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); + const wantPath = accountPath || net.defaultAccountPath; + this._accountPath = /^m(\/\d+'?)+$/.test(wantPath) ? wantPath : net.defaultAccountPath; + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._keys = new keysLib.WalletKeys(root32, this._accountPath, net.prefix); + this._root = new Uint8Array(root32); + const walletFactory = require("./wallet.js"); + this._wallet = walletFactory({ + client: this._client, keys: this._keys, tx, cashaddr, sha256, + storage: this.storage, + log: (...a) => this.log(...a), + onChange: () => this._emit(), + }); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); + this._client.setServers(this._servers); + } + + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + const w = this._wallet.snapshot(); + return { + chain: "bch", + network: this._net.id, + ticker: "BCH", + decimals: 8, + address: w.address, + addressIndex: w.addressIndex, + addressPath: w.addressPath, + balance: w.balance, + height: w.height, + history: w.history, + scanning: w.scanning, + error: w.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._accountPath, + xpub: this._keys.xpub, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + }; + } + + async refresh(full) { return this._wallet.refresh(!!full); } + nextAddress() { return this._wallet.nextUnusedAddress(); } + current() { return this._wallet.current(); } + plan(spec) { + const targets = Array.isArray(spec.outputs) && spec.outputs.length + ? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value })) + : [{ to: spec.to, value: spec.amount ?? spec.value }]; + return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax }); + } + async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); } + // 65-byte BIP-137 recoverable signature — the format Electron Cash and + // most BCH tooling verify against. + signMessage(message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "Bitcoin Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const entry = this.current(); + const sig = this._keys.signRecoverable(entry, digest); + return { address: entry.address, signature: Buffer.from(sig).toString("base64") }; + } + recovery() { + return { accountPath: this._accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + dispose() { + try { this._wallet.dispose(); } catch {} + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { BchWallet, BCH_NETWORKS }; +}; diff --git a/lib/chain-btc.js b/lib/chain-btc.js new file mode 100644 index 0000000..a21525b --- /dev/null +++ b/lib/chain-btc.js @@ -0,0 +1,531 @@ +// Bitcoin (BTC) chain adapter — mainnet + testnet3. BIP84 native SegWit, +// bitcoinjs-lib for the tx/PSBT primitives, Aegis's own ElectrumX transport +// for the network side. Very close in shape to chain-dgb.js; the two could +// share a "bip84-electrum" helper later, but for now a distinct file keeps +// the chain-specific tuning (electrum pool, network object) visible. +// +// Address family: BIP84 only in this rev — bc1q… (mainnet) / tb1q… (testnet). +// BIP44 (1…) and BIP49 (3…) are trivially reachable by editing accountPath +// to m/44'/0'/0' or m/49'/0'/0' respectively; the PSBT layer already +// supports the resulting scripts because bitcoinjs-lib does. An explicit +// address-family picker like DGB's is a follow-up. + +const NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", + hrp: "bc", coinType: 0, + defaultAccountPath: "m/84'/0'/0'", + explorerTx: "https://mempool.space/tx/", + explorerAddr: "https://mempool.space/address/", + defaultServers: [ + "wss://electrum.blockstream.info:50004", + "wss://bitcoin.lu.ke:50004", + "wss://fulcrum.grey.pw:50004", + ], + faucet: null, + }, + testnet: { + id: "testnet", label: "Testnet3", + hrp: "tb", coinType: 1, + defaultAccountPath: "m/84'/1'/0'", + explorerTx: "https://mempool.space/testnet/tx/", + explorerAddr: "https://mempool.space/testnet/address/", + defaultServers: [ + "wss://testnet.aranguren.org:51004", + "wss://blockstream.info:993", + ], + faucet: "https://coinfaucet.eu/en/btc-testnet/", + }, + signet: { + // Signet (BIP-325) shares testnet's address format and SLIP-44 coin + // type (1), so bitcoinjs-lib's `networks.testnet` handles address + // derivation unchanged. The chain itself is a separate, permissioned + // testnet with its own genesis + signer-signed blocks; from a wallet's + // point of view, the only differences are the electrum pool serving + // it and the explorer URL for tx lookups. + id: "signet", label: "Signet", + hrp: "tb", coinType: 1, + defaultAccountPath: "m/84'/1'/0'", + explorerTx: "https://mempool.space/signet/tx/", + explorerAddr: "https://mempool.space/signet/address/", + defaultServers: [ + "wss://signet.aranguren.org:51102", + "wss://signet-electrumx.wakiyamap.dev:50003", + ], + faucet: "https://signetfaucet.com/", + }, +}; + +module.exports = function makeBtcAdapter({ + bitcoinjs, bip32Factory, ecpairFactory, ecc, sha256, electrum, +}) { + if (!bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) { + throw new Error("chain-btc: missing dep"); + } + const { payments, Psbt, networks: bjsNetworks } = bitcoinjs; + const bip32 = bip32Factory(ecc); + const ECPair = ecpairFactory(ecc); + // Taproot (p2tr) address derivation needs bitcoinjs-lib's schnorr backend + // wired to a curve implementation — @bitcoinerlab/secp256k1 provides both + // ECDSA and schnorr, so initEccLib once at load makes p2tr resolve. + try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {} + + // Map our network id → bitcoinjs-lib Network object. Signet shares + // testnet's address prefixes + magic (BIP-325 defines only new consensus + // rules; the p2p / address layer stays testnet-compatible). + function bjsNetworkFor(id) { + if (id === "mainnet") return bjsNetworks.bitcoin; + if (id === "testnet" || id === "signet") return bjsNetworks.testnet; + throw new Error("chain-btc: unknown network " + id); + } + + const toHex = (b) => Buffer.from(b).toString("hex"); + const scripthashOf = (scriptBuf) => Buffer.from(sha256(scriptBuf)).reverse().toString("hex"); + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // Address-family shape from the derivation-path purpose. Every field the + // PSBT layer might need for signing an input funded by this family is + // captured here so signAndBroadcast has one code path per family. + function paymentFor(purpose, node, network) { + const pubkey = Buffer.from(node.publicKey); + if (purpose === 44) { + // Legacy P2PKH. Signing needs the full previous transaction + // (nonWitnessUtxo) — one extra electrum call per input at send time. + const p = payments.p2pkh({ pubkey, network }); + return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh", needsPrevTx: true }; + } + if (purpose === 49) { + // P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh + // output) alongside the witnessUtxo. + const redeem = payments.p2wpkh({ pubkey, network }); + const p = payments.p2sh({ redeem, network }); + return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" }; + } + if (purpose === 86) { + // BIP86 Taproot key-path. Signing goes through a tap-tweaked ECPair + // (see signerFor + signAndBroadcast); the internal 32-byte x-only + // pubkey is captured here so the PSBT input can carry it. + const internalPubkey = Buffer.from(pubkey.subarray(1, 33)); + const p = payments.p2tr({ internalPubkey, network }); + return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" }; + } + // Default: BIP84 native SegWit. + const p = payments.p2wpkh({ pubkey, network }); + return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" }; + } + function purposeOfPath(accountPath) { + const m = /^m\/(\d+)'\//.exec(String(accountPath || "")); + return m ? Number(m[1]) : 84; + } + + class WalletKeys { + constructor(root32, accountPath, bjsNetwork) { + this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'"; + this._purpose = purposeOfPath(this._accountPath); + this._network = bjsNetwork; + this._root = bip32.fromSeed(Buffer.from(root32), bjsNetwork); + this._account = this._root.derivePath(this._accountPath); + this._branch = [this._account.derive(0), this._account.derive(1)]; + this._cache = new Map(); + } + get xpub() { return this._account.neutered().toBase58(); } + get xprv() { return this._account.toBase58(); } + get accountPath() { return this._accountPath; } + get purpose() { return this._purpose; } + entry(branch, index) { + const k = branch + "/" + index; + let e = this._cache.get(k); + if (!e) { + const node = this._branch[branch].derive(index); + const pay = paymentFor(this._purpose, node, this._network); + e = { + branch, index, path: this._accountPath + "/" + branch + "/" + index, + publicKey: Buffer.from(node.publicKey), + family: pay.family, sendKind: pay.send, + script: pay.output, scriptHex: pay.output.toString("hex"), + scripthash: scripthashOf(pay.output), + address: pay.address, + redeemScript: pay.redeem || null, + tapInternalKey: pay.internalPubkey || null, + _node: node, + }; + this._cache.set(k, e); + } + return e; + } + signerFor(entry) { + return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._network }); + } + wipe() { + for (const e of this._cache.values()) e._node = null; + this._cache.clear(); + this._branch = null; + this._account = null; + this._root = null; + } + } + + // Fee vsize model per family. Values are rounded vsize contributions from + // standard tx-size tables; the estimator is pessimistic enough to cover a + // real broadcast without underpaying. + const OVERHEAD_VB = 10.5; + const OUTPUT_VB = 31; // P2WPKH / P2SH / P2PKH outputs are all ~31 vB give or take + const INPUT_VB = { + p2pkh: 148, // (32+4)+1+107+4 legacy input + "p2sh-p2wpkh": 91, // 40 base + ~205/4 witness + p2wpkh: 68, // 41 base + 108/4 witness + p2tr: 58, // 41 base + 66/4 witness (key-path) + }; + const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb); + + class BtcWallet { + constructor(root32, networkId, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + accountPath, + } = {}) { + if (!walletId) throw new Error("chain-btc: walletId required"); + const net = NETWORKS[networkId]; + if (!net) throw new Error("chain-btc: unknown network " + networkId); + this.walletId = walletId; + this.chain = "btc"; + this.network = net.id; + this._net = net; + this._bjsNet = bjsNetworkFor(net.id); + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); + const wantPath = accountPath || net.defaultAccountPath; + this._keys = new WalletKeys(root32, wantPath, this._bjsNet); + this._root = new Uint8Array(root32); + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._state = { + used: new Set(), + watched: new Map(), + height: 0, + balance: { confirmed: 0, unconfirmed: 0 }, + utxos: [], + history: [], + receiveIndex: 0, + scanning: false, + error: null, + }; + this._refreshTimer = null; + this._subscribedHeaders = false; + this._client.onNotify = (method, params) => { + if (method === "blockchain.headers.subscribe") { + const h = params && params[0] && params[0].height; + if (h) { this._state.height = h; this._scheduleRefresh(1500); } + } else if (method === "blockchain.scripthash.subscribe") { + this._scheduleRefresh(800); + } + }; + } + + _emit() { try { this.onChange(); } catch {} } + + async _historyOf(entry) { + const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]); + return Array.isArray(h) ? h : []; + } + async _scan() { + const cursor = Number(this.storage.get("receiveCursor", 0)) || 0; + const GAP = 20; + for (const branch of [0, 1]) { + let gap = 0, i = 0; + const minIndex = branch === 0 ? cursor + 1 : 0; + while (gap < GAP || i < minIndex + GAP) { + const batch = []; + for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k)); + const results = await Promise.all(batch.map((e) => this._historyOf(e))); + for (let k = 0; k < batch.length; k++) { + const e = batch[k]; + this._state.watched.set(e.scripthash, e); + if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++; + i++; + if (gap >= GAP && i >= minIndex + GAP) break; + } + } + } + let r = cursor; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + async _subscribeAll() { + if (!this._subscribedHeaders) { + this._subscribedHeaders = true; + const tip = await this._client.subscribe("blockchain.headers.subscribe", []); + if (tip && tip.height) this._state.height = tip.height; + } + await Promise.all([...this._state.watched.values()].map((e) => + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); + } + async _loadUtxos() { + const lists = await Promise.all([...this._state.watched.values()].map(async (e) => { + const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]); + return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + })); + this._state.utxos = lists.flat(); + let confirmed = 0, unconfirmed = 0; + for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } + this._state.balance = { confirmed, unconfirmed }; + } + async _loadHistory() { + const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index)); + const merged = new Map(); + const lists = await Promise.all(entries.map((e) => this._historyOf(e))); + for (const list of lists) for (const h of list) { + const prev = merged.get(h.tx_hash); + if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); + } + const ordered = [...merged.values()].sort((a, b) => { + const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; + return hb - ha; + }).slice(0, 25); + const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex)); + const out = []; + for (const h of ordered) { + let received = 0, spent = 0; + try { + const t = await this._client.call("blockchain.transaction.get", [h.txid, true]); + for (const o of t.vout || []) { + const hex = o.scriptPubKey && o.scriptPubKey.hex; + if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8); + } + for (const i of t.vin || []) { + if (!i.txid) continue; + try { + const p = await this._client.call("blockchain.transaction.get", [i.txid, true]); + const po = p.vout && p.vout[i.vout]; + const hex = po && po.scriptPubKey && po.scriptPubKey.hex; + if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8); + } catch {} + } + out.push({ + txid: h.txid, height: h.height, confirmations: t.confirmations || 0, + time: t.blocktime || t.time || 0, + delta: received - spent, fee: null, to: null, + status: (t.confirmations || 0) > 0 ? "confirmed" : "pending", + kind: "transfer", + }); + } catch { + out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" }); + } + } + this._state.history = out; + } + async refresh(full = false) { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + if (full || !this._state.watched.size) await this._scan(); + else { + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + await this._loadUtxos(); + await this._loadHistory(); + await this._subscribeAll(); + for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index); + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + _scheduleRefresh(ms = 800) { + clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => this.refresh(false), ms); + } + + current() { return this._keys.entry(0, this._state.receiveIndex); } + nextAddress() { + let r = this._state.receiveIndex + 1; + while (this._state.used.has("0/" + r)) r++; + this.storage.set("receiveCursor", r); + this._state.receiveIndex = r; + const e = this._keys.entry(0, r); + this._state.watched.set(e.scripthash, e); + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}); + this._emit(); + return this.current(); + } + _changeEntry() { + let i = 0; + while (this._state.used.has("1/" + i)) i++; + return this._keys.entry(1, i); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); + this._client.setServers(this._servers); + } + + plan({ to, amount, feeRate = 5, sendMax = false }) { + const rate = Math.min(500, Math.max(1, Number(feeRate) || 5)); + const dest = String(to || ""); + try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); } + catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); } + const cur = this.current(); + if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`); + const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const change = this._changeEntry(); + const kind = cur.sendKind; + if (sendMax) { + const chosen = spendable; + const sum = chosen.reduce((a, u) => a + u.value, 0); + const fee = feeVb(kind, chosen.length, 1, rate); + if (sum <= fee) throw new Error("balance does not cover the fee"); + return { + _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind, + recipients: [{ to: dest, value: sum - fee }], + fee, feeRate: rate, change: 0, + total: sum, + }; + } + const value = Math.round(Number(amount) || 0); + if (!(value > 0)) throw new Error("amount must be > 0"); + let sum = 0; const chosen = []; + for (const u of spendable) { + chosen.push(u); sum += u.value; + const withChange = feeVb(kind, chosen.length, 2, rate); + if (sum >= value + withChange) { + const changeVal = sum - value - withChange; + const fee = changeVal > 546 ? withChange : sum - value; + return { + _chosen: chosen, _rate: rate, _to: dest, _value: value, + _change: change, _changeVal: changeVal > 546 ? changeVal : 0, _kind: kind, + recipients: [{ to: dest, value }], + fee, feeRate: rate, + change: changeVal > 546 ? changeVal : 0, + total: value + fee, + }; + } + } + throw new Error("insufficient funds"); + } + + async signAndBroadcast(plan) { + // BIP44 inputs need the whole previous transaction (nonWitnessUtxo) + // so PSBT can compute a legacy sighash; fetch each one in parallel + // before assembling the PSBT. + const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44"); + const prevHex = new Map(); + if (needsPrev.length) { + const results = await Promise.all(needsPrev.map((u) => + this._client.call("blockchain.transaction.get", [u.txid, false]) + )); + needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i]))); + } + const psbt = new Psbt({ network: this._bjsNet }); + for (const u of plan._chosen) { + const inp = { hash: u.txid, index: u.vout }; + const fam = u.entry.family; + if (fam === "bip44") { + inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex"); + } else { + inp.witnessUtxo = { script: u.entry.script, value: u.value }; + if (fam === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript; + if (fam === "bip86" && u.entry.tapInternalKey) inp.tapInternalKey = u.entry.tapInternalKey; + } + psbt.addInput(inp); + } + const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; + if (!plan._sendMax && plan._changeVal > 0) { + outputs.push({ address: plan._change.address, value: plan._changeVal }); + } + for (const o of outputs) psbt.addOutput(o); + for (let i = 0; i < plan._chosen.length; i++) { + const entry = plan._chosen[i].entry; + // Taproot key-path: bitcoinjs-lib matches the signer's publicKey + // against the tweaked output key, so the signer has to be the + // internal ECPair tweaked with sha256("TapTweak" || internalPubkey). + // ECPair.tweak() from the ecpair package does exactly that (its + // internal state becomes the tap-tweaked keypair) and its + // signSchnorr is what PSBT calls for a key-path spend. + if (entry.family === "bip86") { + const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._bjsNet }); + const tweak = bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey); + const tweaked = raw.tweak(tweak); + psbt.signInput(i, tweaked); + } else { + psbt.signInput(i, this._keys.signerFor(entry)); + } + } + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + const hex = tx.toHex(); + const txid = await this._client.call("blockchain.transaction.broadcast", [hex]); + if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); + this.log("broadcast", txid); + this._scheduleRefresh(1200); + return { txid, hex, fee: plan.fee }; + } + + // BIP-137 recoverable over sha256d("Bitcoin Signed Message:\n" || msg). + signMessage(message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "Bitcoin Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const entry = this.current(); + const signer = this._keys.signerFor(entry); + const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey); + const out = Buffer.alloc(65); + out[0] = 27 + sig.recoveryId + 4; // +4 = compressed + Buffer.from(sig.signature).copy(out, 1); + return { address: entry.address, signature: out.toString("base64") }; + } + + recovery() { + return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + + snapshot() { + const cur = this.current(); + return { + chain: "btc", network: this._net.id, ticker: "BTC", decimals: 8, + address: cur.address, addressIndex: this._state.receiveIndex, + addressPath: cur.path, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._keys.accountPath, + xpub: this._keys.xpub, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + }; + } + + dispose() { + clearTimeout(this._refreshTimer); + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { BtcWallet, NETWORKS }; +}; diff --git a/lib/chain-dgb.js b/lib/chain-dgb.js new file mode 100644 index 0000000..85d32f4 --- /dev/null +++ b/lib/chain-dgb.js @@ -0,0 +1,451 @@ +// DigiByte (DGB) chain adapter — thin bridge over the @dgb-wallet/* packages +// vendored under lib/dgb/ from D:\Dev\SilentCode\Digibyte\packages\{core,psbt}. +// Address derivation, network params and PSBT construction come from the +// upstream design; Aegis provides the runtime shell (ElectrumX transport, +// gap-limit scanning, wallet-manager plumbing). +// +// Backend: DGB ElectrumX pool via Theseus's existing lib/electrum.js — same +// TCP-over-wss stack the BCH wallet uses, no separate protocol adapter. +// Optional Blockbook mode is on the roadmap; ElectrumX is the default +// because it matches Aegis's transport shape and needs no per-server keys. +// +// Only BIP84 (m/84'/20'/0'/0/x → dgb1q…) is exposed in this rev; the +// vendored core also supports BIP44 (D…) and BIP49 (S…) — plumb them by +// switching the purpose passed to accountNode(). Address recovery from any +// BIP39 tool at coin type 20 is guaranteed by bitcoinjs-lib's Network +// object, so a seed exported here can be restored on iancoleman.io/bip39 +// or the SilentCode Digibyte web-wallet with matching addresses. + +const NETWORK = "mainnet"; +const DEFAULT_PURPOSE = 84; +const EXPLORER_TX = "https://digiexplorer.info/tx/"; +const EXPLORER_ADDR = "https://digiexplorer.info/address/"; +const DEFAULT_SERVERS = [ + "wss://electrum1.cyberbits.eu:50022", + "wss://electrum3.cyberbits.eu:50022", + "wss://electrum1.digibyteblockexplorer.com:50022", +]; + +module.exports = function makeDgbAdapter({ + dgbCore, // ESM namespace of @dgb-wallet/core (vendored) + dgbPsbt, // ESM namespace of @dgb-wallet/psbt (vendored) + bitcoinjs, // require("bitcoinjs-lib") + bip32Factory, // require("bip32").BIP32Factory + ecpairFactory, // require("ecpair").ECPairFactory + ecc, // require("@bitcoinerlab/secp256k1") + sha256, // @noble/hashes/sha2 (only used for scripthash reversal) + electrum, +}) { + if (!dgbCore || !dgbPsbt || !bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) { + throw new Error("chain-dgb: missing dep"); + } + const { rootFromSeed, accountNode, addressNode, p2wpkhAddress, digibyte, DGB_COIN_TYPE } = dgbCore; + const { buildPsbt, signAllInputs, finalizeAndExtract, feeSats } = dgbPsbt; + const { payments, Psbt } = bitcoinjs; + const bip32 = bip32Factory(ecc); + const ECPair = ecpairFactory(ecc); + + const toHex = (b) => Buffer.from(b).toString("hex"); + // Electrum scripthash: sha256(scriptPubKey), byte-reversed, hex. + function scripthashOf(scriptBuf) { + const h = sha256(scriptBuf); + const rev = Buffer.from(h).reverse(); + return rev.toString("hex"); + } + function scriptPubKeyBuf(pubkeyBuf) { + return payments.p2wpkh({ pubkey: pubkeyBuf, network: digibyte }).output; + } + + // ---- keys -------------------------------------------------------------- + // The HD node is the sole owner of the private key material; every derived + // entry keeps a reference so PSBT.signInput(index, node) can sign each + // input under its own key. + class WalletKeys { + constructor(root32, accountPath) { + const purpose = parsePurposeFromPath(accountPath) || DEFAULT_PURPOSE; + this._purpose = purpose; + // bip32.fromSeed uses bitcoinjs-lib's Network object — pass DGB's so + // extended keys serialize with the right BIP32 magic (0x0488B21E). + this._root = bip32.fromSeed(Buffer.from(root32), digibyte); + this._account = this._root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/0'`); + this._accountPath = `m/${purpose}'/${DGB_COIN_TYPE}'/0'`; + this._branch = [this._account.derive(0), this._account.derive(1)]; + this._cache = new Map(); + } + get xpub() { return this._account.neutered().toBase58(); } + get xprv() { return this._account.toBase58(); } + get accountPath() { return this._accountPath; } + entry(branch, index) { + const k = branch + "/" + index; + let e = this._cache.get(k); + if (!e) { + const node = this._branch[branch].derive(index); + const pubkey = Buffer.from(node.publicKey); + const address = p2wpkhAddress(node, digibyte); + const script = Buffer.from(scriptPubKeyBuf(pubkey)); + e = { + branch, index, path: this._accountPath + "/" + branch + "/" + index, + publicKey: pubkey, script, scriptHex: script.toString("hex"), + scripthash: scripthashOf(script), + address, + _node: node, + }; + this._cache.set(k, e); + } + return e; + } + signerFor(entry) { + // bitcoinjs-lib's PSBT accepts anything with .publicKey + .sign(hash). + // BIP32Interface fits, but ECPair.fromPrivateKey gives a plain signer + // that matches what the DGB web-wallet uses — pick that for parity. + return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: digibyte }); + } + wipe() { + // BIP32Interface holds Buffers; drop references so GC picks them up. + for (const e of this._cache.values()) e._node = null; + this._cache.clear(); + this._branch = null; + this._account = null; + this._root = null; + } + } + function parsePurposeFromPath(p) { + const m = /^m\/(\d+)'\/\d+'\/\d+'$/.exec(String(p || "")); + return m ? Number(m[1]) : null; + } + + // ---- vsize model (fee estimation ahead of PSBT.getFee) ----------------- + const OVERHEAD_VB = 10.5; + const P2WPKH_INPUT_VB = 68; + const P2WPKH_OUTPUT_VB = 31; + const feeVb = (nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * P2WPKH_INPUT_VB + nOut * P2WPKH_OUTPUT_VB) * feePerVb); + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // ---- wallet ------------------------------------------------------------ + class DgbWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + accountPath, + } = {}) { + if (!walletId) throw new Error("chain-dgb: walletId required"); + this.walletId = walletId; + this.chain = "dgb"; + this.network = NETWORK; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._servers = Array.isArray(servers) && servers.length ? servers : DEFAULT_SERVERS.slice(); + this._keys = new WalletKeys(root32, accountPath); + this._root = new Uint8Array(root32); + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._state = { + used: new Set(), + watched: new Map(), + height: 0, + balance: { confirmed: 0, unconfirmed: 0 }, + utxos: [], + history: [], + receiveIndex: 0, + scanning: false, + error: null, + }; + this._refreshTimer = null; + this._subscribedHeaders = false; + this._client.onNotify = (method, params) => { + if (method === "blockchain.headers.subscribe") { + const h = params && params[0] && params[0].height; + if (h) { this._state.height = h; this._scheduleRefresh(1500); } + } else if (method === "blockchain.scripthash.subscribe") { + this._scheduleRefresh(800); + } + }; + } + + _emit() { try { this.onChange(); } catch {} } + + // ---- discovery (gap-limit) ----------------------------------------- + async _historyOf(entry) { + const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]); + return Array.isArray(h) ? h : []; + } + async _scan() { + const cursor = Number(this.storage.get("receiveCursor", 0)) || 0; + const GAP = 20; + for (const branch of [0, 1]) { + let gap = 0, i = 0; + const minIndex = branch === 0 ? cursor + 1 : 0; + while (gap < GAP || i < minIndex + GAP) { + const batch = []; + for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k)); + const results = await Promise.all(batch.map((e) => this._historyOf(e))); + for (let k = 0; k < batch.length; k++) { + const e = batch[k]; + this._state.watched.set(e.scripthash, e); + if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++; + i++; + if (gap >= GAP && i >= minIndex + GAP) break; + } + } + } + let r = cursor; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + async _subscribeAll() { + if (!this._subscribedHeaders) { + this._subscribedHeaders = true; + const tip = await this._client.subscribe("blockchain.headers.subscribe", []); + if (tip && tip.height) this._state.height = tip.height; + } + await Promise.all([...this._state.watched.values()].map((e) => + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); + } + async _loadUtxos() { + const lists = await Promise.all([...this._state.watched.values()].map(async (e) => { + const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]); + return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + })); + this._state.utxos = lists.flat(); + let confirmed = 0, unconfirmed = 0; + for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } + this._state.balance = { confirmed, unconfirmed }; + } + async _loadHistory() { + // Best-effort tx-history summary: pull the transactions listed against + // any used scripthash, sum ours-vs-not to get a delta per tx. Fine on + // the DGB electrum pool (get_transaction verbose is supported). + const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index)); + const merged = new Map(); + const lists = await Promise.all(entries.map((e) => this._historyOf(e))); + for (const list of lists) for (const h of list) { + const prev = merged.get(h.tx_hash); + if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); + } + const ordered = [...merged.values()].sort((a, b) => { + const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; + return hb - ha; + }).slice(0, 25); + const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex)); + const out = []; + for (const h of ordered) { + let received = 0, spent = 0; + try { + const t = await this._client.call("blockchain.transaction.get", [h.txid, true]); + for (const o of t.vout || []) { + const hex = o.scriptPubKey && o.scriptPubKey.hex; + if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8); + } + for (const i of t.vin || []) { + if (!i.txid) continue; + try { + const p = await this._client.call("blockchain.transaction.get", [i.txid, true]); + const po = p.vout && p.vout[i.vout]; + const hex = po && po.scriptPubKey && po.scriptPubKey.hex; + if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8); + } catch {} + } + out.push({ + txid: h.txid, height: h.height, confirmations: t.confirmations || 0, + time: t.blocktime || t.time || 0, + delta: received - spent, fee: null, to: null, + status: (t.confirmations || 0) > 0 ? "confirmed" : "pending", + kind: "transfer", + }); + } catch { + out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" }); + } + } + this._state.history = out; + } + async refresh(full = false) { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + if (full || !this._state.watched.size) await this._scan(); + else { + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + await this._loadUtxos(); + await this._loadHistory(); + await this._subscribeAll(); + for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index); + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + _scheduleRefresh(ms = 800) { + clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => this.refresh(false), ms); + } + + current() { return this._keys.entry(0, this._state.receiveIndex); } + nextAddress() { + let r = this._state.receiveIndex + 1; + while (this._state.used.has("0/" + r)) r++; + this.storage.set("receiveCursor", r); + this._state.receiveIndex = r; + const e = this._keys.entry(0, r); + this._state.watched.set(e.scripthash, e); + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}); + this._emit(); + return this.current(); + } + _changeEntry() { + let i = 0; + while (this._state.used.has("1/" + i)) i++; + return this._keys.entry(1, i); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : DEFAULT_SERVERS.slice(); + this._client.setServers(this._servers); + } + + // ---- plan + sign (via @dgb-wallet/psbt) ----------------------------- + plan({ to, amount, feeRate = 20, sendMax = false }) { + const rate = Math.min(500, Math.max(1, Number(feeRate) || 20)); + const dest = String(to || ""); + // The `payments` decoder will reject anything that isn't a valid + // DGB address; catch and re-raise as a sane error. + try { payments.address({ address: dest, network: digibyte }); } + catch { + // bitcoinjs-lib's address decoder is `address.toOutputScript`, not + // payments.address; use it for validation. + try { bitcoinjs.address.toOutputScript(dest, digibyte); } + catch (e) { throw new Error(`bad DGB address: ${e?.message || dest}`); } + } + const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const change = this._changeEntry(); + if (sendMax) { + const chosen = spendable; + const sum = chosen.reduce((a, u) => a + u.value, 0); + const fee = feeVb(chosen.length, 1, rate); + if (sum <= fee) throw new Error("balance does not cover the fee"); + return { + _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, + recipients: [{ to: dest, value: sum - fee }], + fee, feeRate: rate, change: 0, + total: sum, + }; + } + const value = Math.round(Number(amount) || 0); + if (!(value > 0)) throw new Error("amount must be > 0"); + let sum = 0; const chosen = []; + for (const u of spendable) { + chosen.push(u); sum += u.value; + const withChange = feeVb(chosen.length, 2, rate); + if (sum >= value + withChange) { + const changeVal = sum - value - withChange; + const fee = changeVal > 546 ? withChange : sum - value; + return { + _chosen: chosen, _rate: rate, _to: dest, _value: value, + _change: change, _changeVal: changeVal > 546 ? changeVal : 0, + recipients: [{ to: dest, value }], + fee, feeRate: rate, + change: changeVal > 546 ? changeVal : 0, + total: value + fee, + }; + } + } + throw new Error("insufficient funds"); + } + + async signAndBroadcast(plan) { + const psbtInputs = plan._chosen.map((u) => ({ + txid: u.txid, + vout: u.vout, + witness: { scriptHex: u.entry.scriptHex, value: u.value }, + })); + const psbtOutputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; + if (!plan._sendMax && plan._changeVal > 0) { + psbtOutputs.push({ address: plan._change.address, value: plan._changeVal }); + } + const psbt = buildPsbt({ inputs: psbtInputs, outputs: psbtOutputs }, digibyte); + // Sign per-input with the exact key that funded that UTXO. signAllInputs + // would work when all inputs share a key, but each derived address + // has its own key, so we go per-input. + for (let i = 0; i < plan._chosen.length; i++) { + const signer = this._keys.signerFor(plan._chosen[i].entry); + psbt.signInput(i, signer); + } + const { hex, txid } = finalizeAndExtract(psbt); + const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]); + if (typeof broadcast !== "string" || broadcast.length !== 64) { + throw new Error("broadcast rejected: " + JSON.stringify(broadcast)); + } + this.log("broadcast", txid); + this._scheduleRefresh(1200); + return { txid, hex, fee: plan.fee }; + } + + // BIP-137-style: 65-byte recoverable signature over sha256d(magic || msg). + signMessage(message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "DigiByte Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const entry = this.current(); + const signer = this._keys.signerFor(entry); + // ECPair's `signSchnorr` and `sign` don't emit recoverable sigs; fall + // back to node's ecc.signRecoverable through @bitcoinerlab/secp256k1 + // (which the vendored @dgb-wallet/core already loaded). + const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey); + const out = Buffer.alloc(65); + out[0] = 27 + sig.recoveryId + 4; // +4 = compressed pubkey + Buffer.from(sig.signature).copy(out, 1); + return { address: entry.address, signature: out.toString("base64") }; + } + + recovery() { + return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + + snapshot() { + const cur = this.current(); + return { + chain: "dgb", network: NETWORK, ticker: "DGB", decimals: 8, + address: cur.address, addressIndex: this._state.receiveIndex, + addressPath: cur.path, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._keys.accountPath, + xpub: this._keys.xpub, + explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null, + }; + } + + dispose() { + clearTimeout(this._refreshTimer); + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { DgbWallet, EXPLORER_TX, EXPLORER_ADDR }; +}; diff --git a/lib/chain-eth.js b/lib/chain-eth.js new file mode 100644 index 0000000..a5c9bfd --- /dev/null +++ b/lib/chain-eth.js @@ -0,0 +1,361 @@ +// Ethereum (EVM) chain adapter — mainnet + Sepolia testnet. One address per +// wallet, the same "TronLink shape" as chain-tron.js: BIP44 derivation, +// secp256k1 → keccak256 address, JSON-RPC backend, EIP-1559 send. +// +// Kept intentionally minimal: +// - Native ETH only. ERC-20 token support is a follow-up: it needs a token +// registry + eth_call for `balanceOf(address)` per token + a dedicated +// Send flow that builds an ERC-20 `transfer(to, value)` calldata. +// - No transaction history: without an indexer (Etherscan V2 / Alchemy) +// the JSON-RPC alone can't answer "which txs touched this address". +// The panel shows an empty history with a link to Etherscan. +// - EIP-1559 only (type 0x02). Legacy type 0x00 works too but isn't +// needed for mainnet or Sepolia in 2026. + +const NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", chainId: 1, + // Cloudflare's public Ethereum gateway — no key required, rate-limited + // but adequate for a per-user wallet. User can override in settings. + defaultRpc: "https://cloudflare-eth.com", + explorerTx: "https://etherscan.io/tx/", + explorerAddr: "https://etherscan.io/address/", + faucet: null, + }, + sepolia: { + id: "sepolia", label: "Sepolia testnet", chainId: 11155111, + defaultRpc: "https://ethereum-sepolia-rpc.publicnode.com", + explorerTx: "https://sepolia.etherscan.io/tx/", + explorerAddr: "https://sepolia.etherscan.io/address/", + faucet: "https://sepoliafaucet.com/", + }, +}; + +module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { + if (!HDKey || !secp256k1 || !keccak_256) throw new Error("chain-eth: missing dep"); + + const toHex = (b) => Buffer.from(b).toString("hex"); + const fromHex = (h) => Uint8Array.from(Buffer.from(String(h).replace(/^0x/i, ""), "hex")); + const stripHex = (h) => String(h).replace(/^0x/i, ""); + const hexToBig = (h) => BigInt("0x" + (stripHex(h) || "0")); + const bigToHex = (n) => "0x" + BigInt(n).toString(16); + const zeroBig = 0n; + + // ---- addresses ------------------------------------------------------- + // EIP-55 mixed-case checksum: lowercase hex, then flip case per keccak256 + // of the lowercase hex string (a-f digits get uppercased where the keccak + // nibble is >= 8). Never needed for wire format (RPCs accept lowercase), + // but it's what wallets show, so we return it that way. + function eip55(addressLowerHex) { + const lower = stripHex(addressLowerHex).toLowerCase(); + const hash = toHex(keccak_256(Buffer.from(lower, "utf8"))); + let out = "0x"; + for (let i = 0; i < lower.length; i++) { + const c = lower[i]; + out += /[0-9]/.test(c) ? c : (parseInt(hash[i], 16) >= 8 ? c.toUpperCase() : c); + } + return out; + } + function addressFromPubkey(uncompressed) { + const inner = uncompressed.slice(1); + const h = keccak_256(inner); + const h20 = h.slice(h.length - 20); + return eip55(toHex(h20)); + } + function decodeAddress(str) { + const s = String(str || "").trim(); + const hex = stripHex(s); + if (!/^[0-9a-fA-F]{40}$/.test(hex)) throw new Error("bad Ethereum address"); + // Reject checksum mismatches on mixed-case inputs (all-lower and all-upper + // pass unconditionally — that's the EIP-55 rule). + const lower = hex.toLowerCase(), upper = hex.toUpperCase(); + if (hex !== lower && hex !== upper) { + const want = stripHex(eip55(lower)); + if (hex !== want) throw new Error("EIP-55 checksum failed"); + } + return "0x" + lower; + } + + // ---- RLP encode ------------------------------------------------------ + // Minimal encoder — enough for EIP-1559 tx encoding. Follows the RLP spec + // (single byte < 0x80 → self; short string ≤ 55 → 0x80 + len + bytes; + // long string → 0x80 + 55 + lenOfLen + lenBytes + bytes; lists similarly + // with 0xc0/0xf7). + function rlpEncodeBytes(bytes) { + const b = Uint8Array.from(bytes); + if (b.length === 1 && b[0] < 0x80) return b; + if (b.length <= 55) return concat(Uint8Array.from([0x80 + b.length]), b); + const lenBytes = encodeIntBE(b.length); + return concat(Uint8Array.from([0xb7 + lenBytes.length]), lenBytes, b); + } + function rlpEncodeList(items) { + const encoded = items.map(rlpEncode); + const body = concat(...encoded); + if (body.length <= 55) return concat(Uint8Array.from([0xc0 + body.length]), body); + const lenBytes = encodeIntBE(body.length); + return concat(Uint8Array.from([0xf7 + lenBytes.length]), lenBytes, body); + } + function rlpEncode(item) { + if (item instanceof Uint8Array) return rlpEncodeBytes(item); + if (Array.isArray(item)) return rlpEncodeList(item); + if (typeof item === "bigint") return rlpEncodeBytes(bigToBytes(item)); + if (typeof item === "number") return rlpEncodeBytes(bigToBytes(BigInt(item))); + if (typeof item === "string") return rlpEncodeBytes(item.startsWith("0x") ? fromHex(item) : Buffer.from(item, "utf8")); + throw new Error("rlp: unsupported item type " + typeof item); + } + function bigToBytes(v) { + if (v < 0n) throw new Error("negative bigint"); + if (v === 0n) return new Uint8Array(0); + let hex = v.toString(16); + if (hex.length % 2) hex = "0" + hex; + return fromHex(hex); + } + function encodeIntBE(n) { + let hex = n.toString(16); + if (hex.length % 2) hex = "0" + hex; + return fromHex(hex); + } + function concat(...ps) { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + } + + // ---- signing --------------------------------------------------------- + // EIP-1559 signed tx: 0x02 || RLP([chainId, nonce, maxPriorityFeePerGas, + // maxFeePerGas, gasLimit, to, value, data, accessList, + // yParity, r, s]) + // hash-to-sign: keccak256(0x02 || RLP([...same-without-sig-fields])) + function signTxEip1559(unsignedFields, privKey) { + const unsignedRlp = rlpEncodeList(unsignedFields); + const preimage = concat(Uint8Array.from([0x02]), unsignedRlp); + const hash = keccak_256(preimage); + const sig = secp256k1.sign(hash, privKey, { prehash: false, lowS: true, format: "recovered" }); + // noble returns [recid || r(32) || s(32)]; EIP-1559 uses yParity as + // 0 or 1 (recid directly, no +27 shift). + const yParity = sig[0]; + const r = sig.subarray(1, 33); + const s = sig.subarray(33, 65); + const signedFields = [...unsignedFields, yParity, stripLeadingZeros(r), stripLeadingZeros(s)]; + const signedRlp = rlpEncodeList(signedFields); + return "0x" + toHex(concat(Uint8Array.from([0x02]), signedRlp)); + } + function stripLeadingZeros(bytes) { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + return bytes.subarray(i); + } + + // ---- JSON-RPC client ------------------------------------------------- + function makeClient(rpcUrl) { + let seq = 1; + async function call(method, params = []) { + const r = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: seq++, method, params }), + }); + if (!r.ok) throw new Error(`${method}: HTTP ${r.status}`); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); + return j.result; + } + return { url: rpcUrl, call }; + } + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // ---- wallet ---------------------------------------------------------- + class EthWallet { + constructor(root32, networkId, { + walletId, storage, log = () => {}, onChange = () => {}, rpcUrl, + customNetwork, // { id, label, chainId, defaultRpc, explorerTx, explorerAddr, ticker } for EIP-3085 chains + } = {}) { + if (!walletId) throw new Error("chain-eth: walletId required"); + const net = customNetwork || NETWORKS[networkId]; + if (!net) throw new Error(`chain-eth: unknown network ${networkId}`); + this.walletId = walletId; + this.chain = "eth"; + this.network = net.id; + this._net = net; + this._ticker = net.ticker || "ETH"; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + const master = HDKey.fromMasterSeed(root32); + // BIP44 for Ethereum: m/44'/60'/0'/0/0 is the canonical first address. + const node = master.derive("m/44'/60'/0'/0/0"); + this._priv = node.privateKey; + this._pubUncompressed = secp256k1.getPublicKey(this._priv, false); + this.address = addressFromPubkey(this._pubUncompressed); + this._root = new Uint8Array(root32); + this._client = makeClient(String(rpcUrl || "").trim() || net.defaultRpc); + this._state = { + balance: { confirmed: "0", unconfirmed: "0" }, + history: [], + height: 0, + scanning: false, + error: null, + }; + this._pollTimer = null; + } + + setRpcUrl(url) { + const v = String(url || "").trim() || this._net.defaultRpc; + this._client = makeClient(v); + this._emit(); + } + _emit() { try { this.onChange(); } catch {} } + + // Wei is 10^18 native units; the panel formats via decimals=18. The + // ticker follows the chain's nativeCurrency (ETH on mainnet/Sepolia, + // MATIC on Polygon, etc.) so the send-approval overlay reads correctly. + snapshot() { + return { + chain: "eth", network: this._net.id, ticker: this._ticker, decimals: 18, + address: this.address, addressIndex: 0, + addressPath: "m/44'/60'/0'/0/0", + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url, + rpcUrl: this._client.url, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + chainId: this._net.chainId, + }; + } + + async refresh() { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + const [bal, block] = await Promise.all([ + this._client.call("eth_getBalance", [this.address, "latest"]), + this._client.call("eth_blockNumber", []), + ]); + this._state.balance = { confirmed: hexToBig(bal).toString(), unconfirmed: "0" }; + this._state.height = Number(hexToBig(block)); + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); + } + + async plan({ to, amount, sendMax }) { + const dest = decodeAddress(to); + const from = this.address.toLowerCase(); + const [nonceHex, priorityHex, gasPriceHex, gasLimitHex] = await Promise.all([ + this._client.call("eth_getTransactionCount", [from, "pending"]), + this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei + this._client.call("eth_gasPrice", []), + Promise.resolve("0x5208"), // 21000 for a plain ETH transfer + ]); + const nonce = Number(hexToBig(nonceHex)); + const maxPriorityFeePerGas = hexToBig(priorityHex); + // maxFeePerGas heuristic: 2 * base fee + priority tip. base fee ~= + // gasPrice - priority tip on EIP-1559 chains; we approximate with the + // reported gasPrice as an upper bound plus the priority. + const baseGuess = hexToBig(gasPriceHex); + const maxFeePerGas = baseGuess * 2n + maxPriorityFeePerGas; + const gasLimit = hexToBig(gasLimitHex); + const fee = gasLimit * maxFeePerGas; + const bal = BigInt(this._state.balance.confirmed || "0"); + let value; + if (sendMax) { + if (bal <= fee) throw new Error("balance does not cover the gas fee"); + value = bal - fee; + } else { + value = BigInt(Math.round(Number(amount) || 0)); // wei + if (value <= 0n) throw new Error("amount must be > 0 wei"); + if (value + fee > bal) throw new Error("insufficient funds"); + } + return { + _draft: { + chainId: this._net.chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, + gasLimit, to: dest, value, data: "0x", accessList: [], + }, + recipients: [{ to: dest, value: value.toString() }], + fee: fee.toString(), + feeRate: maxFeePerGas.toString(), + inputs: [], + change: "0", + total: (value + fee).toString(), + }; + } + + async signAndBroadcast(plan) { + const d = plan && plan._draft; + if (!d) throw new Error("bad plan"); + const unsignedFields = [ + d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, + fromHex(d.to.slice(2)), d.value, fromHex(""), [], + ]; + const rawTxHex = signTxEip1559(unsignedFields, this._priv); + const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]); + if (typeof txid !== "string" || !/^0x[0-9a-f]{64}$/i.test(txid)) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("broadcast", txid); + setTimeout(() => this.refresh(), 3000); + return { txid }; + } + + // EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid). + signTypedDataDigest(digest32) { + const sig = secp256k1.sign(digest32, this._priv, { prehash: false, lowS: true, format: "recovered" }); + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; + return { address: this.address, signature: "0x" + toHex(out) }; + } + // Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg). + signMessage(message) { + const msg = String(message); + const enc = new TextEncoder(); + const body = enc.encode(msg); + const prefix = enc.encode("\x19Ethereum Signed Message:\n" + body.length); + const buf = concat(prefix, body); + const hash = keccak_256(buf); + const sig = secp256k1.sign(hash, this._priv, { prehash: false, lowS: true, format: "recovered" }); + // personal_sign format: r || s || v where v = 27 + recid. + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; + return { address: this.address, signature: "0x" + toHex(out) }; + } + + recovery() { + // Ethereum wallets typically expose the raw private key hex; we do too, + // but only when the caller re-confirms in the approval overlay upstream. + return { + accountPath: "m/44'/60'/0'/0/0", + xpub: "0x" + toHex(this._pubUncompressed), + xprv: "0x" + toHex(this._priv), + }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { EthWallet, NETWORKS, addressFromPubkey, decodeAddress, eip55 }; +}; diff --git a/lib/chain-sia.js b/lib/chain-sia.js new file mode 100644 index 0000000..dac88ea --- /dev/null +++ b/lib/chain-sia.js @@ -0,0 +1,182 @@ +// Sia (SC) chain adapter — v2 walletd-backed. Constructs a wallet from a +// 32-byte root and a user-supplied walletd URL. Amounts are hastings +// (1 SC = 10^24 hastings) and cross the panel boundary as decimal strings +// so the panel never has to touch BigInt precision. +// +// Deps: sia.js / keys.js / wallet.js / walletd.js — copied from the +// standalone siawallet addon on 2026-09-07 when Aegis absorbed it, so the +// key derivation + tx layout are byte-identical to the older addon. +// Existing on-chain funds carry over via the vault-derive absorb (aegis's +// manifest lists "siawallet" under absorbs, so the legacy purpose +// "siawallet/mainnet/0" resolves to the same seed inside Aegis). + +const SC = 10n ** 24n; +const EXPLORER_TX = "https://siascan.com/tx/"; +const EXPLORER_ADDR = "https://siascan.com/address/"; + +module.exports = function makeSiaAdapter({ ed25519, blake2b }) { + if (!ed25519 || !blake2b) throw new Error("chain-sia: missing dep"); + const sia = require("./sia/sia.js")({ ed25519, blake2b }); + const keysLib = require("./sia/keys.js")({ sia }); + const walletd = require("./sia/walletd.js")({ log: () => {} }); + const walletFactory = require("./sia/wallet.js"); + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + class SiaWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, + walletdUrl = "", + } = {}) { + if (!walletId) throw new Error("chain-sia: walletId required"); + this.walletId = walletId; + this.chain = "sc"; + this.network = "mainnet"; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._root = new Uint8Array(root32); + this._keys = new keysLib.WalletKeys(root32); + this._walletdUrl = String(walletdUrl || "").trim(); + this._client = null; + this._wallet = null; + if (this._walletdUrl) this._build(); + } + + _build() { + if (this._wallet) { try { this._wallet.dispose(); } catch {} this._wallet = null; } + if (this._client) this._client.setBase(this._walletdUrl); + else this._client = new walletd.Client(this._walletdUrl); + this._wallet = walletFactory({ + client: this._client, keys: this._keys, sia, + storage: this.storage, + log: (...a) => this.log(...a), + onChange: () => { try { this.onChange(); } catch {} }, + }); + } + + setWalletdUrl(url) { + const v = String(url || "").trim(); + if (v === this._walletdUrl) return; + this._walletdUrl = v; + if (v) this._build(); else { try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; } + } + + // The panel treats Sia amounts as decimal strings of hastings; the + // display layer picks how many SC-precision digits to show. + snapshot() { + const w = this._wallet && this._wallet.snapshot(); + const base = { + chain: "sc", network: "mainnet", ticker: "SC", decimals: 24, + address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || 0})`, + balance: { confirmed: "0", unconfirmed: "0" }, + height: 0, history: [], scanning: false, error: null, + server: this._client ? this._client.displayUrl : null, + walletdUrl: this._walletdUrl, + needsWalletdUrl: !this._walletdUrl, + explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null, + }; + if (w) { + base.address = w.address; + base.addressIndex = w.addressIndex; + // wallet.js exposes confirmed / immature; the panel's shared shape + // is confirmed / unconfirmed, so map immature → unconfirmed for + // visual parity with BCH and TRX (small semantic bend, but the + // number is the "not yet spendable" one either way). + base.balance = { confirmed: w.balance.confirmed, unconfirmed: w.balance.immature }; + base.height = w.height; + base.history = (w.history || []).map((r) => ({ + txid: r.id, delta: r.delta, to: r.to, from: null, + fee: null, time: r.time || 0, + confirmations: r.confirmations || 0, + status: r.confirmations > 0 ? "confirmed" : "pending", + kind: r.type, + })); + base.scanning = w.scanning; + base.error = w.error; + } + return base; + } + + async refresh(full) { + if (!this._wallet) return; + return this._wallet.refresh(!!full); + } + nextAddress() { + if (!this._wallet) throw new Error("no walletd URL configured"); + return this._wallet.nextUnusedAddress(); + } + current() { + if (!this._wallet) throw new Error("no walletd URL configured"); + return this._wallet.current(); + } + plan(spec) { + if (!this._wallet) throw new Error("no walletd URL configured"); + const targets = Array.isArray(spec.outputs) && spec.outputs.length + ? spec.outputs.map((o) => ({ to: o.to, value: toHastings(o.amount ?? o.value) })) + : [{ to: spec.to, value: toHastings(spec.amount ?? spec.value) }]; + const p = this._wallet.plan({ targets, feeMultiplier: spec.feeMultiplier || spec.feeRate, sendMax: !!spec.sendMax }); + // Present the plan in the common panel shape: BigInts as decimal + // strings, plus `total = sum(recipients) + fee`. + const sent = p.recipients.reduce((a, r) => a + BigInt(r.value), 0n); + return { + _sia: p, // internal handle so signAndBroadcast doesn't re-plan + recipients: p.recipients, + fee: p.fee.toString(), + feeRate: p.feePerByte.toString(), + inputs: p.tx.inputs, + change: p.change.toString(), + total: (sent + p.fee).toString(), + }; + } + async signAndBroadcast(plan) { + if (!this._wallet) throw new Error("no walletd URL configured"); + const inner = plan && plan._sia; + if (!inner) throw new Error("bad plan"); + return this._wallet.signAndBroadcast(inner); + } + // Sia signature = ed25519 over blake2b256 of the raw message. Not a + // BIP-137 style thing — dapps that want it should treat this as an + // opaque {publicKey, signature} pair verified via ed25519. + signMessage(message) { + const entry = this.current(); + const digest = sia.b256(new TextEncoder().encode(String(message))); + const sig = this._keys.sign(entry, digest); + return { + address: entry.address, + publicKey: "ed25519:" + sia.toHex(entry.pub), + signature: sia.toHex(sig), + }; + } + recovery() { + // Sia has no xpub/xprv notion here; the scheme is "seed + index". + return { + accountPath: `KeyFromSeed(seed, i)`, + xpub: sia.toHex(this._keys.entry(0).pub), // just the first pub for reference + xprv: this._keys.seedHex, + }; + } + startPolling() { if (this._wallet) this._wallet.startPolling(60_000); } + dispose() { + try { this._wallet && this._wallet.dispose(); } catch {} + try { this._keys && this._keys.wipe(); } catch {} + if (this._root) this._root.fill(0); + } + } + + // Amounts arrive as strings (hastings) or as small numbers. + function toHastings(v) { + if (typeof v === "bigint") return v; + const s = String(v ?? "0").trim(); + if (!/^\d+$/.test(s)) throw new Error("amount must be an integer number of hastings"); + return BigInt(s); + } + + return { SiaWallet, EXPLORER_TX, EXPLORER_ADDR }; +}; diff --git a/lib/chain-sol.js b/lib/chain-sol.js new file mode 100644 index 0000000..8d47829 --- /dev/null +++ b/lib/chain-sol.js @@ -0,0 +1,473 @@ +// Solana (SOL) chain adapter — mainnet-beta + devnet. Ed25519 keypair per +// SLIP-0010 (all-hardened path), base58 address, native SOL transfers via +// the system program. Sits directly on the JSON-RPC endpoint; no @solana +// SDK dep so the addon stays lean. +// +// Derivation: m/44'/501'/0'/0' — Phantom's default path. Every segment is +// hardened per SLIP-0010 (ed25519 forbids non-hardened derivation because +// the point-add trick that BIP32 uses on secp256k1 doesn't exist for +// Curve25519). Multi-index accounts under one wallet aren't exposed here; +// each Aegis "sub-account" gets its own vault-derive purpose instead. +// +// Not implemented in this rev: +// - SPL token balances / transfers (needs Associated Token Account math +// and the SPL Token program's transfer instruction). +// - Transaction history (getSignaturesForAddress + getTransaction is +// doable but heavy for a first cut; the panel links to Solana Explorer +// for now). + +const NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", + defaultRpc: "https://api.mainnet-beta.solana.com", + explorerTx: "https://explorer.solana.com/tx/", + explorerAddr: "https://explorer.solana.com/address/", + explorerCluster: "", + faucet: null, + }, + devnet: { + id: "devnet", label: "Devnet", + defaultRpc: "https://api.devnet.solana.com", + explorerTx: "https://explorer.solana.com/tx/", + explorerAddr: "https://explorer.solana.com/address/", + explorerCluster: "?cluster=devnet", + faucet: "https://faucet.solana.com/", + }, +}; + +// System program's address is 32 bytes of zeros; base58 is "1111...1111". +const SYSTEM_PROGRAM = new Uint8Array(32); + +module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) { + if (!ed25519 || !base58 || !sha256) throw new Error("chain-sol: missing dep"); + const spl = require("./sol-spl.js")({ ed25519, sha256, base58 }); + + // ---- HMAC-SHA512 (for SLIP-0010) ------------------------------------- + // Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes + // exports `hmac` in ./hmac. If unavailable, use Node's crypto — Electron + // main is Node, so require("crypto") always works. + const nodeCrypto = require("node:crypto"); + function hmacSha512(key, msg) { + return new Uint8Array(nodeCrypto.createHmac("sha512", Buffer.from(key)).update(Buffer.from(msg)).digest()); + } + + // ---- SLIP-0010 ed25519 derivation ------------------------------------ + const ED25519_MASTER_KEY = new TextEncoder().encode("ed25519 seed"); + function slip10Master(seed32) { + const I = hmacSha512(ED25519_MASTER_KEY, seed32); + return { key: I.slice(0, 32), chainCode: I.slice(32) }; + } + function slip10Derive(parent, indexHardened) { + // Data: 0x00 || parent.key || uint32BE(0x80000000 | index) + const idx = 0x80000000 | (indexHardened & 0x7fffffff); + const data = new Uint8Array(1 + 32 + 4); + data[0] = 0x00; + data.set(parent.key, 1); + // Write index as big-endian u32; JS bitwise is signed so |0 masks correctly. + data[33] = (idx >>> 24) & 0xff; + data[34] = (idx >>> 16) & 0xff; + data[35] = (idx >>> 8) & 0xff; + data[36] = idx & 0xff; + const I = hmacSha512(parent.chainCode, data); + return { key: I.slice(0, 32), chainCode: I.slice(32) }; + } + function derivePath(seed32, segments) { + let node = slip10Master(seed32); + for (const s of segments) node = slip10Derive(node, s); + return node; + } + // "m/44'/501'/0'/0'" → [44, 501, 0, 0]. Every SLIP-0010 ed25519 segment + // is hardened; the parser accepts either the standard "'" suffix or a + // bare integer (both mean the same for this curve). + function parseAllHardened(path) { + const parts = String(path || "").trim().split("/").filter((p) => p && p !== "m"); + return parts.map((p) => { + const m = /^(\d+)'?$/.exec(p); + if (!m) throw new Error("bad SOL derivation path: " + path); + return Number(m[1]); + }); + } + + // ---- Solana short-vec (compact-u16) ---------------------------------- + // Up to 3 bytes; 7 data bits per byte with continuation bit in position 7. + function encodeCompactU16(n) { + if (n < 0 || n > 0xffff) throw new Error("compact-u16 out of range"); + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; + rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; + out.push(byte); + } + return Uint8Array.from(out); + } + + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const o = new Uint8Array(n); let k = 0; + for (const p of ps) { o.set(p, k); k += p.length; } + return o; + }; + const u64le = (n) => { + let v = BigInt(n); const o = new Uint8Array(8); + for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; } + return o; + }; + + // ---- transaction assembly -------------------------------------------- + // For a native SOL transfer between two addresses: + // accounts (writable-signed | readonly-signed | writable-unsigned | readonly-unsigned): + // [ from (WS), to (WU), systemProgram (RU) ] + // header = [1 required-sig, 0 readonly-signed, 1 readonly-unsigned] + // instructions = [{ programIdIndex: 2, accounts: [0, 1], data: u32(2) || u64(lamports) }] + function buildSolTransferMessage({ fromPub, toPub, lamports, recentBlockhash }) { + // account keys must be de-duplicated in the message; distinct here. + const keys = [fromPub, toPub, SYSTEM_PROGRAM]; + const header = Uint8Array.from([1, 0, 1]); + const keysSection = concat( + encodeCompactU16(keys.length), + ...keys.map((k) => Uint8Array.from(k)), + ); + // Instruction data: [2 (u32 LE = system Transfer discriminator), lamports (u64 LE)] + const instrData = concat(Uint8Array.from([2, 0, 0, 0]), u64le(lamports)); + const instr = concat( + Uint8Array.from([2]), // programIdIndex + encodeCompactU16(2), // account key count + Uint8Array.from([0, 1]), // account indexes (from, to) + encodeCompactU16(instrData.length), // data length + instrData, + ); + const instrSection = concat(encodeCompactU16(1), instr); + return concat(header, keysSection, recentBlockhash, instrSection); + } + + // ---- JSON-RPC -------------------------------------------------------- + function makeClient(rpcUrl) { + let seq = 1; + async function call(method, params = []) { + const r = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: seq++, method, params }), + }); + if (!r.ok) throw new Error(`${method}: HTTP ${r.status}`); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); + return j.result; + } + return { url: rpcUrl, call }; + } + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // ---- wallet ---------------------------------------------------------- + class SolWallet { + constructor(root32, networkId, { + walletId, storage, log = () => {}, onChange = () => {}, rpcUrl, + derivationPath = "m/44'/501'/0'/0'", + } = {}) { + if (!walletId) throw new Error("chain-sol: walletId required"); + const net = NETWORKS[networkId]; + if (!net) throw new Error(`chain-sol: unknown network ${networkId}`); + this.walletId = walletId; + this.chain = "sol"; + this.network = net.id; + this._net = net; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._path = derivationPath; + const derived = derivePath(root32, parseAllHardened(derivationPath)); + this._priv = derived.key; // 32-byte ed25519 seed + this._pub = ed25519.getPublicKey(this._priv); // 32 bytes + this.address = base58.encode(this._pub); + this._root = new Uint8Array(root32); + this._client = makeClient(String(rpcUrl || "").trim() || net.defaultRpc); + this._state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + tokens: [], // [{mint, symbol, name, decimals, balance, tokenAccount, tokenProgram}] + height: 0, + scanning: false, + error: null, + }; + this._pollTimer = null; + } + + setRpcUrl(url) { + const v = String(url || "").trim() || this._net.defaultRpc; + this._client = makeClient(v); + this._emit(); + } + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + return { + chain: "sol", network: this._net.id, ticker: "SOL", decimals: 9, + address: this.address, addressIndex: 0, addressPath: this._path, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + tokens: this._state.tokens, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url, + rpcUrl: this._client.url, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + explorerSuffix: this._net.explorerCluster, + faucet: this._net.faucet, + }; + } + + async refresh() { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + const [balRes, slot, tokens] = await Promise.all([ + this._client.call("getBalance", [this.address]), + this._client.call("getSlot", []), + this._fetchTokens().catch((e) => { this.log("tokens fetch failed:", e?.message); return []; }), + ]); + // getBalance response: { context, value: lamports } + const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0); + this._state.balance = { confirmed: lamports, unconfirmed: 0 }; + this._state.height = Number(slot || 0); + this._state.tokens = tokens; + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + // getTokenAccountsByOwner + parse. Result shape: + // {context, value: [{ pubkey, account: {data: {parsed: {info:{mint, tokenAmount:{amount,decimals,uiAmountString}}}, program, space}, executable, ...} }]} + // We ask for jsonParsed encoding so the RPC does the layout heavy-lift. + async _fetchTokens() { + const known = spl.KNOWN_TOKENS[this._net.id] || {}; + const call = (programB58) => this._client.call("getTokenAccountsByOwner", [ + this.address, + { programId: programB58 }, + { encoding: "jsonParsed", commitment: "confirmed" }, + ]); + const results = await Promise.all([ + call(spl.TOKEN_PROGRAM_ID_B58).catch(() => ({ value: [] })), + call(spl.TOKEN_2022_PROGRAM_ID_B58).catch(() => ({ value: [] })), + ]); + const out = []; + for (let p = 0; p < results.length; p++) { + const list = results[p]?.value || []; + const isTk22 = p === 1; + for (const it of list) { + const info = it?.account?.data?.parsed?.info; + if (!info) continue; + const mint = String(info.mint || ""); + const dec = Number(info.tokenAmount?.decimals || 0); + const rawAmount = String(info.tokenAmount?.amount || "0"); + const meta = known[mint]; + out.push({ + mint, tokenAccount: String(it.pubkey || ""), + tokenProgram: isTk22 ? spl.TOKEN_2022_PROGRAM_ID_B58 : spl.TOKEN_PROGRAM_ID_B58, + symbol: meta?.symbol || mint.slice(0, 6) + "…", + name: meta?.name || null, + decimals: dec, + balance: rawAmount, // string (u64) to preserve precision + isKnown: !!meta, + isToken2022: isTk22, + }); + } + } + // Sort known tokens first, then by balance desc. + out.sort((a, b) => (b.isKnown - a.isKnown) || (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); + return out; + } + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); + } + + // Solana fees are (usually) 5000 lamports per signature; reserve that + // in max-mode. Real fee comes from the network on broadcast. + async plan({ to, amount, sendMax }) { + const toBytes = base58.decode(String(to || "").trim()); + if (!toBytes || toBytes.length !== 32) throw new Error("bad Solana address"); + const bal = this._state.balance.confirmed || 0; + const FEE = 5000; // lamports per signature + let lamports; + if (sendMax) { + if (bal <= FEE) throw new Error("balance does not cover the fee"); + lamports = bal - FEE; + } else { + lamports = Math.round(Number(amount) || 0); + if (!(lamports > 0)) throw new Error("amount must be > 0 lamports"); + if (lamports + FEE > bal) throw new Error("insufficient funds"); + } + // Fetch the fresh blockhash at plan time so signing can use it + // immediately — Solana blockhashes expire quickly (~150 slots ≈ 60s). + const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {}; + if (!blockhash) throw new Error("could not fetch a recent blockhash"); + const recent = base58.decode(String(blockhash)); + return { + _draft: { toBytes, lamports, recentBlockhash: recent }, + recipients: [{ to: base58.encode(toBytes), value: lamports }], + fee: FEE, feeRate: FEE, + inputs: [], change: 0, + total: lamports + FEE, + }; + } + + async signAndBroadcast(plan) { + const d = plan && plan._draft; + if (!d) throw new Error("bad plan"); + const message = buildSolTransferMessage({ + fromPub: this._pub, toPub: d.toBytes, lamports: d.lamports, + recentBlockhash: d.recentBlockhash, + }); + const sig = ed25519.sign(message, this._priv); // 64 bytes + // Full transaction wire format: sig-count || sigs... || message + const sigCount = encodeCompactU16(1); + const wire = concat(sigCount, sig, message); + // Solana's sendTransaction accepts base58 (default) or base64 with + // {encoding:"base64"} in the second arg; we use base58 for parity + // with the rest of the ecosystem. + const wireBase58 = base58.encode(wire); + const txid = await this._client.call("sendTransaction", [wireBase58]); + if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("broadcast", txid); + setTimeout(() => this.refresh(), 4000); + return { txid }; + } + + // ---- SPL token send ----------------------------------------------- + // Build a TransferChecked (+ optional CreateAssociatedTokenAccountIdempotent) + // transaction moving `amount` (in raw token units) of a given mint to a + // recipient's ATA. `mintB58` is the mint address as a base58 string; + // decimals come from the sender's own token account (or the caller + // passes them explicitly if the sender's ATA is empty). + async planTokenTransfer({ mint, to, amount, decimals, tokenProgram }) { + const mintB58 = String(mint || ""); + const mintBytes = base58.decode(mintB58); + if (mintBytes.length !== 32) throw new Error("bad mint address"); + const recipientBytes = base58.decode(String(to || "").trim()); + if (recipientBytes.length !== 32) throw new Error("bad recipient address"); + // Resolve the token program for this mint from our own token list — + // Token-2022 mints need the 2022 program in the transfer instruction. + let tp = tokenProgram ? base58.decode(tokenProgram) : spl.TOKEN_PROGRAM_ID; + let dec = decimals; + const mine = (this._state.tokens || []).find((t) => t.mint === mintB58); + if (mine) { + tp = base58.decode(mine.tokenProgram); + if (dec == null) dec = mine.decimals; + } + if (dec == null) throw new Error("token decimals unknown — no ATA on this wallet for that mint"); + const amt = BigInt(amount); + if (amt <= 0n) throw new Error("amount must be > 0"); + if (mine && BigInt(mine.balance) < amt) throw new Error("insufficient token balance"); + const sourceATA = spl.associatedTokenAddress(this._pub, mintBytes, tp); + const destATA = spl.associatedTokenAddress(recipientBytes, mintBytes, tp); + // Ask the RPC whether the destination ATA already exists. If not, + // prepend a CreateIdempotent instruction so the transfer succeeds + // in one round-trip — the recipient never has to have interacted + // with this mint before. + const destATA_B58 = base58.encode(destATA); + const info = await this._client.call("getAccountInfo", [destATA_B58, { encoding: "base64" }]); + const destExists = !!(info && info.value); + const instructions = []; + if (!destExists) { + instructions.push(spl.createATAIdempotentInstruction({ + payer: this._pub, ata: destATA, owner: recipientBytes, mint: mintBytes, tokenProgram: tp, + })); + } + instructions.push(spl.transferCheckedInstruction({ + sourceATA, mint: mintBytes, destATA, owner: this._pub, + amount: amt, decimals: dec, tokenProgram: tp, + })); + const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {}; + if (!blockhash) throw new Error("could not fetch a recent blockhash"); + const recentBlockhash = base58.decode(String(blockhash)); + return { + _spl: { instructions, recentBlockhash, destExists, sourceATA, destATA }, + recipients: [{ to: base58.encode(recipientBytes), value: amt.toString() }], + // Fee estimate: 5000 lamports per signature + ~2039280 rent-exempt + // if we're creating a new ATA. Real fee still comes from the network. + fee: destExists ? 5000 : 5000 + 2039280, + feeRate: 5000, + inputs: [], + change: 0, + total: amt.toString(), + mint: mintB58, + decimals: dec, + }; + } + async signAndBroadcastToken(plan) { + const sp = plan && plan._spl; + if (!sp) throw new Error("bad token plan"); + const message = spl.buildMessage({ + feePayer: this._pub, + instructions: sp.instructions, + recentBlockhash: sp.recentBlockhash, + }); + const sig = ed25519.sign(message, this._priv); + const encodeCompactU16 = (n) => { + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; out.push(byte); + } + return Uint8Array.from(out); + }; + const sigCount = encodeCompactU16(1); + const wire = new Uint8Array(sigCount.length + 64 + message.length); + wire.set(sigCount, 0); + wire.set(sig, sigCount.length); + wire.set(message, sigCount.length + 64); + const wireB58 = base58.encode(wire); + const txid = await this._client.call("sendTransaction", [wireB58]); + if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("SPL broadcast", txid); + setTimeout(() => this.refresh().catch(() => {}), 4000); + return { txid }; + } + + // Solana's convention: ed25519 signature over the raw message bytes, + // returned as {publicKey, signature} both base58. Dapps that follow + // the wallet-adapter standard verify against these. + signMessage(message) { + const bytes = new TextEncoder().encode(String(message)); + const sig = ed25519.sign(bytes, this._priv); + return { + address: this.address, + publicKey: base58.encode(this._pub), + signature: base58.encode(sig), + }; + } + + recovery() { + return { + accountPath: this._path, + xpub: base58.encode(this._pub), + xprv: Buffer.from(this._priv).toString("hex"), + }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { SolWallet, NETWORKS }; +}; diff --git a/lib/chain-tron.js b/lib/chain-tron.js new file mode 100644 index 0000000..eebbf7b --- /dev/null +++ b/lib/chain-tron.js @@ -0,0 +1,353 @@ +// Tron chain adapter (mainnet + Nile testnet). One address per wallet, in the +// TronLink style: seed for this wallet → BIP32 → m/44'/195'/0'/0/0 → secp256k1 +// private key → uncompressed pubkey (drop 0x04) → keccak256 last 20 bytes → +// prepend 0x41 → base58check → "T..." address. +// +// Balance and history come from TronGrid (read; no key required). Send is +// build-locally-signed-locally-broadcast-remotely: +// POST /wallet/createtransaction {owner_address, to_address, amount, visible:true} +// → { raw_data, raw_data_hex, txID, ... } +// sig = secp256k1.sign(sha256(raw_data_hex), privateKey, {format:"recovered"}) +// → 65 bytes [r||s||recid] +// POST /wallet/broadcasttransaction {raw_data, raw_data_hex, txID, signature:[hexSig]} +// → { result: true/false, txid/message } +// +// Nile and mainnet share the address format (both prefix 0x41). The RPC host +// differs. Coin type 195 is used for both — the derivation-path *string* is +// identical; the difference between mainnet and Nile is which sub-wallet the +// user picked, which vault-derive purpose seeded it, and which RPC we hit. +// Two addresses; a mainnet wallet's key cannot accidentally sign for Nile +// (different key material entirely; the check is enforced at the seed layer). + +const NETWORKS = { + mainnet: { + id: "mainnet", + label: "Tron", + ticker: "TRX", + rpcBase: "https://api.trongrid.io", + explorerTx: "https://tronscan.org/#/transaction/", + explorerAddr: "https://tronscan.org/#/address/", + faucet: null, + }, + nile: { + id: "nile", + label: "Tron Nile testnet", + ticker: "TRX", + rpcBase: "https://nile.trongrid.io", + explorerTx: "https://nile.tronscan.org/#/transaction/", + explorerAddr: "https://nile.tronscan.org/#/address/", + faucet: "https://nileex.io/join/getJoinPage", + }, +}; + +module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256, base58check }) { + if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) { + throw new Error("chain-tron: missing dep"); + } + const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const hexToBytes = (h) => { + const s = String(h).replace(/^0x/i, ""); + if (s.length % 2) throw new Error("hex: odd length"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + + // Derive the raw key material for a single Tron account. + // root32: 32-byte HKDF child from api.vault.derive. + // returns: { privateKey (Uint8Array 32), publicKey (Uint8Array 65 uncompressed), + // addressBytes (Uint8Array 21, prefix 0x41 || h20), address (base58) } + function deriveAccount(root32) { + const master = HDKey.fromMasterSeed(root32); + // Tron follows Ethereum-style non-hardened branch/index: m/44'/195'/0'/0/0. + const node = master.derive("m/44'/195'/0'/0/0"); + const priv = node.privateKey; + // Uncompressed pub is 65 bytes with a 0x04 prefix; drop it to feed keccak256. + const pubUncompressed = secp256k1.getPublicKey(priv, false); + const inner = pubUncompressed.slice(1); // 64 bytes + const kh = keccak_256(inner); // 32 bytes + const h20 = kh.slice(kh.length - 20); + const addrBytes = new Uint8Array(21); + addrBytes[0] = 0x41; + addrBytes.set(h20, 1); + const address = base58check.encodeCheck(addrBytes); + return { privateKey: priv, publicKey: pubUncompressed, addressBytes: addrBytes, address, node }; + } + + // "T..." → 21-byte payload (0x41 || h20). Throws on bad checksum / prefix. + function decodeAddress(str) { + const payload = base58check.decodeCheck(String(str).trim()); + if (payload.length !== 21) throw new Error("bad address length"); + if (payload[0] !== 0x41) throw new Error("bad address prefix (want 0x41 / T…)"); + return payload; + } + function hexToAddress(hex) { + const b = hexToBytes(hex); + if (b.length !== 21 || b[0] !== 0x41) throw new Error("bad address hex"); + return base58check.encodeCheck(b); + } + + function makeClient(networkId) { + const net = NETWORKS[networkId]; + if (!net) throw new Error(`unknown Tron network ${networkId}`); + async function rpc(pathPart, body) { + const url = net.rpcBase + pathPart; + let r; + try { r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); } + catch (e) { throw new Error(`network: ${e?.message || e}`); } + if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`); + return r.json(); + } + async function get(pathPart) { + const url = net.rpcBase + pathPart; + const r = await fetch(url); + if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`); + return r.json(); + } + return { net, rpc, get }; + } + + class TronWallet { + constructor(root32, networkId, { storage, log = () => {}, onChange = () => {} } = {}) { + this.storage = storage; + this.log = log; + this.onChange = onChange; + this.client = makeClient(networkId); + this.net = this.client.net; + const acc = deriveAccount(root32); + // Root is stored so future revs can rotate accounts; the derived priv + // is what actually signs. Both wiped in dispose(). + this._root = new Uint8Array(root32); + this._priv = acc.privateKey; + this.publicKey = acc.publicKey; + this.addressBytes = acc.addressBytes; + this.address = acc.address; + this.state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + height: 0, + scanning: false, + error: null, + server: this.net.rpcBase, + }; + this._pollTimer = null; + } + + // Public snapshot (no key material). + snapshot() { + return { + chain: "trx", + network: this.net.id, + ticker: this.net.ticker, + address: this.address, + addressIndex: 0, + addressPath: "m/44'/195'/0'/0/0", + balance: this.state.balance, + height: this.state.height, + history: this.state.history, + scanning: this.state.scanning, + error: this.state.error, + server: this.state.server, + explorerTx: this.net.explorerTx, + explorerAddr: this.net.explorerAddr, + faucet: this.net.faucet, + decimals: 6, + }; + } + + async refresh(_full = false) { + if (this.state.scanning) return; + this.state.scanning = true; this.state.error = null; this.onChange(); + try { + const [acc, txs] = await Promise.all([this._fetchAccount(), this._fetchHistory()]); + // TronGrid returns balance in "sun" (1 TRX = 1_000_000 sun). + const sun = Number(acc?.balance || 0); + this.state.balance = { confirmed: sun, unconfirmed: 0 }; + this.state.history = txs; + // Block height comes from any recent tx or a getnowblock call. + try { + const nb = await this.client.rpc("/wallet/getnowblock", {}); + this.state.height = Number(nb?.block_header?.raw_data?.number || 0); + } catch {} + } catch (e) { + this.state.error = e?.message || String(e); + this.log("refresh failed:", this.state.error); + } finally { + this.state.scanning = false; + this.onChange(); + } + } + + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh(false).finally(() => this.schedulePoll(ms)), ms); + } + + async _fetchAccount() { + const body = { address: this.address, visible: true }; + // /wallet/getaccount returns {} for never-funded addresses. + return this.client.rpc("/wallet/getaccount", body); + } + + async _fetchHistory() { + const url = `/v1/accounts/${encodeURIComponent(this.address)}/transactions?limit=25`; + let raw; + try { raw = await this.client.get(url); } catch (e) { this.log("history failed:", e?.message || e); return []; } + const list = Array.isArray(raw?.data) ? raw.data : []; + const now = Math.floor(Date.now() / 1000); + return list.map((t) => this._describeTx(t, now)).filter(Boolean); + } + + _describeTx(t, nowSec) { + const contract = t?.raw_data?.contract?.[0]; + const type = contract?.type; + const value = contract?.parameter?.value || {}; + const txID = t.txID || t.txid; + const time = Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000); + const conf = t.ret && Array.isArray(t.ret) ? (t.ret[0]?.contractRet === "SUCCESS" ? 1 : -1) : 0; + if (type === "TransferContract") { + // owner_address / to_address come as hex here (prefixed 41), regardless + // of visible:true (that flag only affects some endpoints). + let ownerB58 = "", toB58 = ""; + try { ownerB58 = value.owner_address ? hexToAddress(value.owner_address) : ""; } catch {} + try { toB58 = value.to_address ? hexToAddress(value.to_address) : ""; } catch {} + const amount = Number(value.amount || 0); + const inc = toB58 === this.address; + return { + txid: txID, + delta: inc ? amount : -amount, + to: inc ? null : toB58, + from: inc ? ownerB58 : null, + fee: t.net_fee || t.energy_fee || null, + time: time || 0, + confirmations: conf > 0 ? 1 : (conf < 0 ? 0 : 0), + status: conf > 0 ? "confirmed" : (conf < 0 ? "failed" : "pending"), + kind: "transfer", + }; + } + // Non-transfer contracts (delegation, votes, TRC20) — surface as a + // neutral entry so users see something happened without exaggerating. + return { + txid: txID, + delta: 0, + to: null, from: null, fee: null, + time: time || 0, + confirmations: conf > 0 ? 1 : 0, + status: conf > 0 ? "confirmed" : "other", + kind: type || "contract", + }; + } + + // Preview a send: no side effects, no signature. Returns the fields the + // panel needs plus the raw createtransaction result cached under _draft + // so signAndBroadcast doesn't re-fetch. + async plan({ to, amount, sendMax }) { + if (!to) throw new Error("recipient required"); + const dest = decodeAddress(to); + const balance = this.state.balance.confirmed; + // Tron doesn't have a fee-market for plain TRX transfers between accounts + // that carry enough bandwidth. For untouched addresses the network + // consumes a fixed 100_000 sun (0.1 TRX) burn from the sender if no + // free bandwidth is available. We show that as an upper-bound estimate. + const FEE_EST = 100_000; + let value; + if (sendMax) { + value = Math.max(0, balance - FEE_EST); + } else { + value = Math.round(Number(amount) || 0); + } + if (!(value > 0)) throw new Error("amount must be > 0 sun"); + if (value + FEE_EST > balance) throw new Error("insufficient funds"); + const body = { owner_address: this.address, to_address: base58check.encodeCheck(dest), amount: value, visible: true }; + const draft = await this.client.rpc("/wallet/createtransaction", body); + if (draft?.Error || !draft?.raw_data_hex) { + throw new Error("createtransaction: " + (draft?.Error || "empty response")); + } + const plan = { + recipients: [{ to: base58check.encodeCheck(dest), value }], + fee: FEE_EST, + feeRate: 1, + total: value + FEE_EST, + inputs: [], + _draft: draft, + }; + return plan; + } + + // Sign the raw_data_hex bytes with the wallet key and POST the signed tx. + async signAndBroadcast(plan) { + const draft = plan && plan._draft; + if (!draft || !draft.raw_data_hex) throw new Error("bad plan"); + const rawBytes = hexToBytes(draft.raw_data_hex); + const digest = sha256(rawBytes); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + // Recovered format from noble is 65 bytes: [recid || r(32) || s(32)]. Tron + // wants [r(32) || s(32) || recid]. Reorder in place. + const trxSig = new Uint8Array(65); + trxSig.set(sig.subarray(1), 0); + trxSig[64] = sig[0]; + const body = { + raw_data: draft.raw_data, + raw_data_hex: draft.raw_data_hex, + txID: draft.txID, + visible: true, + signature: [bytesToHex(trxSig)], + }; + const r = await this.client.rpc("/wallet/broadcasttransaction", body); + if (r?.result !== true) { + const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected"); + throw new Error("broadcast: " + msg); + } + // Refresh soon so history/balance catch up. + setTimeout(() => this.refresh(false), 2500); + return { txid: draft.txID }; + } + + // BIP137-style signing isn't standard on Tron; dapps use signMessageV2 + // (tronWeb.trx.signMessageV2), which is a raw ECDSA over sha256 of the + // message bytes with a Tron prefix "\x19TRON Signed Message:\n32". Kept + // simple here: signMessageV2 always asks the user. + signMessageV2(message) { + const enc = new TextEncoder(); + const bodyBytes = enc.encode(String(message)); + const prefix = enc.encode("\x19TRON Signed Message:\n" + bodyBytes.length); + const buf = new Uint8Array(prefix.length + bodyBytes.length); + buf.set(prefix, 0); buf.set(bodyBytes, prefix.length); + const digest = keccak_256(buf); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; // Ethereum-style v = 27 + recid + return { address: this.address, signature: "0x" + bytesToHex(out) }; + } + + // Sign an arbitrary raw_data_hex the dapp built with its own tronWeb. + // The caller must have already presented an approval overlay. + signRawData(rawDataHex) { + const rawBytes = hexToBytes(rawDataHex); + const digest = sha256(rawBytes); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + const trxSig = new Uint8Array(65); + trxSig.set(sig.subarray(1), 0); + trxSig[64] = sig[0]; + return bytesToHex(trxSig); + } + + async broadcastSignedTx(signedTx) { + const r = await this.client.rpc("/wallet/broadcasttransaction", signedTx); + if (r?.result !== true) { + const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected"); + throw new Error("broadcast: " + msg); + } + return { txid: signedTx.txID }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { TronWallet, deriveAccount, decodeAddress, hexToAddress, NETWORKS }; +}; diff --git a/lib/dgb/core/address.d.ts b/lib/dgb/core/address.d.ts new file mode 100644 index 0000000..fd2b706 --- /dev/null +++ b/lib/dgb/core/address.d.ts @@ -0,0 +1,10 @@ +import type { BIP32Interface } from 'bip32'; +import { type Network } from './network.js'; +export declare function p2pkhAddress(node: BIP32Interface, network?: Network): string; +export declare function p2shP2wpkhAddress(node: BIP32Interface, network?: Network): string; +export declare function p2shP2wpkhAddressesBoth(node: BIP32Interface): { + modern: string; + legacy: string; +}; +export declare function p2wpkhAddress(node: BIP32Interface, network?: Network): string; +export declare function p2trAddress(node: BIP32Interface, network?: Network): string; diff --git a/lib/dgb/core/address.js b/lib/dgb/core/address.js new file mode 100644 index 0000000..f41fad5 --- /dev/null +++ b/lib/dgb/core/address.js @@ -0,0 +1,53 @@ +import { payments, initEccLib } from 'bitcoinjs-lib'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { digibyte, digibyteLegacyP2SH } from './network.js'; +// Required for Taproot address derivation (P2TR). +initEccLib(ecc); +export function p2pkhAddress(node, network = digibyte) { + const { address } = payments.p2pkh({ + pubkey: Buffer.from(node.publicKey), + network, + }); + if (!address) + throw new Error('p2pkh derivation returned no address'); + return address; +} +export function p2shP2wpkhAddress(node, network = digibyte) { + const redeem = payments.p2wpkh({ + pubkey: Buffer.from(node.publicKey), + network, + }); + const { address } = payments.p2sh({ redeem, network }); + if (!address) + throw new Error('p2sh-p2wpkh derivation returned no address'); + return address; +} +// Derive the "S..." (current DGB, scriptHash 0x3f) AND "3..." (legacy +// Bitcoin-compatible, scriptHash 0x05) BIP49 addresses for the same +// key. Ian Coleman's BIP39 tool and several older wallets generate the +// legacy "3..." variant, so any seed-recovery scan must check both. +export function p2shP2wpkhAddressesBoth(node) { + return { + modern: p2shP2wpkhAddress(node, digibyte), + legacy: p2shP2wpkhAddress(node, digibyteLegacyP2SH), + }; +} +export function p2wpkhAddress(node, network = digibyte) { + const { address } = payments.p2wpkh({ + pubkey: Buffer.from(node.publicKey), + network, + }); + if (!address) + throw new Error('p2wpkh derivation returned no address'); + return address; +} +// BIP86 Taproot address using the x-only pubkey with no script tree, +// which applies the standard BIP86 tweak internally in bitcoinjs-lib. +export function p2trAddress(node, network = digibyte) { + const internalPubkey = Buffer.from(node.publicKey.subarray(1, 33)); + const { address } = payments.p2tr({ internalPubkey, network }); + if (!address) + throw new Error('p2tr derivation returned no address'); + return address; +} +//# sourceMappingURL=address.js.map \ No newline at end of file diff --git a/lib/dgb/core/descriptor.d.ts b/lib/dgb/core/descriptor.d.ts new file mode 100644 index 0000000..b5de22f --- /dev/null +++ b/lib/dgb/core/descriptor.d.ts @@ -0,0 +1,5 @@ +import type { BIP32Interface } from 'bip32'; +import type { Purpose } from './hd.js'; +export declare function derivationPath(purpose: Purpose, account: number, change: 0 | 1, index: number): string; +export declare function accountXpub(accountNode: BIP32Interface): string; +export declare function accountXprv(accountNode: BIP32Interface): string; diff --git a/lib/dgb/core/descriptor.js b/lib/dgb/core/descriptor.js new file mode 100644 index 0000000..e904254 --- /dev/null +++ b/lib/dgb/core/descriptor.js @@ -0,0 +1,18 @@ +import { DGB_COIN_TYPE } from './network.js'; +// Convenience helper: full derivation-path string for a specific +// address, e.g. m/84'/20'/0'/0/5. +export function derivationPath(purpose, account, change, index) { + return `m/${purpose}'/${DGB_COIN_TYPE}'/${account}'/${change}/${index}`; +} +// xpub for an account, ready to be shared with a watch-only or +// external indexer. Never share the corresponding xprv. +export function accountXpub(accountNode) { + return accountNode.neutered().toBase58(); +} +export function accountXprv(accountNode) { + if (!accountNode.privateKey) { + throw new Error('Node has no private key; cannot export xprv'); + } + return accountNode.toBase58(); +} +//# sourceMappingURL=descriptor.js.map \ No newline at end of file diff --git a/lib/dgb/core/hd.d.ts b/lib/dgb/core/hd.d.ts new file mode 100644 index 0000000..6c32856 --- /dev/null +++ b/lib/dgb/core/hd.d.ts @@ -0,0 +1,8 @@ +import { type BIP32Interface } from 'bip32'; +import { type Network } from './network.js'; +export type Purpose = 44 | 49 | 84 | 86; +export declare const PURPOSE_LABEL: Record; +export declare function rootFromSeed(seed: Buffer, network?: Network): BIP32Interface; +export declare function accountNode(root: BIP32Interface, purpose: Purpose, account?: number): BIP32Interface; +export declare function addressNode(account: BIP32Interface, change: 0 | 1, index: number): BIP32Interface; +export type { BIP32Interface }; diff --git a/lib/dgb/core/hd.js b/lib/dgb/core/hd.js new file mode 100644 index 0000000..d0b6f19 --- /dev/null +++ b/lib/dgb/core/hd.js @@ -0,0 +1,24 @@ +import { BIP32Factory } from 'bip32'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { digibyte, DGB_COIN_TYPE } from './network.js'; +const bip32 = BIP32Factory(ecc); +export const PURPOSE_LABEL = { + 44: 'BIP44 legacy P2PKH', + 49: 'BIP49 P2SH-wrapped SegWit', + 84: 'BIP84 native SegWit v0', + 86: 'BIP86 Taproot (SegWit v1)', +}; +export function rootFromSeed(seed, network = digibyte) { + return bip32.fromSeed(seed, network); +} +// Standard account-level derivation: m/purpose'/coin'/account'. +// account defaults to 0 (the first account). +export function accountNode(root, purpose, account = 0) { + return root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/${account}'`); +} +// Address-level derivation from an account node. +// change = 0 for external (receive) addresses, 1 for internal (change). +export function addressNode(account, change, index) { + return account.derive(change).derive(index); +} +//# sourceMappingURL=hd.js.map \ No newline at end of file diff --git a/lib/dgb/core/index.d.ts b/lib/dgb/core/index.d.ts new file mode 100644 index 0000000..14d0835 --- /dev/null +++ b/lib/dgb/core/index.d.ts @@ -0,0 +1,6 @@ +export * from './network.js'; +export * from './seed.js'; +export * from './hd.js'; +export * from './address.js'; +export * from './wif.js'; +export * from './descriptor.js'; diff --git a/lib/dgb/core/index.js b/lib/dgb/core/index.js new file mode 100644 index 0000000..b9669ee --- /dev/null +++ b/lib/dgb/core/index.js @@ -0,0 +1,7 @@ +export * from './network.js'; +export * from './seed.js'; +export * from './hd.js'; +export * from './address.js'; +export * from './wif.js'; +export * from './descriptor.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/dgb/core/network.d.ts b/lib/dgb/core/network.d.ts new file mode 100644 index 0000000..10e1007 --- /dev/null +++ b/lib/dgb/core/network.d.ts @@ -0,0 +1,13 @@ +import type { networks } from 'bitcoinjs-lib'; +export type Network = (typeof networks)['bitcoin']; +export declare const digibyte: Network; +export declare const digibyteLegacyP2SH: Network; +export declare const digibyteLegacyWIF: Network; +export declare const digibyteTestnet: Network; +export declare const DGB_COIN_TYPE = 20; +export declare const DGB_P2P: { + readonly magic: 3669410810; + readonly defaultPort: 12024; + readonly rpcPort: 14022; + readonly dnsSeeds: readonly ["seed.digibyte.io", "seed.diginode.tools", "seed.digibyte.link", "seed.aroundtheblock.app", "seed.tuyul.cc"]; +}; diff --git a/lib/dgb/core/network.js b/lib/dgb/core/network.js new file mode 100644 index 0000000..b874df8 --- /dev/null +++ b/lib/dgb/core/network.js @@ -0,0 +1,74 @@ +// DigiByte mainnet parameters. Sourced from +// github.com/DigiByte-Core/digibyte src/kernel/chainparams.cpp CMainParams. +// +// Note on scriptHash: DGB Core defines BOTH SCRIPT_ADDRESS = 0x3f ("S..." +// addresses, the current default) and SCRIPT_ADDRESS2 = 0x05 ("3..." +// addresses, kept for Bitcoin compatibility). Older BIP39 tools and +// wallets that forked bitcoinjs-lib params generate 3-addresses; current +// DGB Core generates S-addresses. `digibyte` below uses the current +// default; use `digibyteLegacyP2SH` when recovering from Ian Coleman's +// BIP39 tool, older Atomic/Exodus vintages, or anything else that +// inherited Bitcoin's 0x05 P2SH byte. Both are valid on-chain. +export const digibyte = { + messagePrefix: '\x19DigiByte Signed Message:\n', + bech32: 'dgb', + bip32: { + public: 0x0488b21e, + private: 0x0488ade4, + }, + pubKeyHash: 0x1e, + scriptHash: 0x3f, + wif: 0x80, +}; +// Same as `digibyte`, but with the legacy Bitcoin-compatible P2SH +// version byte. Use for producing/scanning "3..."-style P2SH addresses +// during seed recovery from tools that predate the 0x3f switch. +// DGB Core keeps SCRIPT_ADDRESS_OLD = 5 for backward compatibility. +export const digibyteLegacyP2SH = { + ...digibyte, + scriptHash: 0x05, +}; +// Same as `digibyte`, but with the legacy WIF version byte. +// DGB Core keeps SECRET_KEY_OLD = 158 (0x9e) for backward compatibility. +// Older DGB tools may have exported private keys with this prefix. +// The wallet's WIF-import path should try both `digibyte` and this +// variant before rejecting a key. +export const digibyteLegacyWIF = { + ...digibyte, + wif: 0x9e, +}; +// DigiByte testnet parameters. From CTestNetParams in chainparams.cpp. +export const digibyteTestnet = { + messagePrefix: '\x19DigiByte Signed Message:\n', + bech32: 'dgbt', + bip32: { + public: 0x043587cf, + private: 0x04358394, + }, + pubKeyHash: 0x7e, + scriptHash: 0x8c, + wif: 0xef, +}; +// SLIP-0044 registered coin type. +export const DGB_COIN_TYPE = 20; +// P2P network constants (unused by @dgb-wallet/core itself, exposed for +// the P2P client package that will consume them). +export const DGB_P2P = { + // pchMessageStart in DGB Core chainparams.cpp is the byte sequence + // 0xFA 0xC3 0xB6 0xDA on the wire. `writeUInt32LE(magic)` writes + // least-significant-byte first, so the integer value stored here must + // be the LE-reading: 0xDAB6C3FA (byte 0 = 0xFA, byte 3 = 0xDA). + // Getting this wrong means peers see wrong-network frames and close + // immediately at handshake. + magic: 0xdab6c3fa, + defaultPort: 12024, + rpcPort: 14022, + dnsSeeds: [ + 'seed.digibyte.io', + 'seed.diginode.tools', + 'seed.digibyte.link', + 'seed.aroundtheblock.app', + 'seed.tuyul.cc', + ], +}; +//# sourceMappingURL=network.js.map \ No newline at end of file diff --git a/lib/dgb/core/package.json b/lib/dgb/core/package.json new file mode 100644 index 0000000..ddb9a1b --- /dev/null +++ b/lib/dgb/core/package.json @@ -0,0 +1 @@ +{"type":"module","main":"./index.js"} diff --git a/lib/dgb/core/seed.d.ts b/lib/dgb/core/seed.d.ts new file mode 100644 index 0000000..94c97e3 --- /dev/null +++ b/lib/dgb/core/seed.d.ts @@ -0,0 +1,4 @@ +export type SeedStrength = 128 | 160 | 192 | 224 | 256; +export declare function generateSeedPhrase(strength?: SeedStrength): string; +export declare function validateSeedPhrase(phrase: string, wordlist?: string[]): boolean; +export declare function seedFromPhrase(phrase: string, passphrase?: string): Promise; diff --git a/lib/dgb/core/seed.js b/lib/dgb/core/seed.js new file mode 100644 index 0000000..49f8a26 --- /dev/null +++ b/lib/dgb/core/seed.js @@ -0,0 +1,17 @@ +import { generateMnemonic, validateMnemonic, mnemonicToSeed, wordlists, } from 'bip39'; +// Word count → entropy strength for BIP39. +// 12 → 128, 15 → 160, 18 → 192, 21 → 224, 24 → 256. +export function generateSeedPhrase(strength = 128) { + return generateMnemonic(strength); +} +// BIP39 checksum + wordlist validation. Returns false for typos, bad +// word counts, and out-of-wordlist words. +export function validateSeedPhrase(phrase, wordlist = wordlists.english) { + return validateMnemonic(phrase.trim(), wordlist); +} +// BIP39 seed derivation. Passphrase is the optional "25th word"; +// changing it produces a different wallet from the same mnemonic. +export async function seedFromPhrase(phrase, passphrase = '') { + return mnemonicToSeed(phrase.trim(), passphrase); +} +//# sourceMappingURL=seed.js.map \ No newline at end of file diff --git a/lib/dgb/core/wif.d.ts b/lib/dgb/core/wif.d.ts new file mode 100644 index 0000000..2d28c17 --- /dev/null +++ b/lib/dgb/core/wif.d.ts @@ -0,0 +1,27 @@ +import { type ECPairInterface } from 'ecpair'; +import { type Network } from './network.js'; +export type WifImportResult = { + keyPair: ECPairInterface; + variant: 'modern' | 'legacy'; +}; +export declare function importWif(wif: string): WifImportResult; +export declare function importWifStrict(wif: string, network?: Network): ECPairInterface; +export declare function exportWif(keyPair: ECPairInterface): string; +export interface PubkeyAddresses { + p2pkh: string; + p2shP2wpkhModern: string; + p2shP2wpkhLegacy: string; + p2wpkh: string; + p2tr: string; + scripts: { + p2pkh: string; + p2shP2wpkh: string; + p2wpkh: string; + p2tr: string; + }; +} +export declare function addressesForPubkey(pubkey: Buffer, network?: Network): PubkeyAddresses; +export interface WifImportWithAddresses extends WifImportResult, PubkeyAddresses { +} +export declare function importWifWithAddresses(wif: string): WifImportWithAddresses; +export type { ECPairInterface }; diff --git a/lib/dgb/core/wif.js b/lib/dgb/core/wif.js new file mode 100644 index 0000000..159b093 --- /dev/null +++ b/lib/dgb/core/wif.js @@ -0,0 +1,72 @@ +import { ECPairFactory } from 'ecpair'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { payments, initEccLib } from 'bitcoinjs-lib'; +import { digibyte, digibyteLegacyP2SH, digibyteLegacyWIF } from './network.js'; +const ECPair = ECPairFactory(ecc); +initEccLib(ecc); +// Import a DigiByte private key in WIF (Wallet Import Format). +// Tries the modern 0x80 prefix first, falls back to the legacy 0x9e +// prefix that older DGB tools produced. Rejects anything else with a +// clear message that identifies the network mismatch. +export function importWif(wif) { + const trimmed = wif.trim(); + try { + return { keyPair: ECPair.fromWIF(trimmed, digibyte), variant: 'modern' }; + } + catch { + // fall through to legacy attempt + } + try { + return { keyPair: ECPair.fromWIF(trimmed, digibyteLegacyWIF), variant: 'legacy' }; + } + catch (e) { + throw new Error(`Not a valid DigiByte WIF (tried both current 0x80 and legacy 0x9e prefixes). ` + + `Underlying error: ${e.message}. Check the key is for DGB mainnet, not testnet or another chain.`); + } +} +// Import against a specific network only (advanced / testing). +export function importWifStrict(wif, network = digibyte) { + return ECPair.fromWIF(wif.trim(), network); +} +export function exportWif(keyPair) { + return keyPair.toWIF(); +} +export function addressesForPubkey(pubkey, network = digibyte) { + const p2pkh = payments.p2pkh({ pubkey, network }); + const wpkhRedeem = payments.p2wpkh({ pubkey, network }); + // bitcoinjs-lib enforces `redeem.network === outerNetwork` (identity + // comparison, not shape). To render the legacy 3-prefix P2SH address + // we need a fresh redeem whose .network property is the legacy variant + // — same bytes on the wire, different object identity. + const wpkhRedeemLegacy = payments.p2wpkh({ pubkey, network: digibyteLegacyP2SH }); + const p2shModern = payments.p2sh({ redeem: wpkhRedeem, network }); + const p2shLegacy = payments.p2sh({ redeem: wpkhRedeemLegacy, network: digibyteLegacyP2SH }); + const p2wpkh = payments.p2wpkh({ pubkey, network }); + const p2tr = payments.p2tr({ internalPubkey: pubkey.subarray(1, 33), network }); + if (!p2pkh.address || !p2shModern.address || !p2shLegacy.address || !p2wpkh.address || !p2tr.address) { + throw new Error('bitcoinjs-lib returned an empty address for one of the payment types'); + } + if (!p2pkh.output || !p2shModern.output || !p2wpkh.output || !p2tr.output) { + throw new Error('bitcoinjs-lib returned an empty scriptPubKey for one of the payment types'); + } + return { + p2pkh: p2pkh.address, + p2shP2wpkhModern: p2shModern.address, + p2shP2wpkhLegacy: p2shLegacy.address, + p2wpkh: p2wpkh.address, + p2tr: p2tr.address, + scripts: { + p2pkh: Buffer.from(p2pkh.output).toString('hex'), + p2shP2wpkh: Buffer.from(p2shModern.output).toString('hex'), + p2wpkh: Buffer.from(p2wpkh.output).toString('hex'), + p2tr: Buffer.from(p2tr.output).toString('hex'), + }, + }; +} +export function importWifWithAddresses(wif) { + const imported = importWif(wif); + const pubkey = Buffer.from(imported.keyPair.publicKey); + const addrs = addressesForPubkey(pubkey); + return { ...imported, ...addrs }; +} +//# sourceMappingURL=wif.js.map \ No newline at end of file diff --git a/lib/dgb/psbt/build.d.ts b/lib/dgb/psbt/build.d.ts new file mode 100644 index 0000000..d962850 --- /dev/null +++ b/lib/dgb/psbt/build.d.ts @@ -0,0 +1,4 @@ +import { Psbt } from 'bitcoinjs-lib'; +import { type Network } from '@dgb-wallet/core'; +import type { BuildParams } from './types.js'; +export declare function buildPsbt(params: BuildParams, network?: Network): Psbt; diff --git a/lib/dgb/psbt/build.js b/lib/dgb/psbt/build.js new file mode 100644 index 0000000..1fe9142 --- /dev/null +++ b/lib/dgb/psbt/build.js @@ -0,0 +1,56 @@ +import { Psbt } from 'bitcoinjs-lib'; +import { digibyte } from '../core/index.js'; +// Construct an unsigned PSBT from a set of UTXOs and destination outputs. +// Does not add a change output — the caller decides change amount and +// address. Does not compute fees — the caller must have already subtracted +// fee from outputs. +export function buildPsbt(params, network = digibyte) { + const psbt = new Psbt({ network }); + const inputs = params.sortBip69 === false ? params.inputs : sortInputs(params.inputs); + const outputs = params.sortBip69 === false ? params.outputs : sortOutputs(params.outputs); + for (const u of inputs) { + psbt.addInput(inputToPsbtInput(u)); + } + for (const o of outputs) { + psbt.addOutput({ address: o.address, value: o.value }); + } + return psbt; +} +function inputToPsbtInput(u) { + const input = { + hash: u.txid, + index: u.vout, + }; + if (u.witness) { + input.witnessUtxo = { + script: Buffer.from(u.witness.scriptHex, 'hex'), + value: u.witness.value, + }; + } + if (u.nonWitnessTxHex) { + input.nonWitnessUtxo = Buffer.from(u.nonWitnessTxHex, 'hex'); + } + if (u.redeemScriptHex) { + input.redeemScript = Buffer.from(u.redeemScriptHex, 'hex'); + } + if (u.tapInternalKeyHex) { + input.tapInternalKey = Buffer.from(u.tapInternalKeyHex, 'hex'); + } + return input; +} +// BIP69 lexicographic ordering. Improves privacy by not revealing input +// selection order (which can hint at wallet coin-selection strategy). +function sortInputs(inputs) { + return [...inputs].sort((a, b) => { + const cmp = a.txid.localeCompare(b.txid); + return cmp !== 0 ? cmp : a.vout - b.vout; + }); +} +function sortOutputs(outputs) { + return [...outputs].sort((a, b) => { + if (a.value !== b.value) + return a.value - b.value; + return a.address.localeCompare(b.address); + }); +} +//# sourceMappingURL=build.js.map \ No newline at end of file diff --git a/lib/dgb/psbt/index.d.ts b/lib/dgb/psbt/index.d.ts new file mode 100644 index 0000000..e33aca9 --- /dev/null +++ b/lib/dgb/psbt/index.d.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './build.js'; +export * from './sign.js'; diff --git a/lib/dgb/psbt/index.js b/lib/dgb/psbt/index.js new file mode 100644 index 0000000..26f6293 --- /dev/null +++ b/lib/dgb/psbt/index.js @@ -0,0 +1,4 @@ +export * from './types.js'; +export * from './build.js'; +export * from './sign.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/dgb/psbt/package.json b/lib/dgb/psbt/package.json new file mode 100644 index 0000000..ddb9a1b --- /dev/null +++ b/lib/dgb/psbt/package.json @@ -0,0 +1 @@ +{"type":"module","main":"./index.js"} diff --git a/lib/dgb/psbt/sign.d.ts b/lib/dgb/psbt/sign.d.ts new file mode 100644 index 0000000..3df7450 --- /dev/null +++ b/lib/dgb/psbt/sign.d.ts @@ -0,0 +1,9 @@ +import type { Psbt } from 'bitcoinjs-lib'; +import type { ECPairInterface } from 'ecpair'; +export declare function signAllInputs(psbt: Psbt, keyPair: ECPairInterface): Psbt; +export declare function finalizeAndExtract(psbt: Psbt): { + hex: string; + txid: string; +}; +export declare function feeSats(psbt: Psbt): number; +export declare function feeRateSatsPerByte(psbt: Psbt): number; diff --git a/lib/dgb/psbt/sign.js b/lib/dgb/psbt/sign.js new file mode 100644 index 0000000..94a5a2f --- /dev/null +++ b/lib/dgb/psbt/sign.js @@ -0,0 +1,25 @@ +// Sign every input of a PSBT with the given key. Fails loudly if any +// input can't be signed by this key (rather than silently leaving it +// unsigned), so callers notice before broadcasting a half-signed tx. +export function signAllInputs(psbt, keyPair) { + psbt.signAllInputs(keyPair); + return psbt; +} +// After all inputs are signed by all necessary parties, finalize and +// extract the network-ready hex-encoded transaction. +export function finalizeAndExtract(psbt) { + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + return { hex: tx.toHex(), txid: tx.getId() }; +} +// Fee computed from the difference between total input value and total +// output value. Requires all inputs to have witnessUtxo or +// nonWitnessUtxo populated (which `buildPsbt` in this package +// guarantees when the caller populates Utxo.value fields). +export function feeSats(psbt) { + return psbt.getFee(); +} +export function feeRateSatsPerByte(psbt) { + return psbt.getFeeRate(); +} +//# sourceMappingURL=sign.js.map \ No newline at end of file diff --git a/lib/dgb/psbt/types.d.ts b/lib/dgb/psbt/types.d.ts new file mode 100644 index 0000000..33f3542 --- /dev/null +++ b/lib/dgb/psbt/types.d.ts @@ -0,0 +1,23 @@ +export interface Utxo { + txid: string; + vout: number; + value: number; + address: string; + scriptPubKey: string; + witness?: { + scriptHex: string; + value: number; + }; + nonWitnessTxHex?: string; + redeemScriptHex?: string; + tapInternalKeyHex?: string; +} +export interface Output { + address: string; + value: number; +} +export interface BuildParams { + inputs: Utxo[]; + outputs: Output[]; + sortBip69?: boolean; +} diff --git a/lib/dgb/psbt/types.js b/lib/dgb/psbt/types.js new file mode 100644 index 0000000..718fd38 --- /dev/null +++ b/lib/dgb/psbt/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/lib/eip712.js b/lib/eip712.js new file mode 100644 index 0000000..a926679 --- /dev/null +++ b/lib/eip712.js @@ -0,0 +1,143 @@ +// EIP-712 typed-data hashing (personal_sign's structured cousin). Produces +// the 32-byte digest that eth_signTypedData_v4 signs with the wallet's +// secp256k1 key. +// +// Reference: https://eips.ethereum.org/EIPS/eip-712 +// Digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct(primaryType, message)) +// - domainSeparator = hashStruct("EIP712Domain", typedData.domain) +// - hashStruct(type, data) = keccak256(typeHash(type) || encodeData(type, data)) +// - typeHash(type) = keccak256(encodeType(type)) +// - encodeType is the canonical string form; sub-types are appended in +// alphabetical order once, without recursion into themselves twice. +// +// This is enough for every mainstream EIP-712 payload — Permit / EIP-2612, +// OpenSea order signatures, WalletConnect handshakes, Snapshot votes. Not +// implemented: fixed-size arrays of atomic types wider than a byte (rare +// enough that no shipping dapp we care about uses them). + +module.exports = function makeEip712({ keccak_256 }) { + const enc = new TextEncoder(); + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + }; + const hex2bytes = (h) => { + const s = String(h).replace(/^0x/i, ""); + if (s.length % 2) throw new Error("hex: odd length"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + const bytesToBig = (b) => { + let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; + }; + const bigToBe32 = (v, signed) => { + let n = BigInt(v); + if (n < 0n) { + if (!signed) throw new Error("negative value for unsigned type"); + // two's complement to 256 bits + n = (1n << 256n) + n; + } + const out = new Uint8Array(32); + for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; } + return out; + }; + + // encodeType walker — resolves the primary type + every struct it + // transitively references, then emits "Primary(...)Sub1(...)Sub2(...)" + // with sub-types in alphabetical order per the spec. + function findDependencies(primaryType, types, found = new Set()) { + if (found.has(primaryType) || !types[primaryType]) return found; + found.add(primaryType); + for (const f of types[primaryType]) { + const base = f.type.replace(/\[.*\]$/, ""); + if (types[base]) findDependencies(base, types, found); + } + return found; + } + function encodeType(primaryType, types) { + const deps = [...findDependencies(primaryType, types)].filter((t) => t !== primaryType).sort(); + const all = [primaryType, ...deps]; + return all.map((t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`).join(""); + } + function typeHash(primaryType, types) { + return keccak_256(enc.encode(encodeType(primaryType, types))); + } + + // Encode one field value per its declared type. Struct + array types + // hash themselves to 32 bytes; atomics land in a 32-byte slot each. + function encodeValue(type, value, types) { + // Array types: `Type[]` (dynamic) or `Type[N]` (fixed) — both encode + // as keccak256(concat(encodeValue(baseType, element)...)) per EIP-712. + const arr = /^(.+)\[(\d*)\]$/.exec(type); + if (arr) { + const baseType = arr[1]; + const items = Array.isArray(value) ? value : []; + const encoded = items.map((v) => encodeValue(baseType, v, types)); + return keccak_256(concat(...encoded)); + } + // Struct types: hashStruct recursion. + if (types[type]) return hashStruct(type, value, types); + // Atomic types. + if (type === "string") return keccak_256(enc.encode(String(value ?? ""))); + if (type === "bytes") { + const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); + return keccak_256(b); + } + if (type === "address") { + const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); + if (h.length !== 20) throw new Error("address must be 20 bytes"); + const out = new Uint8Array(32); + out.set(h, 12); + return out; + } + if (type === "bool") { + const out = new Uint8Array(32); + out[31] = value ? 1 : 0; + return out; + } + // bytesN (fixed): left-aligned in a 32-byte word. + const bytesN = /^bytes(\d+)$/.exec(type); + if (bytesN) { + const n = Number(bytesN[1]); + if (n < 1 || n > 32) throw new Error("bytesN out of range"); + const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); + if (b.length !== n) throw new Error(`${type} expects ${n} bytes, got ${b.length}`); + const out = new Uint8Array(32); + out.set(b, 0); + return out; + } + // uint* / int*: encode as 32-byte big-endian. + const uintM = /^uint(\d*)$/.exec(type); + if (uintM) return bigToBe32(value, false); + const intM = /^int(\d*)$/.exec(type); + if (intM) return bigToBe32(value, true); + throw new Error("unsupported EIP-712 type: " + type); + } + + function encodeData(primaryType, data, types) { + const fields = types[primaryType]; + if (!fields) throw new Error("unknown type: " + primaryType); + const encoded = fields.map((f) => encodeValue(f.type, data ? data[f.name] : undefined, types)); + return concat(...encoded); + } + function hashStruct(primaryType, data, types) { + return keccak_256(concat(typeHash(primaryType, types), encodeData(primaryType, data, types))); + } + + // Full EIP-712 digest, ready for secp256k1.sign(digest, key). + function digest(typedData) { + const td = typedData && typeof typedData === "object" ? typedData : {}; + const types = td.types || {}; + if (!types.EIP712Domain) throw new Error("typedData.types.EIP712Domain missing"); + const primary = String(td.primaryType || ""); + if (!primary || !types[primary]) throw new Error(`typedData.primaryType "${primary}" not in types`); + const domainSeparator = hashStruct("EIP712Domain", td.domain || {}, types); + const messageHash = hashStruct(primary, td.message || {}, types); + return keccak_256(concat(Uint8Array.from([0x19, 0x01]), domainSeparator, messageHash)); + } + + return { digest, encodeType, typeHash, hashStruct }; +}; diff --git a/lib/electrum.js b/lib/electrum.js new file mode 100644 index 0000000..88f0859 --- /dev/null +++ b/lib/electrum.js @@ -0,0 +1,131 @@ +// Electrum (Fulcrum) JSON-RPC over WebSocket for the wallet. One live +// connection at a time, chosen by walking the server list in order; the +// caller gets a stable `call()` that reconnects transparently on the next +// request after a drop. Notifications (headers / scripthash subscriptions) +// fan out to `onNotify`. +module.exports = function makeElectrum({ WebSocket, log = () => {} }) { + const CALL_TIMEOUT_MS = 20000; + + class Connection { + constructor(url) { + this.url = url; + this.id = 0; + this.pending = new Map(); + this.buf = ""; + this.closed = false; + this.onNotify = null; + this.onClose = null; + } + connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.url); + this.ws = ws; + const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } }; + ws.on("open", async () => { + try { await this.call("server.version", ["theseus-bchwallet", "1.4"]); resolve(this); } + catch (e) { fail(e); this.close(); } + }); + ws.on("error", fail); + ws.on("message", (d) => this._onData(String(d))); + ws.on("close", () => { + this.closed = true; + for (const p of this.pending.values()) p.reject(new Error("electrum connection closed")); + this.pending.clear(); + if (this.onClose) this.onClose(); + }); + }); + } + _onData(chunk) { + this.buf += chunk; + let nl; + while ((nl = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (line) this._handleLine(line); + } + const rest = this.buf.trim(); + if (rest) { try { JSON.parse(rest); this._handleLine(rest); this.buf = ""; } catch {} } + } + _handleLine(line) { + let msg; + try { msg = JSON.parse(line); } catch { return; } + if (msg.id != null && this.pending.has(msg.id)) { + const p = this.pending.get(msg.id); + this.pending.delete(msg.id); + clearTimeout(p.timer); + if (msg.error) p.reject(new Error(typeof msg.error === "object" ? (msg.error.message || JSON.stringify(msg.error)) : String(msg.error))); + else p.resolve(msg.result); + } else if (msg.method && this.onNotify) { + this.onNotify(msg.method, msg.params || []); + } + } + call(method, params = []) { + if (this.closed) return Promise.reject(new Error("electrum connection closed")); + const id = ++this.id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); } + }, CALL_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timer }); + try { this.ws.send(JSON.stringify({ id, method, params }) + "\n"); } + catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); } + }); + } + close() { this.closed = true; try { this.ws.close(); } catch {} } + } + + class Client { + constructor(servers) { + this.servers = servers.slice(); + this.conn = null; + this.connecting = null; + this.subscriptions = new Map(); // method+key -> params (replayed on reconnect) + this.onNotify = null; + this.onServer = null; // (url|null) connection state for the UI + } + setServers(servers) { + this.servers = servers.slice(); + this.disconnect(); + } + get url() { return this.conn && !this.conn.closed ? this.conn.url : null; } + async _ensure() { + if (this.conn && !this.conn.closed) return this.conn; + if (this.connecting) return this.connecting; + this.connecting = (async () => { + let lastErr; + for (const url of this.servers) { + try { + const c = await new Connection(url).connect(); + c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); }; + c.onClose = () => { if (this.conn === c) { this.conn = null; if (this.onServer) this.onServer(null); } }; + this.conn = c; + log("connected", url); + if (this.onServer) this.onServer(url); + // Re-arm subscriptions so a reconnect keeps the live feed. + for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {}); + return c; + } catch (e) { lastErr = e; log("failed", url, e?.message); } + } + throw lastErr || new Error("no electrum server reachable"); + })(); + try { return await this.connecting; } + finally { this.connecting = null; } + } + async call(method, params = []) { + const c = await this._ensure(); + return c.call(method, params); + } + // Remember a subscription so it survives reconnects. + async subscribe(method, params = []) { + this.subscriptions.set(method + ":" + JSON.stringify(params), [method, params]); + return this.call(method, params); + } + clearSubscriptions() { this.subscriptions.clear(); } + disconnect() { + if (this.conn) { const c = this.conn; this.conn = null; c.close(); } + if (this.onServer) this.onServer(null); + } + } + + return { Client }; +}; diff --git a/lib/keys.js b/lib/keys.js new file mode 100644 index 0000000..95c087e --- /dev/null +++ b/lib/keys.js @@ -0,0 +1,66 @@ +// HD key tree for the wallet. Root = 32 bytes from api.vault.derive treated as +// a BIP32 master seed; account = m/44'/145'/0' (BCH, SLIP-44). Branch 0 is +// receive, branch 1 is change. Private keys never leave this module except +// through sign() / signRecoverable() for a specific entry. +module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashaddr }) { + const hash160 = (b) => ripemd160(sha256(b)); + const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); + const p2shScript = (h160) => Uint8Array.from([0xa9, 0x14, ...h160, 0x87]); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + // electrum scripthash: sha256(script), byte-reversed, hex. + const scripthash = (script) => toHex(sha256(script).slice().reverse()); + + class WalletKeys { + constructor(root32, accountPath, prefix) { + this.prefix = prefix; + this.accountPath = accountPath; + this._account = HDKey.fromMasterSeed(root32).derive(accountPath); + this._branch = [this._account.deriveChild(0), this._account.deriveChild(1)]; + this._cache = new Map(); // "branch/index" -> entry + } + get xpub() { return this._account.publicExtendedKey; } + // Revealed only on explicit user action in Settings (show recovery info). + get xprv() { return this._account.privateExtendedKey; } + entry(branch, index) { + const k = branch + "/" + index; + let e = this._cache.get(k); + if (!e) { + const node = this._branch[branch].deriveChild(index); + const h160 = hash160(node.publicKey); + const script = p2pkhScript(h160); + e = { + branch, index, path: this.accountPath + "/" + branch + "/" + index, + publicKey: node.publicKey, h160, script, scriptHex: toHex(script), + scripthash: scripthash(script), + address: cashaddr.encode(this.prefix, 0, h160), + _node: node, + }; + this._cache.set(k, e); + } + return e; + } + findByScriptHex(scriptHex) { + for (const e of this._cache.values()) if (e.scriptHex === scriptHex) return e; + return null; + } + // ECDSA over a 32-byte digest, DER-encoded, low-S (BCH consensus rule). + sign(entry, digest32) { + return secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "der" }); + } + // 65-byte BIP-137 signature: [27 + recid + 4 (compressed)] || r || s. + signRecoverable(entry, digest32) { + const sig = secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "recovered" }); + const out = new Uint8Array(65); + out[0] = 27 + sig[0] + 4; + out.set(sig.subarray(1), 1); + return out; + } + wipe() { + for (const e of this._cache.values()) { try { e._node.wipePrivateData(); } catch {} } + this._cache.clear(); + for (const b of this._branch) { try { b.wipePrivateData(); } catch {} } + try { this._account.wipePrivateData(); } catch {} + } + } + return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex }; +}; diff --git a/lib/sia/keys.js b/lib/sia/keys.js new file mode 100644 index 0000000..c7260b6 --- /dev/null +++ b/lib/sia/keys.js @@ -0,0 +1,29 @@ +// Key tree for the Sia wallet: index i -> ed25519 key via walletd's +// KeyFromSeed(root, i), address = standard unlock hash of the public key. +// Private keys stay inside this module; sign() is the only way out. +module.exports = function makeKeys({ sia }) { + class WalletKeys { + constructor(root32) { + this._root = Uint8Array.from(root32); + this._cache = new Map(); + } + entry(index) { + let e = this._cache.get(index); + if (!e) { + const k = sia.keyFromSeed(this._root, index); + e = { index, pub: k.pub, address32: k.address32, address: k.address, _priv: k.priv }; + this._cache.set(index, e); + } + return e; + } + sign(entry, msg) { return sia.sign(entry._priv, msg); } + // Revealed only on explicit user action in Settings. + get seedHex() { return sia.toHex(this._root); } + wipe() { + for (const e of this._cache.values()) e._priv.fill(0); + this._cache.clear(); + this._root.fill(0); + } + } + return { WalletKeys }; +}; diff --git a/lib/sia/sia.js b/lib/sia/sia.js new file mode 100644 index 0000000..d7e119a --- /dev/null +++ b/lib/sia/sia.js @@ -0,0 +1,142 @@ +// Sia (v2 era) primitives: the Sia binary encoder, standard unlock-hash +// addresses, walletd's per-index key derivation, the v2 input signature hash +// and transaction weight. Mirrors go.sia.tech/core/types; every encoding +// here was checked against a real mainnet v2 transaction. +module.exports = function makeSia({ ed25519, blake2b }) { + const b256 = (data) => blake2b(data, { dkLen: 32 }); + const enc = new TextEncoder(); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const fromHex = (h) => Uint8Array.from(String(h).replace(/^0x/, "").match(/../g) || [], (x) => parseInt(x, 16)); + + // ---- encoder --------------------------------------------------------------- + class Encoder { + constructor() { this.parts = []; this.length = 0; } + write(b) { this.parts.push(b); this.length += b.length; return this; } + u8(n) { return this.write(Uint8Array.from([n & 0xff])); } + bool(v) { return this.u8(v ? 1 : 0); } + u64(n) { + let v = BigInt(n); const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } + return this.write(out); + } + bytes(b) { return this.u64(b.length).write(b); } + str(s) { return this.bytes(enc.encode(s)); } + // Currency is a 128-bit little-endian pair (lo, hi). + currency(hastings) { + const v = BigInt(hastings); + if (v < 0n || v >= (1n << 128n)) throw new Error("currency out of range"); + return this.u64(v & ((1n << 64n) - 1n)).u64(v >> 64n); + } + bytesOut() { + const out = new Uint8Array(this.length); let o = 0; + for (const p of this.parts) { out.set(p, o); o += p.length; } + return out; + } + } + const SPECIFIER_ED25519 = (() => { const s = new Uint8Array(16); s.set(enc.encode("ed25519")); return s; })(); + + // ---- addresses --------------------------------------------------------------- + const LEAF = 0, NODE = 1; + const sumPair = (a, b) => b256(Uint8Array.from([NODE, ...a, ...b])); + const leaf = (bytes) => b256(Uint8Array.from([LEAF, ...bytes])); + // Merkle root of the standard UnlockConditions {timelock 0, [pk], sigs 1}. + function standardUnlockHash(pk) { + const timelockHash = leaf(new Encoder().u64(0).bytesOut()); + const keyHash = leaf(new Encoder().write(SPECIFIER_ED25519).bytes(pk).bytesOut()); + const sigsHash = leaf(new Encoder().u64(1).bytesOut()); + return sumPair(sumPair(timelockHash, keyHash), sigsHash); + } + const addressString = (addr32) => toHex(addr32) + toHex(b256(addr32).slice(0, 6)); + function parseAddress(s) { + const t = String(s || "").trim().toLowerCase().replace(/^addr:/, ""); + if (!/^[0-9a-f]{76}$/.test(t)) throw new Error("address must be 76 hex characters"); + const raw = fromHex(t); + const body = raw.slice(0, 32); + if (toHex(b256(body).slice(0, 6)) !== toHex(raw.slice(32))) throw new Error("address checksum is wrong"); + return { bytes: body, address: t }; + } + + // ---- keys -------------------------------------------------------------------- + // walletd: key_i = ed25519 seed blake2b(seed32 || index u64le). + function keyFromSeed(seed32, index) { + const priv = b256(new Encoder().write(seed32).u64(index).bytesOut()); + const pub = ed25519.getPublicKey(priv); + return { priv, pub, address32: standardUnlockHash(pub), address: addressString(standardUnlockHash(pub)) }; + } + const sign = (priv, msg) => ed25519.sign(msg, priv); + const verify = (pub, msg, sig) => ed25519.verify(sig, msg, pub); + + // ---- v2 transactions --------------------------------------------------------- + // tx: { inputs: [{ parentId(hex) }], outputs: [{ value(BigInt), address32 }], minerFee(BigInt) } + // Sig hash = blake2b("sia/sig/input|" || 0x02 || V2TransactionSemantics). + function inputSigHash(tx) { + const e = new Encoder().write(enc.encode("sia/sig/input|")).u8(2); + e.u64(tx.inputs.length); + for (const i of tx.inputs) e.write(fromHex(i.parentId)); + e.u64(tx.outputs.length); + for (const o of tx.outputs) e.currency(o.value).write(o.address32); + e.u64(0).u64(0); // siafund inputs / outputs + e.u64(0).u64(0).u64(0); // contracts, revisions, resolutions + e.u64(0); // attestations + e.bytes(new Uint8Array(0)); // arbitrary data + e.bool(false); // new foundation address + e.currency(tx.minerFee); + return b256(e.bytesOut()); + } + // Weight = length of the full V2Transaction encoding (fees are per byte). + // Signed inputs carry the parent element with its Merkle proof, the policy + // and one 64-byte signature. + function weight(tx) { + const e = new Encoder().u8(2); + let fields = 0; + if (tx.inputs.length) fields |= 1; if (tx.outputs.length) fields |= 2; if (tx.minerFee > 0n) fields |= 1 << 10; + e.u64(fields); + if (tx.inputs.length) { + e.u64(tx.inputs.length); + for (const i of tx.inputs) { + e.u64(i.leafIndex || 0).u64((i.merkleProof || []).length); + for (const p of i.merkleProof || []) e.write(fromHex(p)); + e.write(fromHex(i.parentId)).currency(i.value).write(i.address32).u64(i.maturityHeight || 0); + // SatisfiedPolicy: version 1, op 7 (unlock conditions), uc, 1 sig, 0 preimages + e.u8(1).u8(7).u64(0).u64(1).write(SPECIFIER_ED25519).bytes(i.pub).u64(1); + e.u64(1).write(new Uint8Array(64)).u64(0); + } + } + if (tx.outputs.length) { e.u64(tx.outputs.length); for (const o of tx.outputs) e.currency(o.value).write(o.address32); } + if (tx.minerFee > 0n) e.currency(tx.minerFee); + return e.length; + } + // walletd JSON for /api/txpool/broadcast. + function toJson(tx, sigs) { + return { + siacoinInputs: tx.inputs.map((i, k) => ({ + parent: i.element, + satisfiedPolicy: { + policy: { type: "uc", policy: { timelock: 0, publicKeys: ["ed25519:" + toHex(i.pub)], signaturesRequired: 1 } }, + signatures: [toHex(sigs[k])], + }, + })), + siacoinOutputs: tx.outputs.map((o) => ({ value: o.value.toString(), address: addressString(o.address32) })), + minerFee: tx.minerFee.toString(), + }; + } + + // ---- units ------------------------------------------------------------------- + const HASTINGS_PER_SC = 10n ** 24n; + function formatSC(hastings, decimals = 6) { + const v = BigInt(hastings); const neg = v < 0n; const a = neg ? -v : v; + const whole = a / HASTINGS_PER_SC; + let frac = (a % HASTINGS_PER_SC).toString().padStart(24, "0").slice(0, decimals).replace(/0+$/, ""); + if (frac.length < 2) frac = frac.padEnd(2, "0"); + return (neg ? "-" : "") + whole.toString() + "." + frac; + } + function parseSC(text) { + const s = String(text || "").trim().replace(/,/g, ""); + if (!/^\d*(\.\d*)?$/.test(s) || s === "" || s === ".") throw new Error("amount must be a number"); + const [w = "0", f = ""] = s.split("."); + if (f.length > 24) throw new Error("too many decimals"); + return BigInt(w || "0") * HASTINGS_PER_SC + BigInt((f + "0".repeat(24)).slice(0, 24)); + } + + return { Encoder, standardUnlockHash, addressString, parseAddress, keyFromSeed, sign, verify, inputSigHash, weight, toJson, formatSC, parseSC, HASTINGS_PER_SC, toHex, fromHex, b256 }; +}; diff --git a/lib/sia/wallet.js b/lib/sia/wallet.js new file mode 100644 index 0000000..a829237 --- /dev/null +++ b/lib/sia/wallet.js @@ -0,0 +1,201 @@ +// Sia wallet state: address discovery, balance, history and v2 sends over a +// walletd client. Keys are the addon's derived key tree; nothing here touches +// UI or IPC. Amounts are BigInt hastings throughout. +module.exports = function makeWallet({ client, keys, sia, storage, log = () => {}, onChange = () => {} }) { + const GAP = 10; + const HISTORY_LIMIT = 25; + const state = { + used: new Set(), // indexes with any event + height: 0, + balance: { confirmed: 0n, immature: 0n }, + outputs: [], // spendable SiacoinElements with { index } + basis: null, + history: [], + receiveIndex: 0, + scanning: false, + error: null, + feePerByte: 0n, + }; + // Outputs we just spent stay hidden until walletd stops listing them. + const pendingSpent = new Map(); // id -> timestamp + + async function isUsed(entry) { + const ev = await client.events(entry.address, 1, 0); + return Array.isArray(ev) && ev.length > 0; + } + async function scan() { + const cursor = Number(storage.get("receiveCursor", 0)) || 0; + let gap = 0, i = 0; + while (gap < GAP || i < cursor + GAP) { + const e = keys.entry(i); + if (await isUsed(e)) { state.used.add(i); gap = 0; } else gap++; + i++; + } + let r = cursor; + while (state.used.has(r)) r++; + state.receiveIndex = r; + } + function watched() { + const idx = new Set(state.used); idx.add(state.receiveIndex); + return [...idx].map((i) => keys.entry(i)); + } + + async function loadOutputs() { + const tip = await client.tip(); + state.height = tip.height || 0; + let confirmed = 0n, immature = 0n; const outs = []; let basis = null; + for (const e of watched()) { + for (let offset = 0; ; offset += 100) { + const r = await client.outputs(e.address, 100, offset); + basis = r.basis || basis; + const list = Array.isArray(r.outputs) ? r.outputs : []; + for (const o of list) { + const v = BigInt(o.siacoinOutput.value); + if (o.maturityHeight > state.height) { immature += v; continue; } + if (pendingSpent.has(o.id)) continue; + confirmed += v; + outs.push({ element: o, id: o.id, value: v, entry: e }); + } + if (list.length < 100) break; + } + } + for (const [id, t] of pendingSpent) if (Date.now() - t > 20 * 60 * 1000) pendingSpent.delete(id); + state.outputs = outs; state.basis = basis; + state.balance = { confirmed, immature }; + try { state.feePerByte = await client.feePerByte(); } catch (e) { log("fee lookup failed:", e?.message); } + } + + // One row per event: net change for our addresses, type, confirmations. + async function loadHistory() { + const ours = new Set(watched().map((e) => e.address)); + const seen = new Map(); + for (const e of watched()) { + if (!state.used.has(e.index)) continue; + const evs = await client.events(e.address, HISTORY_LIMIT, 0); + for (const ev of Array.isArray(evs) ? evs : []) if (!seen.has(ev.id)) seen.set(ev.id, ev); + } + const rows = []; + for (const ev of seen.values()) { + let received = 0n, spent = 0n, to = null; + const d = ev.data || {}; + const type = String(ev.type || "").toLowerCase(); + if (type === "v2transaction" && d.transaction) { + for (const i of d.transaction.siacoinInputs || []) if (ours.has(i.parent.siacoinOutput.address)) spent += BigInt(i.parent.siacoinOutput.value); + for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; } + } else if (type === "v1transaction" && d.transaction) { + for (const s of d.spentSiacoinElements || []) if (ours.has(s.siacoinOutput.address)) spent += BigInt(s.siacoinOutput.value); + for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; } + } else if (d.siacoinElement) { + if (ours.has(d.siacoinElement.siacoinOutput.address)) received += BigInt(d.siacoinElement.siacoinOutput.value); + } + rows.push({ + id: ev.id, type: ev.type, height: ev.index ? ev.index.height : 0, confirmations: ev.confirmations || 0, + time: ev.timestamp ? Math.floor(Date.parse(ev.timestamp) / 1000) : 0, + delta: (received - spent).toString(), to: spent > received ? to : null, + maturityHeight: ev.maturityHeight || 0, + }); + } + rows.sort((a, b) => (b.height || Infinity) - (a.height || Infinity) || b.time - a.time); + state.history = rows.slice(0, HISTORY_LIMIT); + } + + async function refresh(full = false) { + if (state.scanning) return; + state.scanning = true; state.error = null; onChange(); + try { + if (full || !state.used.size && state.receiveIndex === 0) await scan(); + else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has(r)) r++; state.receiveIndex = r; } + await loadOutputs(); + await loadHistory(); + for (const o of state.outputs) state.used.add(o.entry.index); + let r = Number(storage.get("receiveCursor", 0)) || 0; + while (state.used.has(r)) r++; + state.receiveIndex = r; + } catch (e) { + state.error = e?.message || String(e); + log("refresh failed:", state.error); + } finally { state.scanning = false; onChange(); } + } + let pollTimer = null; + function startPolling(ms = 60000) { stopPolling(); pollTimer = setInterval(() => refresh(false), ms); } + function stopPolling() { clearInterval(pollTimer); pollTimer = null; } + + function current() { return keys.entry(state.receiveIndex); } + function nextUnusedAddress() { + let r = state.receiveIndex + 1; + while (state.used.has(r)) r++; + storage.set("receiveCursor", r); + state.receiveIndex = r; + onChange(); + return current(); + } + + // targets: [{ to, value: BigInt }]; feeMultiplier 1-3 over walletd's rate. + function plan({ targets, feeMultiplier = 1, sendMax = false }) { + const mult = BigInt(Math.min(3, Math.max(1, Math.round(Number(feeMultiplier) || 1)))); + const rate = state.feePerByte > 0n ? state.feePerByte * mult : 10n ** 19n * mult; + const outs = targets.map((t) => { + const a = sia.parseAddress(t.to); + return { value: BigInt(t.value || 0), address32: a.bytes, to: a.address }; + }); + const sorted = state.outputs.slice().sort((a, b) => (b.value > a.value ? 1 : b.value < a.value ? -1 : 0)); + const total = sorted.reduce((a, o) => a + o.value, 0n); + const change = current(); + const txOf = (inputs, outputs, fee) => ({ + inputs: inputs.map((o) => ({ + parentId: o.id, element: o.element, value: o.value, address32: o.entry.address32, pub: o.entry.pub, + leafIndex: o.element.stateElement.leafIndex, merkleProof: o.element.stateElement.merkleProof || [], maturityHeight: o.element.maturityHeight || 0, entry: o.entry, + })), + outputs, minerFee: fee, + }); + if (sendMax) { + if (outs.length !== 1) throw new Error("send max needs exactly one recipient"); + if (!sorted.length) throw new Error("no spendable balance"); + let fee = 0n; + for (let k = 0; k < 3; k++) fee = rate * BigInt(sia.weight(txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee))); + if (total <= fee) throw new Error("balance does not cover the fee"); + const tx = txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee); + return { tx, fee, recipients: [{ to: outs[0].to, value: (total - fee).toString() }], change: 0n, feePerByte: rate }; + } + const want = outs.reduce((a, o) => a + o.value, 0n); + for (const o of outs) if (o.value <= 0n) throw new Error("amount must be positive"); + const chosen = []; let sum = 0n; + for (const o of sorted) { + chosen.push(o); sum += o.value; + const withChange = [...outs, { value: 0n, address32: change.address32 }]; + const fee = rate * BigInt(sia.weight(txOf(chosen, withChange, 1n))); + if (sum >= want + fee) { + const rest = sum - want - fee; + const outputs = rest > 0n ? [...outs, { value: rest, address32: change.address32 }] : outs.slice(); + const tx = txOf(chosen, outputs, fee); + return { tx, fee, recipients: outs.map((o) => ({ to: o.to, value: o.value.toString() })), change: rest, feePerByte: rate }; + } + } + throw new Error("insufficient funds"); + } + + async function signAndBroadcast(p) { + const h = sia.inputSigHash(p.tx); + const sigs = p.tx.inputs.map((i) => keys.sign(i.entry, h)); + const json = sia.toJson(p.tx, sigs); + if (!state.basis) throw new Error("no chain basis for the outputs; refresh first"); + const r = await client.broadcast(state.basis, json); + const txid = r && r.v2transactions && r.v2transactions[0] && r.v2transactions[0].id; + for (const i of p.tx.inputs) pendingSpent.set(i.parentId, Date.now()); + log("broadcast", txid || "(no id returned)"); + setTimeout(() => refresh(false), 3000); + return { txid: txid || null, fee: p.fee.toString() }; + } + + function snapshot() { + const cur = current(); + return { + address: cur.address, addressIndex: state.receiveIndex, + balance: { confirmed: state.balance.confirmed.toString(), immature: state.balance.immature.toString() }, + height: state.height, history: state.history, outputCount: state.outputs.length, + feePerByte: state.feePerByte.toString(), scanning: state.scanning, error: state.error, + }; + } + function dispose() { stopPolling(); } + return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, startPolling, dispose, state }; +}; diff --git a/lib/sia/walletd.js b/lib/sia/walletd.js new file mode 100644 index 0000000..da489cd --- /dev/null +++ b/lib/sia/walletd.js @@ -0,0 +1,42 @@ +// Thin client for the walletd HTTP API (go.sia.tech/walletd, index mode +// "full"). Only address-scoped reads plus txpool fee/broadcast are used, so +// any public or self-hosted walletd works; the URL is a user setting. +module.exports = function makeWalletd({ log = () => {} }) { + class Client { + constructor(baseUrl) { this.setBase(baseUrl); } + setBase(baseUrl) { + const u = String(baseUrl || "").trim().replace(/\/+$/, ""); + this.base = u ? (u.endsWith("/api") ? u : u + "/api") : ""; + } + // Everything after the host is the node's business; only the origin is + // ever logged or shown, since hosted providers key access on the path. + get displayUrl() { try { return new URL(this.base).origin; } catch { return this.base; } } + async _req(method, path, body) { + if (!this.base) throw new Error("no walletd URL configured"); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 25000); + try { + const res = await fetch(this.base + path, { + method, signal: ctrl.signal, + headers: body !== undefined ? { "content-type": "application/json" } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`walletd ${res.status}: ${text.slice(0, 200).trim()}`); + try { return JSON.parse(text); } catch { return text; } + } finally { clearTimeout(timer); } + } + get(path) { return this._req("GET", path); } + post(path, body) { return this._req("POST", path, body); } + + tip() { return this.get("/consensus/tip"); } + // Recommended fee in hastings per byte (JSON string). + async feePerByte() { return BigInt(String(await this.get("/txpool/fee")).replace(/"/g, "")); } + balance(addr) { return this.get(`/addresses/${addr}/balance`); } + // { basis, outputs: [SiacoinElement] } — proofs are valid at `basis`. + outputs(addr, limit = 100, offset = 0) { return this.get(`/addresses/${addr}/outputs/siacoin?limit=${limit}&offset=${offset}`); } + events(addr, limit = 25, offset = 0) { return this.get(`/addresses/${addr}/events?limit=${limit}&offset=${offset}`); } + broadcast(basis, v2tx) { return this.post("/txpool/broadcast", { basis, transactions: [], v2transactions: [v2tx] }); } + } + return { Client }; +}; diff --git a/lib/sol-spl.js b/lib/sol-spl.js new file mode 100644 index 0000000..0b18fa9 --- /dev/null +++ b/lib/sol-spl.js @@ -0,0 +1,220 @@ +// Solana Program Library (SPL) token primitives — PDA derivation, +// Associated Token Account math, and the two Token-program instructions +// this wallet needs at the bytecode level: `TransferChecked` (send SPL +// with a decimals sanity check) and `CreateAssociatedTokenAccountIdempotent` +// (make the receiver's token account inline, so the user doesn't have to +// pre-create it on any address they've never seen before). +// +// Every account address on Solana is a 32-byte ed25519 public key. A +// Program-Derived Address (PDA) is a 32-byte value that is NOT on the +// ed25519 curve — the runtime uses that fact as proof that no one holds +// its private key, so only the owning program can spend from it. To +// derive a PDA we sha256(seeds || programId || bump || "ProgramDerivedAddress") +// for bump = 255…0 and pick the first value that isn't a valid curve +// point. `isOnCurve` here defers to @noble/curves/ed25519's ExtendedPoint, +// which throws on invalid points; everything that decodes is on-curve. + +const PDA_MARKER = new TextEncoder().encode("ProgramDerivedAddress"); + +module.exports = function makeSolSpl({ ed25519, sha256, base58 }) { + if (!ed25519 || !sha256 || !base58) throw new Error("sol-spl: missing dep"); + + // ---- constants ------------------------------------------------------- + const TOKEN_PROGRAM_ID_B58 = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + const ASSOC_PROGRAM_ID_B58 = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + const TOKEN_2022_PROGRAM_ID_B58 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + const TOKEN_PROGRAM_ID = base58.decode(TOKEN_PROGRAM_ID_B58); + const ASSOC_PROGRAM_ID = base58.decode(ASSOC_PROGRAM_ID_B58); + const TOKEN_2022_PROGRAM_ID = base58.decode(TOKEN_2022_PROGRAM_ID_B58); + const SYSTEM_PROGRAM_ID = new Uint8Array(32); + + // Known-token registry — just enough to give the panel a sensible label + // for the tokens users actually see day-to-day. Everything else falls + // back to the mint address itself (truncated in the UI). + const KNOWN_TOKENS = { + mainnet: { + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { symbol: "USDC", decimals: 6, name: "USD Coin" }, + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": { symbol: "USDT", decimals: 6, name: "Tether USD" }, + "So11111111111111111111111111111111111111112": { symbol: "wSOL", decimals: 9, name: "Wrapped SOL" }, + }, + devnet: { + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { symbol: "USDC", decimals: 6, name: "USD Coin (devnet)" }, + }, + }; + + // ---- ed25519 curve check -------------------------------------------- + // A 32-byte pubkey is "on curve" if it decompresses to a valid Edwards + // point. @noble/curves v2 exposes Point.fromBytes(bytes) (throws on + // invalid); older versions exposed ExtendedPoint.fromHex(hex-string) + // — try each in order. + function isOnCurve(pubkey32) { + const P = ed25519.Point || ed25519.ExtendedPoint; + if (!P) return true; // no curve access — treat every value as + // on-curve (over-conservative; PDA loop falls + // through more than it should but never gets + // wrong). + try { + if (typeof P.fromBytes === "function") { P.fromBytes(pubkey32); return true; } + if (typeof P.fromHex === "function") { + const hex = Array.from(pubkey32, (b) => b.toString(16).padStart(2, "0")).join(""); + P.fromHex(hex); return true; + } + } catch { return false; } + return true; + } + + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + }; + const u64le = (n) => { + let v = BigInt(n); const o = new Uint8Array(8); + for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; } + return o; + }; + const encodeCompactU16 = (n) => { + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; out.push(byte); + } + return Uint8Array.from(out); + }; + + // ---- PDA / ATA ------------------------------------------------------- + function findProgramAddress(seeds, programId) { + for (let bump = 255; bump >= 0; bump--) { + const material = concat( + ...seeds.map((s) => Uint8Array.from(s)), + Uint8Array.from([bump]), + programId, + PDA_MARKER, + ); + const candidate = sha256(material); + if (!isOnCurve(candidate)) return { address: candidate, bump }; + } + throw new Error("no PDA found (unreachable)"); + } + // Standard ATA = PDA under ASSOC_PROGRAM_ID with seeds + // [ownerPubkey, TOKEN_PROGRAM_ID, mint]. + // We match the seed layout the @solana/spl-token library uses so + // addresses agree with any wallet or block explorer. + function associatedTokenAddress(owner, mint, tokenProgram = TOKEN_PROGRAM_ID) { + return findProgramAddress([owner, tokenProgram, mint], ASSOC_PROGRAM_ID).address; + } + + // ---- instruction encoders ------------------------------------------- + // SPL Token: TransferChecked (discriminator 12) — asserts amount+decimals + // against the mint so a UI bug can't move 1000× the intended value. + // accounts: [sourceATA (writable), mint (readonly), destATA (writable), owner (signer)] + // data: [12, amount:u64_le, decimals:u8] + function transferCheckedInstruction({ sourceATA, mint, destATA, owner, amount, decimals, tokenProgram = TOKEN_PROGRAM_ID }) { + return { + programId: tokenProgram, + keys: [ + { pubkey: sourceATA, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destATA, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: false }, + ], + data: concat(Uint8Array.from([12]), u64le(amount), Uint8Array.from([decimals])), + }; + } + // Associated Token Account program: CreateIdempotent (discriminator 1) + // accounts: [payer(signer,writable), ata(writable), owner(readonly), + // mint(readonly), systemProgram(readonly), tokenProgram(readonly)] + // data: [1] (idempotent variant — no-op if the account exists) + function createATAIdempotentInstruction({ payer, ata, owner, mint, tokenProgram = TOKEN_PROGRAM_ID }) { + return { + programId: ASSOC_PROGRAM_ID, + keys: [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: ata, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: tokenProgram, isSigner: false, isWritable: false }, + ], + data: Uint8Array.from([1]), + }; + } + + // ---- transaction message builder ------------------------------------ + // Assembles the raw Solana message bytes for a single-fee-payer, single- + // signer transaction that may carry multiple instructions. Account keys + // are sorted per Solana's account-classification rules (writable-signed, + // readonly-signed, writable-unsigned, readonly-unsigned). + function buildMessage({ feePayer, instructions, recentBlockhash }) { + // 1. Collect every unique pubkey mentioned across instructions + + // include the fee payer + include each instruction's programId. + const keys = new Map(); // b58 → { pubkey, isSigner, isWritable } + const upsert = (pubkey, isSigner, isWritable) => { + const k = base58.encode(pubkey); + const cur = keys.get(k) || { pubkey, isSigner: false, isWritable: false }; + cur.isSigner = cur.isSigner || isSigner; + cur.isWritable = cur.isWritable || isWritable; + keys.set(k, cur); + }; + upsert(feePayer, true, true); + for (const ins of instructions) { + for (const k of ins.keys) upsert(k.pubkey, k.isSigner, k.isWritable); + upsert(ins.programId, false, false); + } + // 2. Classify + sort into the four buckets. + const bucket = { ws: [], rs: [], wu: [], ru: [] }; + for (const v of keys.values()) { + if (v.isSigner && v.isWritable) bucket.ws.push(v); + else if (v.isSigner) bucket.rs.push(v); + else if (v.isWritable) bucket.wu.push(v); + else bucket.ru.push(v); + } + // Fee payer MUST be at index 0 (Solana requires the first signer to + // be writable & to pay the fee). + const payerB58 = base58.encode(feePayer); + bucket.ws.sort((a, b) => (base58.encode(a.pubkey) === payerB58 ? -1 : base58.encode(b.pubkey) === payerB58 ? 1 : 0)); + const ordered = [...bucket.ws, ...bucket.rs, ...bucket.wu, ...bucket.ru]; + // 3. Encode the message. + const header = Uint8Array.from([ + bucket.ws.length + bucket.rs.length, // numRequiredSignatures + bucket.rs.length, // numReadonlySignedAccounts + bucket.ru.length, // numReadonlyUnsignedAccounts + ]); + const keysSection = concat( + encodeCompactU16(ordered.length), + ...ordered.map((v) => Uint8Array.from(v.pubkey)), + ); + const indexOf = new Map(ordered.map((v, i) => [base58.encode(v.pubkey), i])); + const insSection = concat( + encodeCompactU16(instructions.length), + ...instructions.map((ins) => { + const programIndex = indexOf.get(base58.encode(ins.programId)); + const accountBytes = Uint8Array.from(ins.keys.map((k) => indexOf.get(base58.encode(k.pubkey)))); + return concat( + Uint8Array.from([programIndex]), + encodeCompactU16(accountBytes.length), + accountBytes, + encodeCompactU16(ins.data.length), + ins.data, + ); + }), + ); + return concat(header, keysSection, recentBlockhash, insSection); + } + + return { + // constants + TOKEN_PROGRAM_ID, ASSOC_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, SYSTEM_PROGRAM_ID, + TOKEN_PROGRAM_ID_B58, ASSOC_PROGRAM_ID_B58, + KNOWN_TOKENS, + // helpers + isOnCurve, findProgramAddress, associatedTokenAddress, + // instruction encoders + transferCheckedInstruction, createATAIdempotentInstruction, + // tx assembly + buildMessage, + }; +}; diff --git a/lib/tx.js b/lib/tx.js new file mode 100644 index 0000000..d903da6 --- /dev/null +++ b/lib/tx.js @@ -0,0 +1,122 @@ +// Transaction building for P2PKH spends on Bitcoin Cash: serialization, the +// BIP143-style replay-protected sighash (SIGHASH_ALL | FORKID), coin +// selection and fee estimation. Signing itself is delegated to WalletKeys so +// private keys stay in one module. +module.exports = function makeTx({ sha256 }) { + const SIGHASH_ALL_FORKID = 0x41; + const DUST = 546; + const P2PKH_INPUT_SIZE = 149; // outpoint 36 + len 1 + sig push 74 + pubkey push 34 + sequence 4 + const P2PKH_OUTPUT_SIZE = 34; + const OVERHEAD = 10; // version 4 + in/out counts 2 + locktime 4 + + const dsha = (b) => sha256(sha256(b)); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const fromHex = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16)); + const concat = (...parts) => { + const n = parts.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; + }; + const u32le = (n) => Uint8Array.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]); + const u64le = (n) => { + let v = BigInt(n); const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } + return out; + }; + const varint = (n) => { + if (n < 0xfd) return Uint8Array.from([n]); + if (n <= 0xffff) return Uint8Array.from([0xfd, n & 0xff, n >> 8]); + return concat(Uint8Array.from([0xfe]), u32le(n)); + }; + const varbytes = (b) => concat(varint(b.length), b); + const pushdata = (b) => { + if (b.length < 0x4c) return concat(Uint8Array.from([b.length]), b); + return concat(Uint8Array.from([0x4c, b.length]), b); + }; + const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout)); + + const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE; + const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte); + + // inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }] + // outputs: [{ value, script(Uint8Array) }] + function serialize(tx) { + return concat( + u32le(tx.version ?? 2), + varint(tx.inputs.length), + ...tx.inputs.map((i) => concat(outpoint(i), varbytes(i.unlocking || new Uint8Array(0)), u32le(i.sequence ?? 0xffffffff))), + varint(tx.outputs.length), + ...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script))), + u32le(tx.locktime ?? 0), + ); + } + + function sighash(tx, index) { + const inp = tx.inputs[index]; + const hashPrevouts = dsha(concat(...tx.inputs.map(outpoint))); + const hashSequence = dsha(concat(...tx.inputs.map((i) => u32le(i.sequence ?? 0xffffffff)))); + const hashOutputs = dsha(concat(...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script))))); + const preimage = concat( + u32le(tx.version ?? 2), + hashPrevouts, hashSequence, + outpoint(inp), + varbytes(inp.script), + u64le(inp.value), + u32le(inp.sequence ?? 0xffffffff), + hashOutputs, + u32le(tx.locktime ?? 0), + u32le(SIGHASH_ALL_FORKID), + ); + return dsha(preimage); + } + + // signer(input, index, digest) -> { sig: DER bytes, publicKey } + function sign(tx, signer) { + tx.inputs.forEach((inp, i) => { + const { sig, publicKey } = signer(inp, i, sighash(tx, i)); + inp.unlocking = concat(pushdata(concat(sig, Uint8Array.from([SIGHASH_ALL_FORKID]))), pushdata(publicKey)); + }); + const raw = serialize(tx); + return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) }; + } + + // Largest-first accumulation. `targets` = [{ value, script }]; returns + // { inputs, outputs, fee, change } or throws when funds don't cover it. + // sendMax: spend every UTXO into targets[0] and no change. + function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) { + const sorted = utxos.slice().sort((a, b) => b.value - a.value); + const total = sorted.reduce((a, u) => a + u.value, 0); + if (sendMax) { + if (targets.length !== 1) throw new Error("send max needs exactly one recipient"); + const fee = feeFor(sorted.length, 1, satPerByte); + const value = total - fee; + if (!sorted.length || value < DUST) throw new Error("balance too small to send"); + return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 }; + } + const want = targets.reduce((a, t) => a + t.value, 0); + for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`); + const chosen = []; let sum = 0; + for (const u of sorted) { + chosen.push(u); sum += u.value; + const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte); + if (sum >= want + feeWithChange) { + const change = sum - want - feeWithChange; + if (change >= DUST) { + return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change }; + } + // Change would be dust: fold it into the fee, one output fewer. + const fee = sum - want; + return { inputs: chosen, outputs: targets.slice(), fee, change: 0 }; + } + const feeNoChange = feeFor(chosen.length, targets.length, satPerByte); + if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) { + return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 }; + } + } + const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total; + throw new Error(`insufficient funds: need about ${short} more sat`); + } + + return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha }; +}; diff --git a/lib/wallet.js b/lib/wallet.js new file mode 100644 index 0000000..16407c5 --- /dev/null +++ b/lib/wallet.js @@ -0,0 +1,243 @@ +// Wallet state machine on top of an electrum client and a WalletKeys tree: +// address discovery (gap limit), balance, history with per-tx deltas, UTXO +// set and send construction. Knows nothing about UI or IPC. +module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) { + const GAP = 20; + const HISTORY_LIMIT = 25; + const sats = (bch) => Math.round(Number(bch) * 1e8); + + const state = { + used: new Set(), // "branch/index" with history + watched: new Map(), // scripthash -> entry + height: 0, + balance: { confirmed: 0, unconfirmed: 0 }, + utxos: [], // { txid, vout, value, height, entry } + history: [], // newest first + receiveIndex: 0, + scanning: false, + error: null, + }; + // Verbose transactions are public chain data; caching them on disk saves a + // round of fetches on every launch. + const txCache = storage.get("txCache", {}) || {}; + let refreshTimer = null; + let subscribedHeaders = false; + + function key(e) { return e.branch + "/" + e.index; } + function watch(e) { if (!state.watched.has(e.scripthash)) state.watched.set(e.scripthash, e); } + + async function historyOf(e) { + const h = await client.call("blockchain.scripthash.get_history", [e.scripthash]); + return Array.isArray(h) ? h : []; + } + + // Walk both branches until GAP consecutive unused indexes, always covering + // the user's chosen receive cursor so its lookahead stays subscribed. + async function scan() { + const cursor = Number(storage.get("receiveCursor", 0)) || 0; + for (const branch of [0, 1]) { + let gap = 0, i = 0; + const minIndex = branch === 0 ? cursor + 1 : 0; + while (gap < GAP || i < minIndex + GAP) { + const batch = []; + for (let k = 0; k < 10; k++) batch.push(keys.entry(branch, i + k)); + const results = await Promise.all(batch.map(historyOf)); + for (let k = 0; k < batch.length; k++) { + const e = batch[k]; watch(e); + if (results[k].length) { state.used.add(key(e)); gap = 0; } else gap++; + i++; + if (gap >= GAP && i >= minIndex + GAP) break; + } + } + } + // Current receive address: first unused at or after the cursor. + let r = cursor; + while (state.used.has("0/" + r)) r++; + state.receiveIndex = r; + watch(keys.entry(0, r)); + } + + async function subscribeAll() { + if (!subscribedHeaders) { + subscribedHeaders = true; + const tip = await client.subscribe("blockchain.headers.subscribe", []); + if (tip && tip.height) state.height = tip.height; + } + await Promise.all([...state.watched.values()].map((e) => + client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); + } + + async function loadUtxos() { + const lists = await Promise.all([...state.watched.values()].map(async (e) => { + const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); + return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + })); + state.utxos = lists.flat(); + let confirmed = 0, unconfirmed = 0; + for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } + state.balance = { confirmed, unconfirmed }; + } + + async function getTx(txid) { + const c = txCache[txid]; + if (c && c.confirmations > 0) return c; + const raw = await client.call("blockchain.transaction.get", [txid, true]); + const slim = { + txid, + confirmations: raw.confirmations || 0, + time: raw.blocktime || raw.time || 0, + vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })), + vout: (raw.vout || []).map((o) => ({ value: sats(o.value), scriptHex: o.scriptPubKey && o.scriptPubKey.hex })), + size: raw.size || 0, + }; + txCache[txid] = slim; + return slim; + } + + async function loadHistory() { + const entries = [...state.watched.values()].filter((e) => state.used.has(key(e))); + const merged = new Map(); + const lists = await Promise.all(entries.map(historyOf)); + for (const list of lists) for (const h of list) { + const prev = merged.get(h.tx_hash); + if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); + } + const ordered = [...merged.values()].sort((a, b) => { + const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; + return hb - ha; + }).slice(0, HISTORY_LIMIT); + const ours = new Set([...state.watched.values()].map((e) => e.scriptHex)); + const out = []; + for (const h of ordered) { + const t = await getTx(h.txid); + let received = 0, spent = 0, inputsTotal = 0, outputsTotal = 0, allInputsOurs = true; + for (const o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; } + for (const i of t.vin) { + if (!i.txid) continue; // coinbase + const p = await getTx(i.txid); + const po = p.vout[i.vout]; + if (!po) continue; + inputsTotal += po.value; + if (ours.has(po.scriptHex)) spent += po.value; else allInputsOurs = false; + } + const delta = received - spent; + let to = null; + if (delta < 0) { + const ext = t.vout.find((o) => !ours.has(o.scriptHex)); + if (ext && ext.scriptHex) to = scriptToAddress(ext.scriptHex); + } + out.push({ + txid: t.txid, height: h.height, confirmations: t.confirmations, time: t.time, + delta, fee: allInputsOurs && inputsTotal ? inputsTotal - outputsTotal : null, to, + }); + } + state.history = out; + storage.set("txCache", txCache); + } + + function scriptToAddress(scriptHex) { + try { + if (/^76a914[0-9a-f]{40}88ac$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 0, tx.fromHex(scriptHex.slice(6, 46))); + if (/^a914[0-9a-f]{40}87$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 1, tx.fromHex(scriptHex.slice(4, 44))); + } catch {} + return null; + } + + async function refresh(full = false) { + if (state.scanning) return; + state.scanning = true; state.error = null; onChange(); + try { + if (full || !state.watched.size) await scan(); + else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has("0/" + r)) r++; state.receiveIndex = r; watch(keys.entry(0, r)); } + await loadUtxos(); + await loadHistory(); + await subscribeAll(); + // A tx that just landed can mark the current receive address used. + for (const u of state.utxos) state.used.add(key(u.entry)); + let r = Number(storage.get("receiveCursor", 0)) || 0; + while (state.used.has("0/" + r)) r++; + if (r !== state.receiveIndex) { state.receiveIndex = r; watch(keys.entry(0, r)); } + } catch (e) { + state.error = e?.message || String(e); + log("refresh failed:", state.error); + } finally { + state.scanning = false; + onChange(); + } + } + function scheduleRefresh(ms = 800) { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => refresh(false), ms); + } + + client.onNotify = (method, params) => { + if (method === "blockchain.headers.subscribe") { + const h = params && params[0] && params[0].height; + if (h) { state.height = h; scheduleRefresh(1500); } + } else if (method === "blockchain.scripthash.subscribe") { + scheduleRefresh(800); + } + }; + + function nextUnusedAddress() { + let r = state.receiveIndex + 1; + while (state.used.has("0/" + r)) r++; + storage.set("receiveCursor", r); + state.receiveIndex = r; + watch(keys.entry(0, r)); + client.subscribe("blockchain.scripthash.subscribe", [keys.entry(0, r).scripthash]).catch(() => {}); + onChange(); + return current(); + } + function current() { return keys.entry(0, state.receiveIndex); } + function changeEntry() { + let i = 0; + while (state.used.has("1/" + i)) i++; + return keys.entry(1, i); + } + + // targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan. + function plan({ targets, feeRate = 1, sendMax = false }) { + const rate = Math.min(10, Math.max(1, Number(feeRate) || 1)); + const outs = targets.map((t) => { + const a = cashaddr.parseAny(t.to, sha256, keys.prefix); + const script = a.type === 0 + ? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac]) + : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]); + return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; + }); + // Spend confirmed coins first; unconfirmed only when needed. + const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax }); + return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) }; + } + + async function signAndBroadcast(p) { + const t = { inputs: p.inputs.map((u) => ({ ...u, script: u.entry.script })), outputs: p.outputs }; + const signed = tx.sign(t, (inp, _i, digest) => ({ sig: keys.sign(inp.entry, digest), publicKey: inp.entry.publicKey })); + const txid = await client.call("blockchain.transaction.broadcast", [signed.hex]); + if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); + log("broadcast", txid); + scheduleRefresh(1200); + return { txid, hex: signed.hex, fee: p.fee }; + } + + function snapshot() { + const cur = current(); + return { + address: cur.address, + addressIndex: state.receiveIndex, + addressPath: cur.path, + balance: state.balance, + height: state.height, + history: state.history, + utxoCount: state.utxos.length, + scanning: state.scanning, + error: state.error, + }; + } + + function dispose() { clearTimeout(refreshTimer); } + + return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, dispose, state }; +}; diff --git a/panel.html b/panel.html new file mode 100644 index 0000000..44d477e --- /dev/null +++ b/panel.html @@ -0,0 +1,374 @@ + + + + +Aegis Wallet + + + + + +
+
+
+ + Aegis Wallet + +
+
+ + ▾ +
+
+ +
+
——
+
+ connecting… + +
+
+
+ +
+ +
+
+
+
+
Receiving address
+
—
+
+ + + + +
+
+ +
+ + + +
+
+ + + + diff --git a/panel.js b/panel.js new file mode 100644 index 0000000..e0c667a --- /dev/null +++ b/panel.js @@ -0,0 +1,836 @@ +// Aegis wallet panel. All state comes from activate() via window.silentmode. +// This file renders the multi-wallet picker, per-chain views, and collects +// input; it never touches keys or the vault. +const $ = (id) => document.getElementById(id); +const S = window.silentmode; +let state = null; // full state (all wallets + selected) +let tab = "receive"; +let unit = null; // "big" | "small" — chain-dependent +let sendMax = false; +let planTimer = null; +let lastPlan = null; +let settingsFilled = false; +// Selected asset for the Send tab. `null` = native coin. Otherwise a +// { mint, symbol, decimals } picked from the SOL wallet's SPL token list. +let sendAsset = null; + +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } }; +const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {}); +const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, ""); + +// ---- coin logos ------------------------------------------------------------ +// Inline SVGs so the header, wallet picker and settings surface all render +// the same mark. Sized by the container via width/height attributes. +function logoSvg(logo, size) { + const s = size || 20; + if (logo === "bch") { + return ` + + ₿ + `; + } + if (logo === "trx") { + // Simplified from the official geometric Tron mark: triangle + tail line. + return ` + + + + `; + } + if (logo === "sc") { + // Sia's mark is a stylized S built from two mirrored crescents. Approximated + // here with a plain S glyph on the brand green so it reads at 22px. + return ` + + S + `; + } + if (logo === "dgb") { + return ` + + D + `; + } + if (logo === "btc") { + // Orange disc with the Bitcoin sign — the widely-recognised BTC mark. + return ` + + ₿ + `; + } + if (logo === "eth") { + // Ethereum's mark is the two-triangle rhombus. Simplified to the + // silhouette on a light-purple disc so it reads at 22px. + return ` + + + + `; + } + if (logo === "sol") { + // Solana's three-slant mark. Purple → green gradient in the brand + // spec; approximated with two solid parallelograms on a dark disc. + return ` + + + + + `; + } + if (logo === "aegis") { + // Athena's aspis — hexagonal shield with a boss at center + four + // spoke marks. Same silhouette as the aegis.x hero SVG so the wallet + // and the marketing page read as one identity. + return ` + + + + + `; + } + // Fallback = Aegis shield (rather than a "?"), so an unrecognised + // registry entry still looks intentional. + return logoSvg("aegis", s); +} +function testnetTag() { return `TEST`; } + +// Selected wallet convenience. +const sel = () => state && state.selected; +const chain = () => sel()?.chain || ""; +const decimals = () => sel()?.meta?.decimals || 8; +const ticker = () => sel()?.meta?.ticker || ""; + +// Numbers past ~9e15 lose precision as JS `Number`, and Sia amounts live at +// 10^24-scale routinely. Use BigInt for anything that arrives as a string. +function fmtBig(units, dec) { + const d = dec != null ? dec : decimals(); + if (typeof units === "string" && /^-?\d+$/.test(units)) { + const neg = units.startsWith("-"); + const raw = neg ? units.slice(1) : units; + const bi = BigInt(raw || "0"); + const base = 10n ** BigInt(d); + const whole = (bi / base).toString(); + let frac = (bi % base).toString().padStart(d, "0").replace(/0+$/, ""); + // Show 8-digit precision at most for very small units; keep 2 dp minimum. + const cap = Math.min(d, 8); + if (frac.length > cap) frac = frac.slice(0, cap); + if (!frac) frac = ""; + return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); + } + const s = (Number(units || 0) / Math.pow(10, d)).toFixed(d); + return s.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +} +function fmtSmall(units) { + if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return Number(units || 0).toLocaleString("en-US"); +} +function smallUnitLabel() { + const c = chain(); + if (c === "bch" || c === "dgb" || c === "btc") return "sat"; + if (c === "trx") return "sun"; + if (c === "sc") return "H"; + if (c === "eth") return "wei"; + if (c === "sol") return "lamports"; + return "u"; +} +// Some chains (SOL) suffix explorer URLs to tell devnet from mainnet. +function explorerHref(base, id) { + const s = sel(); + return base + id + (s?.explorerSuffix || ""); +} +function bigUnitLabel() { return ticker(); } + +// ---- 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") { settingsFilled = false; fillSettings(); } + if (name === "send") applyUnitPicker(); +} + +// ---- wallet picker (two-step add) ------------------------------------------ + +$("pickerBtn").addEventListener("click", (e) => { + // The "+" chip inside the picker header opens the dropdown with the + // Add-wallet section pre-expanded — same UX as the empty-state gate + // button but always available. + const isAddChip = e.target && (e.target.id === "hAddWallet" || e.target.closest("#hAddWallet")); + const d = $("drop"); + if (isAddChip) { + d.hidden = false; + fillPicker(); + setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0); + e.stopPropagation(); + return; + } + d.hidden = !d.hidden; + if (!d.hidden) fillPicker(); +}); +document.addEventListener("click", (e) => { + const d = $("drop"); + if (d.hidden) return; + if (e.target.closest("#drop") || e.target.closest("#pickerBtn")) return; + d.hidden = true; +}); + +function fillPicker() { + const d = $("drop"); + const wallets = state?.wallets || []; + const coins = state?.coins || []; + const rowsHtml = wallets.map((w) => { + const on = w.id === state.selectedWalletId ? "on" : ""; + const bal = w.balance ? fmtBig(w.balance.confirmed || 0, w.decimals) + " " + w.ticker : "—"; + const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`; + return `
+ ${logoSvg(w.logo, 22)} +
${esc(w.label)}
${sub}
+
${esc(bal)}
+
`; + }).join(""); + // "Add wallet" is a two-step flyout: first show coins, then that coin's + // networks. Nothing is created until the user clicks a specific network. + const coinRows = coins.map((c) => { + const testCount = c.networks.filter((n) => n.testnet).length; + const sub = c.networks.length > 1 + ? c.networks.map((n) => n.label).join(" · ") + : c.networks[0].label; + return `
+ ${logoSvg(c.logo, 22)} +
${esc(c.label)}
${esc(sub)}
+
▸
+
+ `; + }).join(""); + d.innerHTML = + rowsHtml + + `
+ Add wallet
${coinRows}`; + + d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => { + d.hidden = true; + try { state = await S.invoke("selectWallet", { id: r.dataset.select }); settingsFilled = false; render(); } + catch (e) { showErr(cleanErr(e)); } + })); + d.querySelectorAll(".coinrow").forEach((r) => r.addEventListener("click", () => { + // Collapse other coins' network groups; toggle this one. + d.querySelectorAll(".netgroup").forEach((g) => { if (g.id !== "netgroup-" + r.dataset.coin) g.hidden = true; }); + d.querySelectorAll(".coinrow .caret").forEach((c) => { c.textContent = "▸"; }); + const group = d.querySelector("#netgroup-" + r.dataset.coin); + group.hidden = !group.hidden; + r.querySelector(".caret").textContent = group.hidden ? "▸" : "▾"; + })); + d.querySelectorAll("[data-add]").forEach((r) => r.addEventListener("click", async () => { + const [c, n] = r.dataset.add.split(":"); + d.hidden = true; + try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); } + catch (e) { showErr(cleanErr(e)); } + })); +} +function showErr(text) { + const box = $("gate"); + box.hidden = false; box.innerHTML = `
⚠
${esc(text)}
`; + setTimeout(() => { if (state?.selected?.phase === "ready") { box.hidden = true; } }, 3500); +} + +// ---- render ---------------------------------------------------------------- + +function render() { + if (!state) return; + const s = sel(); + const ready = s && s.phase === "ready"; + $("tabs").hidden = !ready; + const gate = $("gate"); + gate.hidden = ready; + // Header: replace the badge slot with the coin's SVG and show + // + $("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22); + $("hLabel").textContent = s?.label || "Aegis Wallet"; + $("hNet").innerHTML = s?.meta + ? `${esc(s.meta.coinLabel)} · ${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}` + : ""; + if (!ready) { + const copy = { + locked: ["🔒", "Unlock your password vault to open the wallet.", "Settings › Passwords. Aegis derives its keys 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 every wallet in Aegis can be recreated from it on any machine."], + error: ["⚠", "This wallet could not start.", s?.error || ""], + empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."], + }[s?.phase || "locked"] || ["…", "Starting…", ""]; + const showAddButton = s?.phase === "empty"; + gate.innerHTML = `
${copy[0]}
${esc(copy[1])}
${esc(copy[2])}
` + + (showAddButton ? `
` : ""); + // Wire the empty-state button — opens the header picker with the Add + // section pre-expanded so the user can pick a coin in one click. + if (showAddButton) { + const btn = $("gateAddWallet"); + if (btn) btn.addEventListener("click", () => { + const d = $("drop"); + d.hidden = false; + fillPicker(); + setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0); + }); + } + } + const dot = $("dot"); + dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : ""); + $("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || "")); + if (ready) { + // Sia-specific gate: adapter is up, keys are derived, but no walletd URL + // means no balance / history until the user configures one in Settings. + if (chain() === "sc" && s.needsWalletdUrl) { + $("balMain").textContent = "—"; $("balTicker").textContent = s.meta.ticker; + $("netlbl").textContent = "point Aegis at a walletd node in Settings"; + $("tabs").hidden = true; + gate.hidden = false; + gate.innerHTML = `
🗝
Point Aegis at a walletd node
Settings › Sia › walletd URL. Any public or self-hosted go.sia.tech/walletd in "full" index mode works.
`; + return; + } + const total = balanceSum(s.balance); + $("balMain").textContent = fmtBig(total); + $("balTicker").textContent = s.meta.ticker; + const uc = s.balance?.unconfirmed; + if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`; + } else { + $("balMain").textContent = "—"; $("balTicker").textContent = ""; + } + if (!ready) return; + const addr = s.address || ""; + if ($("addr").textContent !== addr) { + $("addr").textContent = addr; + drawQr(qrPayload(chain(), addr, sel()?.network)); + } + $("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : ""; + $("nextAddr").hidden = chain() !== "bch"; + $("openFaucet").hidden = !s.faucet; + // Render SPL tokens list (SOL wallets only). Sending a token clicks + // through to the Send tab with that asset pre-picked. + renderTokens(); + applyUnitPicker(); + $("feeField").hidden = chain() !== "bch"; + renderHistory(); +} + +function renderTokens() { + const s = sel(); + const tokens = (chain() === "sol" && s?.tokens) || []; + const card = $("tokensCard"); + card.hidden = tokens.length === 0; + if (!tokens.length) return; + const el = $("tokensList"); + el.innerHTML = tokens.map((t) => { + const dec = Number(t.decimals) || 0; + const bal = fmtTokenAmount(t.balance, dec); + return `
+
${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + '' : ""}
${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}
+
${esc(bal)}
+ +
`; + }).join(""); + el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => { + sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) }; + showTab("send"); + })); +} +// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts. +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 applyUnitPicker() { + const s = sel(); if (!s) return; + if (!unit) unit = "big"; + // ---- SPL asset picker (SOL wallets with tokens) --------------------- + const tokens = (chain() === "sol" && s.tokens) || []; + const assetField = $("sendAssetField"); + if (tokens.length) { + assetField.hidden = false; + const sel_ = $("sendAsset"); + // Rebuild whenever the asset set changes so a new token appears. + const key = tokens.map((t) => t.mint).join("|"); + if (sel_.dataset.key !== key) { + sel_.dataset.key = key; + sel_.innerHTML = `` + tokens.map((t) => + `` + ).join(""); + sel_.onchange = () => { + const opt = sel_.options[sel_.selectedIndex]; + sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null; + applyUnitPicker(); schedulePlan(); + }; + } + // Reflect the current sendAsset back into the select. + sel_.value = sendAsset ? sendAsset.mint : ""; + } else { + assetField.hidden = true; + sendAsset = null; + } + const isToken = sendAsset != null; + const big = isToken ? sendAsset.symbol : bigUnitLabel(); + const small = isToken ? "raw" : smallUnitLabel(); + $("unitPicker").innerHTML = + `` + + ``; + $("unitPicker").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => setUnit(b.dataset.u))); + $("sendTo").placeholder = ({ + bch: s.network === "chipnet" ? "bchtest:q… or legacy m…" : "bitcoincash:q… or legacy 1…", + btc: s.network === "testnet" ? "tb1q… (or 2… / m…, n…)" : "bc1q… (or bc1p…, 3…, 1…)", + trx: "T… (base58check, 34 chars)", + sc: "addr1… (76-hex + checksum)", + dgb: "dgb1q… (or D… / S… depending on family)", + eth: "0x… (40 hex chars, EIP-55)", + sol: "base58 public key (32 bytes)", + })[chain()] || "recipient address"; + $("sendAmt").placeholder = unit === "big" ? "0.00" : "0"; +} +function setUnit(u) { + if (u === unit) return; + const s = amountUnits(); + unit = u; + applyUnitPicker(); + if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s); +} +function amountUnits() { + const raw = $("sendAmt").value.trim().replace(/,/g, ""); + if (!raw) return 0; + // For SPL tokens the amount is a raw u64 string in the token's own + // smallest unit — same BigInt-safe path SC uses. + const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); + const bigDecimals = sendAsset != null || d > 15; + if (unit === "small") { + if (bigDecimals) return raw.replace(/\D+/g, "") || "0"; + return Math.round(Number(raw)); + } + const [w, f = ""] = raw.split("."); + const frac = (f + "0".repeat(d)).slice(0, d); + if (bigDecimals) { + const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0"); + return total.toString(); + } + return Number(w || 0) * Math.pow(10, d) + Number(frac || 0); +} +// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere). +function balanceSum(b) { + if (!b) return 0; + if (typeof b.confirmed === "string" || typeof b.unconfirmed === "string") { + return (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString(); + } + return (b.confirmed || 0) + (b.unconfirmed || 0); +} + +// ---- history --------------------------------------------------------------- + +function renderHistory() { + const s = sel(); + const list = s?.history || []; + const el = $("txlist"); + if (!list.length) { el.innerHTML = `
${s?.scanning ? "Syncing…" : "No transactions yet."}
`; 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 who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); + const what = (inc ? "Received" : "Sent") + (who ? " " + who : ""); + const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed"); + const delta = Math.abs(t.delta || 0); + return `
+
${inc ? "↓" : "↑"}
+
${esc(what)}
+
${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}
+
${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}
+
${esc(conf)}
+
`; + }).join(""); + el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid)))); +} +function shortAddr(a) { + if (!a) return ""; + const s = String(a).replace(/^bitcoincash:|^bchtest:/, ""); + return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4)); +} + +// ---- QR -------------------------------------------------------------------- + +// Coin-scheme URI so wallet apps that scan know which chain the payment is +// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681 +// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed +// URI scheme exists). +function qrPayload(chain, address, network) { + if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, ""); + if (chain === "btc") return "bitcoin:" + address; // BIP21 + if (chain === "dgb") return "digibyte:" + address; + if (chain === "eth") return "ethereum:" + address; + if (chain === "sol") return "solana:" + address; + if (chain === "trx") return "tron:" + address; + return String(address); +} +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); +} + +// ---- receive actions ------------------------------------------------------- + +$("copyAddr").addEventListener("click", async () => { + try { await navigator.clipboard.writeText(sel().address); flash($("copyAddr"), "Copied"); } catch {} +}); +$("nextAddr").addEventListener("click", async () => { + try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); } + catch (e) { flash($("nextAddr"), "Failed"); } +}); +$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address))); +$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet)); +function flash(btn, text) { + const old = btn.textContent; btn.textContent = text; + setTimeout(() => { btn.textContent = old; }, 1200); +} + +// ---- send ------------------------------------------------------------------ + +$("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 && !amountUnits())) return; + try { + if (sendAsset) { + // SPL token flow — amount is raw units of the token's decimals. + const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() }); + lastPlan = { _token: true, ...p }; + $("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol; + $("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL"; + $("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol; + $("sendBtn").disabled = false; + return; + } + const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; + const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax }); + lastPlan = p; + $("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : ""; + $("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker(); + $("sumFee").textContent = chain() === "bch" + ? fmtSmall(p.fee) + " " + smallUnitLabel() + : fmtBig(p.fee) + " " + ticker(); + $("sumTotal").textContent = fmtBig(p.total) + " " + ticker(); + if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(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 isToken = sendAsset && lastPlan._token; + const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; + const r = isToken + ? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() }) + : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax }); + msg.className = "msg ok"; + msg.innerHTML = `Sent. ${esc(r.txid.slice(0, 16))}…`; + msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().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"; } +}); + +// ---- settings -------------------------------------------------------------- + +function fillSettings() { + const s = sel(); if (!s) return; + $("bchSettings").hidden = chain() !== "bch"; + $("trxSettings").hidden = chain() !== "trx"; + $("scSettings").hidden = chain() !== "sc"; + $("dgbSettings").hidden = chain() !== "dgb"; + $("btcSettings").hidden = chain() !== "btc"; + $("ethSettings").hidden = chain() !== "eth"; + $("solSettings").hidden = chain() !== "sol"; + $("removeBtn").disabled = !!s.isLegacy && s.chain === "bch"; + $("removeHint").textContent = (s.isLegacy && s.chain === "bch") + ? "The default BCH wallet cannot be removed (it protects legacy funds)." + : (s.isLegacy && s.chain === "sc" ? "Removing this wallet unlinks it from Aegis. Funds stay on-chain and reappear if you add a Siacoin wallet again with the legacy seed slot." : ""); + $("renameLabel").value = s.label || ""; + if (chain() === "bch") { + if (!settingsFilled) { + $("setPath").value = s.accountPath || ""; + $("setServers").value = (state.bchServers?.list || []).join("\n"); + settingsFilled = true; + } + $("bchServersRow").hidden = s.network !== "mainnet"; + $("serverHint").textContent = s.network !== "mainnet" + ? "Chipnet uses bundled defaults in this build." + : (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected."); + $("purpose").textContent = "silentmode/addons/" + (s.purpose || ""); + } else if (chain() === "trx") { + $("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + } else if (chain() === "sc") { + if (!settingsFilled) { + $("setWalletdUrl").value = s.walletdUrl || ""; + settingsFilled = true; + } + $("scPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("scRecovery").innerHTML = ""; + } else if (chain() === "dgb") { + if (!settingsFilled) { + fillFamilyPicker("Dgb", s); + settingsFilled = true; + } + $("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("dgbRecovery").innerHTML = ""; + } else if (chain() === "btc") { + if (!settingsFilled) { + fillFamilyPicker("Btc", s); + settingsFilled = true; + } + $("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("btcRecovery").innerHTML = ""; + } else if (chain() === "eth") { + if (!settingsFilled) { + $("setEthRpcUrl").value = s.rpcUrl || ""; + settingsFilled = true; + } + $("ethPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("ethRecovery").innerHTML = ""; + } else if (chain() === "sol") { + if (!settingsFilled) { + $("setSolRpcUrl").value = s.rpcUrl || ""; + settingsFilled = true; + } + $("solPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("solRecovery").innerHTML = ""; + } + renderSites(); +} +async function renderSites() { + let perms = {}; + try { perms = await S.invoke("permissions"); } catch {} + const origins = Object.keys(perms).filter((o) => { + const p = perms[o]; + return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress)); + }); + const el = $("sites"); + if (!origins.length) { el.innerHTML = `
None yet.
`; return; } + el.innerHTML = origins.map((o) => { + const p = perms[o]; const what = []; + if (p.readAddress) what.push("BCH address"); + if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`); + if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address"); + return `
${esc(o)}
${esc(what.join(" · "))}
`; + }).join(""); + el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => { + try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {} + })); +} + +$("applySettings").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + const path = $("setPath").value.trim(); + const servers = $("setServers").value.split(/\n+/).map((s) => s.trim()).filter(Boolean); + if (path && path !== (sel().accountPath || "")) { + state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: path }); + } + if (sel().network === "mainnet") { + const currentJoined = (state.bchServers?.list || []).join(); + if (state.bchServers?.custom || servers.join() !== currentJoined) { + state = await S.invoke("setBchServers", { servers }); + } + } + 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("setBchServers", { servers: [] }); settingsFilled = false; fillSettings(); render(); } + catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); +$("renameBtn").addEventListener("click", async () => { + const label = $("renameLabel").value.trim(); + if (!label) return; + try { state = await S.invoke("renameWallet", { id: state.selectedWalletId, label }); render(); flash($("renameBtn"), "Renamed"); } + catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); +$("removeBtn").addEventListener("click", async () => { + const s = sel(); if (!s || s.isLegacy) return; + if (!confirm(`Remove the wallet "${s.label}"?\n\nThe on-chain address stays; the wallet is unlinked from Aegis. You can add it back later by creating a new wallet on the same coin + network.`)) return; + try { state = await S.invoke("removeWallet", { id: state.selectedWalletId }); settingsFilled = false; render(); } + catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); +$("showXpub").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); } + catch (e) { $("recovery").textContent = cleanErr(e); } +}); +$("showXprv").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } + catch (e) { $("recovery").textContent = cleanErr(e); } +}); +function recoveryHtml(r) { + let h = `
Account path
${esc(r.accountPath)}
Account xpub
${esc(r.xpub)}
`; + if (r.xprv) h += `
Account private key (xprv)
${esc(r.xprv)}
`; + return h; +} +document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => { + if (b.dataset.tab !== "settings") { + $("recovery").innerHTML = ""; + $("scRecovery").innerHTML = ""; + $("dgbRecovery").innerHTML = ""; + $("btcRecovery").innerHTML = ""; + $("ethRecovery").innerHTML = ""; + $("solRecovery").innerHTML = ""; + } +})); + +// Sia-specific settings. +$("applyWalletdUrl").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setWalletdUrl", { id: state.selectedWalletId, walletdUrl: $("setWalletdUrl").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("showScSeed").addEventListener("click", async () => { + try { + const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); + $("scRecovery").innerHTML = + `
First address (index 0)
${esc(r.xpub || "")}
` + + (r.xprv ? `
Wallet seed (hex)
${esc(r.xprv)}
` : ""); + } catch (e) { $("scRecovery").textContent = cleanErr(e); } +}); + +// Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc": +// the DOM IDs are #setFamily + #setPath. +function fillFamilyPicker(prefix, s) { + const families = s.meta?.addressFamilies || []; + const current = String(s.accountPath || ""); + let currentId = families.find((f) => f.defaultAccountPath === current)?.id; + if (!currentId) { + const m = /^m\/(\d+)'/.exec(current); + const purpose = m ? Number(m[1]) : null; + currentId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id; + } + $(`set${prefix}Path`).value = current || families[0]?.defaultAccountPath || ""; + $(`set${prefix}Family`).innerHTML = families.map((f) => + `` + ).join(""); +} +// Any family select → auto-fill the sibling path input. +document.addEventListener("change", (e) => { + const t = e.target; + if (!t) return; + const m = /^set(Dgb|Btc)Family$/.exec(t.id || ""); + if (!m) return; + const opt = t.options[t.selectedIndex]; + if (opt && opt.dataset.path) $(`set${m[1]}Path`).value = opt.dataset.path; +}); +$("applyDgbPath").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setDgbPath").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyDgbPath"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("applyBtcPath").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setBtcPath").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyBtcPath"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("showBtcXpub").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("btcRecovery").textContent = cleanErr(e); } +}); +$("showBtcXprv").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("btcRecovery").textContent = cleanErr(e); } +}); + +// ETH / SOL: RPC URL. +$("applyEthRpc").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setEthRpcUrl").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyEthRpc"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("applySolRpc").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setSolRpcUrl").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("showEthKey").addEventListener("click", async () => { + try { + const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); + $("ethRecovery").innerHTML = + `
Address
${esc(sel().address || "")}
` + + `
Public key (uncompressed hex)
${esc(r.xpub || "")}
` + + (r.xprv ? `
Private key (hex)
${esc(r.xprv)}
` : ""); + } catch (e) { $("ethRecovery").textContent = cleanErr(e); } +}); +$("showSolKey").addEventListener("click", async () => { + try { + const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); + $("solRecovery").innerHTML = + `
Address (public key, base58)
${esc(r.xpub || "")}
` + + (r.xprv ? `
Wallet seed (hex, 32 bytes)
${esc(r.xprv)}
` : ""); + } catch (e) { $("solRecovery").textContent = cleanErr(e); } +}); +$("showDgbXpub").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("dgbRecovery").textContent = cleanErr(e); } +}); +$("showDgbXprv").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("dgbRecovery").textContent = cleanErr(e); } +}); + +// ---- boot ------------------------------------------------------------------ +S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); }); +(async () => { + try { state = await S.invoke("state"); render(); } + catch (e) { $("gate").hidden = false; $("gate").innerHTML = `
⚠
${esc(cleanErr(e))}
`; } +})(); diff --git a/qr.js b/qr.js new file mode 100644 index 0000000..a0d2946 --- /dev/null +++ b/qr.js @@ -0,0 +1,218 @@ +// Minimal QR encoder for the receive tab: byte mode, versions 1-10, error +// correction M (falls back to L when M won't fit). Returns a boolean matrix. +// Loaded as a plain script in panel.html and as a CommonJS module in tests. +(function (root) { + const EC_LEVELS = { L: 1, M: 0 }; + // Per version: [totalCodewords, {L:[ecPerBlock, [[blocks, dataCw], ...]], M:[...]}] + const TABLE = { + 1: [26, { L: [7, [[1, 19]]], M: [10, [[1, 16]]] }], + 2: [44, { L: [10, [[1, 34]]], M: [16, [[1, 28]]] }], + 3: [70, { L: [15, [[1, 55]]], M: [26, [[1, 44]]] }], + 4: [100, { L: [20, [[1, 80]]], M: [18, [[2, 32]]] }], + 5: [134, { L: [26, [[1, 108]]], M: [24, [[2, 43]]] }], + 6: [172, { L: [18, [[2, 68]]], M: [16, [[4, 27]]] }], + 7: [196, { L: [20, [[2, 78]]], M: [18, [[4, 31]]] }], + 8: [242, { L: [24, [[2, 97]]], M: [22, [[2, 38], [2, 39]]] }], + 9: [292, { L: [30, [[2, 116]]], M: [22, [[3, 36], [2, 37]]] }], + 10: [346, { L: [18, [[2, 68], [2, 69]]], M: [26, [[4, 43], [1, 44]]] }], + }; + const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] }; + const REMAINDER = { 1: 0, 2: 7, 3: 7, 4: 7, 5: 7, 6: 7, 7: 0, 8: 0, 9: 0, 10: 0 }; + + // GF(256) with the QR polynomial 0x11d. + const EXP = new Uint8Array(512), LOG = new Uint8Array(256); + (function () { + let x = 1; + for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } + for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; + })(); + const gmul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0; + function generator(n) { + let g = [1]; + for (let i = 0; i < n; i++) { + const next = new Array(g.length + 1).fill(0); + for (let j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= gmul(g[j], EXP[i]); } + g = next; + } + return g; + } + function rsEncode(data, nEc) { + const g = generator(nEc); + const out = new Uint8Array(data.length + nEc); + out.set(data); + for (let i = 0; i < data.length; i++) { + const c = out[i]; + if (c) for (let j = 1; j < g.length; j++) out[i + j] ^= gmul(g[j], c); + } + return out.slice(data.length); + } + + function dataCapacity(v, ec) { return TABLE[v][1][ec][1].reduce((a, [b, d]) => a + b * d, 0); } + function pickVersion(len) { + for (const ec of ["M", "L"]) { + for (let v = 1; v <= 10; v++) { + const bitsNeeded = 4 + (v <= 9 ? 8 : 16) + len * 8; + if (bitsNeeded <= dataCapacity(v, ec) * 8) return { v, ec }; + } + } + throw new Error("qr: text too long"); + } + + function encodeData(bytes, v, ec) { + const cap = dataCapacity(v, ec); + const bits = []; + const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); }; + push(0b0100, 4); + push(bytes.length, v <= 9 ? 8 : 16); + for (const b of bytes) push(b, 8); + for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0); + while (bits.length % 8) bits.push(0); + const data = new Uint8Array(cap); + for (let i = 0; i < bits.length / 8; i++) data[i] = parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2); + for (let i = bits.length / 8, k = 0; i < cap; i++, k++) data[i] = k % 2 ? 0x11 : 0xec; + return data; + } + + function interleave(data, v, ec) { + const [nEc, groups] = TABLE[v][1][ec]; + const blocks = []; let o = 0; + for (const [count, size] of groups) for (let i = 0; i < count; i++) { blocks.push(data.slice(o, o + size)); o += size; } + const ecBlocks = blocks.map((b) => rsEncode(b, nEc)); + const out = []; + const maxLen = Math.max(...blocks.map((b) => b.length)); + for (let i = 0; i < maxLen; i++) for (const b of blocks) if (i < b.length) out.push(b[i]); + for (let i = 0; i < nEc; i++) for (const b of ecBlocks) out.push(b[i]); + return out; + } + + // Remainder of value·x^degree modulo the generator poly (BCH error correction + // for the format / version fields). + function bch(value, poly, degree) { + let v = value << degree; + for (let i = 31 - Math.clz32(v); i >= degree; i--) if ((v >> i) & 1) v ^= poly << (i - degree); + return v; + } + const formatBits = (ec, mask) => { const d = (EC_LEVELS[ec] << 3) | mask; return ((d << 10) | bch(d, 0x537, 10)) ^ 0x5412; }; + const versionBits = (v) => (v << 12) | bch(v, 0x1f25, 12); + + function build(text) { + const bytes = typeof text === "string" ? new TextEncoder().encode(text) : Uint8Array.from(text); + const { v, ec } = pickVersion(bytes.length); + const size = v * 4 + 17; + const codewords = interleave(encodeData(bytes, v, ec), v, ec); + const grid = Array.from({ length: size }, () => new Uint8Array(size)); // 1 dark, 0 light + const fixed = Array.from({ length: size }, () => new Uint8Array(size)); // function pattern / reserved + const set = (r, c, val) => { grid[r][c] = val ? 1 : 0; fixed[r][c] = 1; }; + const finder = (r0, c0) => { + for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) { + const rr = r0 + r, cc = c0 + c; + if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue; + const inner = r >= 0 && r <= 6 && c >= 0 && c <= 6; + const dark = inner && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)); + set(rr, cc, dark); + } + }; + finder(0, 0); finder(0, size - 7); finder(size - 7, 0); + for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); } + const al = ALIGN[v], last = al.length - 1; + al.forEach((r, ri) => al.forEach((c, ci) => { + // The three corners that would sit on a finder pattern are omitted. + if ((ri === 0 && ci === 0) || (ri === 0 && ci === last) || (ri === last && ci === 0)) return; + for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) { + set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1); + } + })); + set(size - 8, 8, 1); // dark module + // Reserve format areas (filled per mask below) and version areas. + for (let i = 0; i < 9; i++) { fixed[8][i] = 1; fixed[i][8] = 1; } + for (let i = 0; i < 8; i++) { fixed[8][size - 1 - i] = 1; fixed[size - 1 - i][8] = 1; } + if (v >= 7) { + const vb = versionBits(v); + for (let i = 0; i < 18; i++) { + const bit = (vb >> i) & 1; + set(Math.floor(i / 3), size - 11 + (i % 3), bit); + set(size - 11 + (i % 3), Math.floor(i / 3), bit); + } + } + // Zigzag data placement. + const bits = []; + for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1); + for (let i = 0; i < REMAINDER[v]; i++) bits.push(0); + let bi = 0, up = true; + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col--; + for (let k = 0; k < size; k++) { + const r = up ? size - 1 - k : k; + for (const c of [col, col - 1]) { + if (fixed[r][c]) continue; + grid[r][c] = bi < bits.length ? bits[bi] : 0; + bi++; + } + } + up = !up; + } + // Try every mask, keep the lowest penalty. + const MASKS = [ + (i, j) => (i + j) % 2 === 0, (i) => i % 2 === 0, (_i, j) => j % 3 === 0, (i, j) => (i + j) % 3 === 0, + (i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0, (i, j) => ((i * j) % 2) + ((i * j) % 3) === 0, + (i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0, (i, j) => (((i + j) % 2) + ((i * j) % 3)) % 2 === 0, + ]; + let best = null; + for (let m = 0; m < 8; m++) { + const g = grid.map((row) => Uint8Array.from(row)); + for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!fixed[r][c] && MASKS[m](r, c)) g[r][c] ^= 1; + const fb = formatBits(ec, m); + for (let i = 0; i < 15; i++) { + const bit = (fb >> i) & 1; + // Copy 1: down column 8 (bits 0-7, skipping the timing row), then + // left along row 8 (bits 8-14). Copy 2: right end of row 8, bottom + // of column 8. + if (i < 6) g[i][8] = bit; else if (i === 6) g[7][8] = bit; else if (i === 7) g[8][8] = bit; + else if (i === 8) g[8][7] = bit; else g[8][14 - i] = bit; + if (i < 8) g[8][size - 1 - i] = bit; else g[size - 15 + i][8] = bit; + } + const p = penalty(g, size); + if (!best || p < best.p) best = { g, p, m }; + } + return { size, version: v, ec, mask: best.m, modules: best.g.map((row) => Array.from(row, (x) => !!x)) }; + } + + function penalty(g, n) { + let p = 0; + const runs = (get) => { + for (let a = 0; a < n; a++) { + let run = 1; + for (let b = 1; b <= n; b++) { + if (b < n && get(a, b) === get(a, b - 1)) run++; + else { if (run >= 5) p += 3 + run - 5; run = 1; } + } + } + }; + runs((a, b) => g[a][b]); runs((a, b) => g[b][a]); + for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) { + const s = g[r][c] + g[r][c + 1] + g[r + 1][c] + g[r + 1][c + 1]; + if (s === 0 || s === 4) p += 3; + } + const pat = [1, 0, 1, 1, 1, 0, 1]; + const finderLike = (get, a) => { + for (let b = 0; b <= n - 7; b++) { + let ok = true; + for (let k = 0; k < 7; k++) if (get(a, b + k) !== pat[k]) { ok = false; break; } + if (!ok) continue; + const before = b >= 4 && [0, 1, 2, 3].every((k) => get(a, b - 1 - k) === 0); + const after = b + 10 < n && [0, 1, 2, 3].every((k) => get(a, b + 7 + k) === 0); + if (before || after) p += 40; + } + }; + for (let a = 0; a < n; a++) { finderLike((x, y) => g[x][y], a); finderLike((x, y) => g[y][x], a); } + let dark = 0; + for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += g[r][c]; + const pct = (dark * 100) / (n * n); + p += Math.floor(Math.abs(pct - 50) / 5) * 10; + return p; + } + + const api = { build }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.QR = api; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/wallet-inject.js b/wallet-inject.js new file mode 100644 index 0000000..ae4d1f2 --- /dev/null +++ b/wallet-inject.js @@ -0,0 +1,506 @@ +// Aegis dapp bridges. Runs in the isolated world of every matching tab and +// puts up to two dapp APIs on the page's main world: +// +// window.bitcoincash Silent Mode's own BCH API (only on .x pages — +// matches the pre-multi-wallet gate). +// window.tronWeb TronLink-shaped Tron dapp bridge (on any page). +// window.tronLink TronLink shim; `tron_requestAccounts` triggers +// an approval overlay via activate(). +// +// Everything crosses into the wallet's activate() context in main, which +// shows the approval overlay and enforces per-origin permissions. Nothing +// here can sign, read a balance, or read an address on its own. +// +// `theseus` is provided by the host: { id, origin, contextBridge, invoke }. + +const call = (msg, payload) => + theseus.invoke(msg, payload).catch((e) => { + const text = String(e && e.message || e).replace(/^Error invoking remote method '[^']+': (Error: )?/, ""); + throw new Error(text); + }); + +// -------- BCH bridge (window.bitcoincash), only on .x pages ------------------ +const isBchOrigin = (() => { try { return /\.x$/i.test(location.hostname); } catch { return false; } })(); +if (isBchOrigin) { + theseus.contextBridge.exposeInMainWorld("bitcoincash", { + isTheseus: true, + version: "0.2.0", + network: "mainnet", + getAddress: () => call("getAddress"), + signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}), + signMessage: (message) => call("signMessage", { message: String(message ?? "") }), + }); +} + +// -------- Tron bridge (window.tronWeb, window.tronLink), everywhere --------- +// +// Matches enough of the TronLink surface for read + send flows on dapps like +// tronscan, sunswap, and justlend (mainnet or Nile). Not implemented: +// TRC20/TRC721 abstractions, address utilities beyond base58<->hex, event +// emitters beyond the "message" events TronLink documents. + +let CACHED = { address: null, network: null }; +const listeners = new Set(); + +// Public copy of tronWeb.trx. +const trx = { + // Returns the account object TronGrid gives (may be {} for never-funded). + // Deliberately mirrors tronWeb.trx.getAccount. + getAccount: async () => call("trx.getAccount"), + // Sign an assembled transaction (dapp built it via its own tronWeb). + // Returns the same object with a `signature` field populated. + sign: async (transaction) => { + if (!transaction || typeof transaction !== "object" || !transaction.raw_data_hex) { + throw new Error("tronWeb.trx.sign: expected an assembled transaction with raw_data_hex"); + } + return call("trx.signTransaction", { transaction }); + }, + // Broadcast a signed transaction. Returns { result: true, txid } on success. + sendRawTransaction: async (signedTx) => { + const r = await call("trx.sendRawTransaction", { transaction: signedTx }); + return { result: true, txid: r.txid }; + }, + // Convenience wrappers matching the tronWeb surface — dapps call these + // when the wallet is supposed to build the tx too. Left unimplemented + // in this build so a dapp that hits them gets a clear error instead of + // silently unassembled behavior; assemble on the dapp side. + sendTrx: async () => { throw new Error("tronWeb.trx.sendTrx not implemented — build the transaction with tronWeb.transactionBuilder and call sign+sendRawTransaction"); }, + // TronLink documents signMessage (legacy, keccak of just the message) and + // signMessageV2 (adds the "\x19TRON Signed Message:\n" prefix). We + // only implement V2 because that is what modern dapps use. + signMessageV2: async (message) => { + const r = await call("trx.signMessageV2", { message: String(message ?? "") }); + return r.signature; + }, +}; + +// Shallow tronWeb — enough for `tronWeb.defaultAddress.base58` / `.hex` reads +// and the trx methods. Populated once the user connects. +const tronWeb = { + ready: false, + defaultAddress: { base58: false, hex: false, name: false }, + fullNode: { host: "" }, + solidityNode: { host: "" }, + eventServer: { host: "" }, + trx, + // Utility helpers a few dapps poke at. + isConnected: () => tronWeb.ready === true && !!tronWeb.defaultAddress.base58, + address: { + fromHex: (hex) => hex, // no-op stub; dapps that need a real + toHex: (base58) => base58, // conversion should use their own copy. + }, + // Some dapps read this to decide "Tron mainnet vs testnet". + get currentNetwork() { return CACHED.network || null; }, +}; + +function nodeHost(network) { + return network === "nile" ? "https://nile.trongrid.io" : "https://api.trongrid.io"; +} +function applyConnection({ address, network }) { + CACHED = { address, network }; + tronWeb.defaultAddress = { base58: address || false, hex: false, name: false }; + tronWeb.ready = !!address; + tronLink.ready = !!address; + const host = nodeHost(network); + tronWeb.fullNode = { host }; + tronWeb.solidityNode = { host }; + tronWeb.eventServer = { host }; + // Broadcast on the window in the way TronLink documents. + try { + window.postMessage({ message: { action: "accountsChanged", data: { address } }, isTronLink: true }, location.origin); + window.postMessage({ message: { action: "setAccount", data: { address } }, isTronLink: true }, location.origin); + window.postMessage({ message: { action: "setNode", data: { node: { fullNode: host, solidityNode: host, eventServer: host, chain: network === "nile" ? "0xcd8690dc" : "0x2b6653dc" } } }, isTronLink: true }, location.origin); + } catch {} + for (const fn of listeners) { try { fn({ address, network }); } catch {} } +} + +const tronLink = { + ready: false, + tronWeb: tronWeb, + // TronLink's canonical request router. Handles at minimum tron_requestAccounts. + request: async (opts) => { + const method = String(opts?.method || ""); + if (method === "tron_requestAccounts" || method === "requestAccounts") { + const r = await call("trx.requestAccounts"); + applyConnection({ address: r.address, network: r.network }); + return { code: 200, message: "ok" }; + } + if (method === "tron_accounts") { + const r = await call("trx.getAccount").catch(() => null); + return r ? [r.address] : []; + } + throw new Error("Aegis: unsupported tronLink method " + method); + }, + // Dapps sometimes attach here to react to wallet changes. + on: (event, fn) => { + if (event === "message" || event === "accountsChanged" || event === "networkChanged") listeners.add(fn); + }, + removeListener: (_event, fn) => listeners.delete(fn), +}; + +theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb); +theseus.contextBridge.exposeInMainWorld("tronLink", tronLink); + +// -------- Main-world bridges (window.ethereum, window.solana) --------------- +// +// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet +// object to touch the dapp's own JS values — Solana in particular passes +// `Transaction` instances whose `.serializeMessage()` method the wallet has +// to call. Electron's contextBridge shallow-copies function arguments +// between worlds and strips methods, so bridges that need to call methods +// on dapp-side objects have to live in the main world. We inject a `