diff --git a/addons-host.js b/addons-host.js index 46f5e37..658c4bc 100644 --- a/addons-host.js +++ b/addons-host.js @@ -136,7 +136,19 @@ function validateManifest(raw, folderName) { items: cleanItems, }; } - return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu }; + // `absorbs`: legacy add-on ids whose vault-derive namespace this add-on + // inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so + // funds derived under the old id's paths stay reachable through the new + // one. Each entry is validated as an id itself and gates vault.derive by + // (own id OR one of these) in makeApi below. + const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : []; + for (const a of absorbs) { + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) { + throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`); + } + if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`); + } + return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, absorbs }; } // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest @@ -377,9 +389,11 @@ class AddonHost { if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload); }, // vault-derive: a 32-byte HKDF child of the password vault's root, - // keyed by a path that MUST start with this add-on's id so one add-on - // can never ask for another's material. Resolves only once the user - // has unlocked the vault (main polls; the await can be long). + // keyed by a path that MUST start with this add-on's id — or one of + // the ids it declared under `absorbs` in addon.json, so a superseding + // add-on can keep deriving the same keys as the add-on it replaced + // (funds stay reachable across the transition). Resolves only once + // the user has unlocked the vault (main polls; the await can be long). vault: { derive: async (purposePath) => { if (!manifest.capabilities.includes("vault-derive")) { @@ -387,9 +401,16 @@ class AddonHost { } if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`); const p = String(purposePath || ""); - if (!p.startsWith(manifest.id + "/") || /[^a-z0-9/._-]/i.test(p) || p.includes("..")) { + if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) { throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); } + const allowed = [manifest.id, ...(manifest.absorbs || [])]; + if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) { + const list = allowed.length > 1 + ? `one of "${allowed.join('", "')}"` + : `"${manifest.id}"`; + throw new Error(`vault.derive: purposePath must start with ${list} + "/"`); + } return this._vaultDerive(p, manifest.id); }, }, diff --git a/approval.html b/approval.html index f279ade..f7088f4 100644 --- a/approval.html +++ b/approval.html @@ -11,7 +11,7 @@ :root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12); --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; /* Darker acid for light backgrounds — ~5.5:1 on white. */ - --acid: #3a5c00; } + --acid: #088A66; } } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; background: transparent; } diff --git a/bundled-addons/bchwallet/addon.json b/bundled-addons/bchwallet/addon.json index 09d39ab..3644ec5 100644 --- a/bundled-addons/bchwallet/addon.json +++ b/bundled-addons/bchwallet/addon.json @@ -1,12 +1,13 @@ { "id": "bchwallet", "name": "Aegis Wallet", - "version": "0.2.0", - "description": "Multi-chain wallet (BCH, Tron mainnet, Tron Nile testnet) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites and window.tronWeb / window.tronLink on any https page.", + "version": "0.3.0", + "description": "Multi-chain wallet (BCH mainnet/chipnet, Tron mainnet/Nile, Siacoin, DigiByte) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites and window.tronWeb / window.tronLink 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/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index 6f3f791..a4b05fa 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -26,9 +26,11 @@ let ctx = null; 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"); @@ -42,7 +44,38 @@ async function loadDeps(api) { const tronAdapter = require("./lib/chain-tron.js")({ HDKey, secp256k1, sha256, keccak_256, base58check, }); - return { HDKey, secp256k1, sha256, ripemd160, keccak_256, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter }; + 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 --------------------------------------------------------------- @@ -57,39 +90,277 @@ function bchServerList(api) { } // ---- chain registry -------------------------------------------------------- -// Chain-side facts that don't depend on state. Adding a chain = extending this -// map + writing an adapter that constructs a wallet from a 32-byte root. -const CHAIN_REGISTRY = { - "bch:mainnet": { - chain: "bch", network: "mainnet", - label: "Bitcoin Cash", short: "BCH", ticker: "BCH", decimals: 8, - badge: "🟨", color: "#0ac18e", - purposePrefix: "bchwallet/bch/", // legacy wallet uses the flat "bchwallet/mainnet/0" instead - startIndex: 1, // 0 is reserved for the legacy wallet +// 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:mainnet": { - chain: "trx", network: "mainnet", - label: "Tron", short: "TRX", ticker: "TRX", decimals: 6, - badge: "🔴", color: "#ff060a", - purposePrefix: "bchwallet/trx/mainnet/", - 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, + }, + }, }, - "trx:nile": { - chain: "trx", network: "nile", - label: "Tron Nile testnet", short: "TRX (Nile)", ticker: "TRX", decimals: 6, - badge: "🔵", color: "#4d9dff", - purposePrefix: "bchwallet/trx/nile/", - startIndex: 0, + sc: { + chain: "sc", + label: "Siacoin", + short: "SC", + ticker: "SC", + decimals: 24, + color: "#20be82", + logo: "sc", supportsMessageSign: true, - supportsPageInject: 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}`; } -function chainMeta(chain, network) { return CHAIN_REGISTRY[chainKey(chain, network)] || null; } +// 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 ------------------------------------------------------------ @@ -186,12 +457,16 @@ async function mountWallet(entry) { 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), - servers: bchServerList(c.api), + network: entry.network, + servers, accountPath: entry.accountPath, }); } else if (entry.chain === "trx") { @@ -201,6 +476,59 @@ async function mountWallet(entry) { 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}`); } @@ -255,8 +583,9 @@ function walletSummary(w) { 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, - badge: meta?.badge || "🧩", color: meta?.color || "#888", ticker: meta?.ticker || "?", - short: meta?.short || w.chain, decimals: meta?.decimals || 8, + 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", @@ -276,7 +605,11 @@ function snapshotForSelected() { chain: entry?.chain, network: entry?.network, isLegacy: !!entry?.isLegacy, - meta: meta ? { badge: meta.badge, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals } : null, + 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, @@ -295,9 +628,7 @@ function fullState() { list: bchServerList(ctx.api), custom: Array.isArray(ctx.api.storage.get("servers", null)), }, - chains: Object.entries(CHAIN_REGISTRY).map(([k, m]) => ({ - key: k, chain: m.chain, network: m.network, label: m.label, short: m.short, ticker: m.ticker, badge: m.badge, decimals: m.decimals, - })), + coins: coinsForPanel(), }; } @@ -329,6 +660,18 @@ 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(); }); @@ -347,12 +690,26 @@ function registerPanelMessages(api) { const meta = chainMeta(chain, network); if (!meta) throw new Error("unknown chain/network"); const list = walletEntries().slice(); - const index = nextIndex(list, meta); - const purpose = meta.purposePrefix + index; - const id = makeWalletId(meta, index); + // 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, createdAt: Date.now() }; + const entry = { id, label, chain, network, purpose, isLegacy, createdAt: Date.now() }; list.push(entry); writeWallets(api, list); api.storage.set("selectedWalletId", id); @@ -423,19 +780,64 @@ function registerPanelMessages(api) { const id = String(patch.id || selectedWalletId()); const entry = walletEntries().find((w) => w.id === id); if (!entry) throw new Error("unknown wallet"); - if (entry.chain !== "bch") throw new Error("account path is a BCH-only setting"); + 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/44'/145'/0'"); + 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); - list[idx] = { ...list[idx], accountPath: v || "m/44'/145'/0'" }; + // 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); - // Rebuild the wallet's adapter with the new account path. 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) => { @@ -444,6 +846,42 @@ function registerPanelMessages(api) { 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); @@ -456,7 +894,7 @@ function registerPanelMessages(api) { { 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: `${meta.badge} ${rt.entry.label}` }, + { label: "Wallet", value: `${rt.entry.label} — ${meta.coinLabel} · ${meta.networkLabel}` }, ]; const pick = await api.approvalModal({ title: `Send ${meta.ticker}?`, @@ -472,14 +910,16 @@ function registerPanelMessages(api) { fromPanel(m); const id = String(p && p.id || selectedWalletId()); const rt = requireWallet(id); - if (rt.entry.chain !== "bch") throw new Error("recovery details are only exposed for BCH wallets in this build"); + 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: "Reveal the account private key?", + title: rt.entry.chain === "sc" ? "Reveal the wallet seed?" : "Reveal the account private key?", origin: "Aegis wallet panel", - body: "Anyone holding this key can spend every coin in this wallet. It stays on screen until you close the Settings tab.", + 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; @@ -662,7 +1102,7 @@ function registerPageMessages(api) { rows: [ { label: "Address", value: snap.address, mono: true }, { label: "Network", value: snap.network === "nile" ? "Nile testnet" : "Tron mainnet" }, - { label: "Wallet", value: `🔴 ${rt.entry.label}` }, + { 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" }, @@ -706,7 +1146,7 @@ function registerPageMessages(api) { 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} (${rt.adapter.snapshot().network})` }); + 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, @@ -755,6 +1195,429 @@ function registerPageMessages(api) { 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 --------------------------------------------------------------- diff --git a/bundled-addons/bchwallet/lib/chain-bch.js b/bundled-addons/bchwallet/lib/chain-bch.js index 89b3dae..cdddf39 100644 --- a/bundled-addons/bchwallet/lib/chain-bch.js +++ b/bundled-addons/bchwallet/lib/chain-bch.js @@ -6,15 +6,42 @@ // 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, }) { - const NETWORK = "mainnet"; - const PREFIX = "bitcoincash"; - const DEFAULT_ACCOUNT_PATH = "m/44'/145'/0'"; - const EXPLORER_TX = "https://blockchair.com/bitcoin-cash/transaction/"; - const EXPLORER_ADDR = "https://blockchair.com/bitcoin-cash/address/"; // storage is the FULL api.storage. keyPrefix scopes every read/write under // "wallets//…" so multiple BCH wallets don't stomp each other. @@ -29,20 +56,27 @@ module.exports = function makeBchAdapter({ class BchWallet { constructor(root32, { walletId, storage, log = () => {}, onChange = () => {}, servers, - accountPath = DEFAULT_ACCOUNT_PATH, + 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 = NETWORK; + this.network = net.id; + this._net = net; this.log = log; this.onChange = onChange; this.storage = scopedStorage(storage, `wallets/${walletId}/`); - this._servers = Array.isArray(servers) && servers.length ? servers : []; - this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : DEFAULT_ACCOUNT_PATH; + // 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, PREFIX); + this._keys = new keysLib.WalletKeys(root32, this._accountPath, net.prefix); this._root = new Uint8Array(root32); const walletFactory = require("./wallet.js"); this._wallet = walletFactory({ @@ -54,7 +88,7 @@ module.exports = function makeBchAdapter({ } setServers(list) { - this._servers = Array.isArray(list) && list.length ? list : []; + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); this._client.setServers(this._servers); } @@ -64,7 +98,7 @@ module.exports = function makeBchAdapter({ const w = this._wallet.snapshot(); return { chain: "bch", - network: NETWORK, + network: this._net.id, ticker: "BCH", decimals: 8, address: w.address, @@ -79,9 +113,9 @@ module.exports = function makeBchAdapter({ servers: this._servers, accountPath: this._accountPath, xpub: this._keys.xpub, - explorerTx: EXPLORER_TX, - explorerAddr: EXPLORER_ADDR, - faucet: null, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, }; } @@ -118,5 +152,5 @@ module.exports = function makeBchAdapter({ } } - return { BchWallet, DEFAULT_ACCOUNT_PATH, PREFIX, EXPLORER_TX, EXPLORER_ADDR }; + return { BchWallet, BCH_NETWORKS }; }; diff --git a/bundled-addons/bchwallet/lib/chain-btc.js b/bundled-addons/bchwallet/lib/chain-btc.js new file mode 100644 index 0000000..a21525b --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/chain-dgb.js b/bundled-addons/bchwallet/lib/chain-dgb.js new file mode 100644 index 0000000..85d32f4 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/chain-eth.js b/bundled-addons/bchwallet/lib/chain-eth.js new file mode 100644 index 0000000..a5c9bfd --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/chain-sia.js b/bundled-addons/bchwallet/lib/chain-sia.js new file mode 100644 index 0000000..dac88ea --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/chain-sol.js b/bundled-addons/bchwallet/lib/chain-sol.js new file mode 100644 index 0000000..8d47829 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/address.d.ts b/bundled-addons/bchwallet/lib/dgb/core/address.d.ts new file mode 100644 index 0000000..fd2b706 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/address.js b/bundled-addons/bchwallet/lib/dgb/core/address.js new file mode 100644 index 0000000..f41fad5 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/descriptor.d.ts b/bundled-addons/bchwallet/lib/dgb/core/descriptor.d.ts new file mode 100644 index 0000000..b5de22f --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/descriptor.js b/bundled-addons/bchwallet/lib/dgb/core/descriptor.js new file mode 100644 index 0000000..e904254 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/hd.d.ts b/bundled-addons/bchwallet/lib/dgb/core/hd.d.ts new file mode 100644 index 0000000..6c32856 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/hd.js b/bundled-addons/bchwallet/lib/dgb/core/hd.js new file mode 100644 index 0000000..d0b6f19 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/index.d.ts b/bundled-addons/bchwallet/lib/dgb/core/index.d.ts new file mode 100644 index 0000000..14d0835 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/index.js b/bundled-addons/bchwallet/lib/dgb/core/index.js new file mode 100644 index 0000000..b9669ee --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/network.d.ts b/bundled-addons/bchwallet/lib/dgb/core/network.d.ts new file mode 100644 index 0000000..10e1007 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/network.js b/bundled-addons/bchwallet/lib/dgb/core/network.js new file mode 100644 index 0000000..b874df8 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/package.json b/bundled-addons/bchwallet/lib/dgb/core/package.json new file mode 100644 index 0000000..ddb9a1b --- /dev/null +++ b/bundled-addons/bchwallet/lib/dgb/core/package.json @@ -0,0 +1 @@ +{"type":"module","main":"./index.js"} diff --git a/bundled-addons/bchwallet/lib/dgb/core/seed.d.ts b/bundled-addons/bchwallet/lib/dgb/core/seed.d.ts new file mode 100644 index 0000000..94c97e3 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/seed.js b/bundled-addons/bchwallet/lib/dgb/core/seed.js new file mode 100644 index 0000000..49f8a26 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/wif.d.ts b/bundled-addons/bchwallet/lib/dgb/core/wif.d.ts new file mode 100644 index 0000000..2d28c17 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/core/wif.js b/bundled-addons/bchwallet/lib/dgb/core/wif.js new file mode 100644 index 0000000..159b093 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/build.d.ts b/bundled-addons/bchwallet/lib/dgb/psbt/build.d.ts new file mode 100644 index 0000000..d962850 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/build.js b/bundled-addons/bchwallet/lib/dgb/psbt/build.js new file mode 100644 index 0000000..1fe9142 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/index.d.ts b/bundled-addons/bchwallet/lib/dgb/psbt/index.d.ts new file mode 100644 index 0000000..e33aca9 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/index.js b/bundled-addons/bchwallet/lib/dgb/psbt/index.js new file mode 100644 index 0000000..26f6293 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/package.json b/bundled-addons/bchwallet/lib/dgb/psbt/package.json new file mode 100644 index 0000000..ddb9a1b --- /dev/null +++ b/bundled-addons/bchwallet/lib/dgb/psbt/package.json @@ -0,0 +1 @@ +{"type":"module","main":"./index.js"} diff --git a/bundled-addons/bchwallet/lib/dgb/psbt/sign.d.ts b/bundled-addons/bchwallet/lib/dgb/psbt/sign.d.ts new file mode 100644 index 0000000..3df7450 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/sign.js b/bundled-addons/bchwallet/lib/dgb/psbt/sign.js new file mode 100644 index 0000000..94a5a2f --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/types.d.ts b/bundled-addons/bchwallet/lib/dgb/psbt/types.d.ts new file mode 100644 index 0000000..33f3542 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/dgb/psbt/types.js b/bundled-addons/bchwallet/lib/dgb/psbt/types.js new file mode 100644 index 0000000..718fd38 --- /dev/null +++ b/bundled-addons/bchwallet/lib/dgb/psbt/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/bundled-addons/bchwallet/lib/eip712.js b/bundled-addons/bchwallet/lib/eip712.js new file mode 100644 index 0000000..a926679 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/sia/keys.js b/bundled-addons/bchwallet/lib/sia/keys.js new file mode 100644 index 0000000..c7260b6 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/sia/sia.js b/bundled-addons/bchwallet/lib/sia/sia.js new file mode 100644 index 0000000..d7e119a --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/sia/wallet.js b/bundled-addons/bchwallet/lib/sia/wallet.js new file mode 100644 index 0000000..a829237 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/sia/walletd.js b/bundled-addons/bchwallet/lib/sia/walletd.js new file mode 100644 index 0000000..da489cd --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/lib/sol-spl.js b/bundled-addons/bchwallet/lib/sol-spl.js new file mode 100644 index 0000000..0b18fa9 --- /dev/null +++ b/bundled-addons/bchwallet/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/bundled-addons/bchwallet/panel.html b/bundled-addons/bchwallet/panel.html index e0c5977..7f0ce7b 100644 --- a/bundled-addons/bchwallet/panel.html +++ b/bundled-addons/bchwallet/panel.html @@ -3,6 +3,7 @@ Aegis Wallet +