diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index 13257c8f..6c19e43f 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -243,15 +243,16 @@ const COINS = { supportsMessageSign: true, supportsPageInject: false, // BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and - // tb1q… / tb1p… / 2… / m/n… on testnet3. Coin type shifts per - // network (0 for mainnet, 1 for testnet — the BIP44 convention). + // 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 }, + coinType: { mainnet: 0, testnet: 1, signet: 1 }, defaultPurpose: 84, networks: { mainnet: { @@ -262,6 +263,10 @@ const COINS = { 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, + }, }, }, }; @@ -1091,6 +1096,229 @@ 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.switchChain", async (p, m) => { + fromPage(m); + const want = String(p && p.chainId || "").toLowerCase(); + const rt = activeEthRuntime(); + const cur = "0x" + Number(rt.adapter.snapshot().chainId).toString(16); + if (want === cur) return null; + throw new Error(`Aegis: switch chains via the Aegis sidebar picker. This wallet is on ${cur}; site asked for ${want}.`); + }); + // 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: page hands us the wallet-adapter Transaction's + // .serializeMessage() output (base64). We sign it + broadcast via the + // adapter's own RPC. The txid returned by broadcast is the wire's txid. + 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 b64 = String(p && p.messageB64 || ""); + const messageBytes = new Uint8Array(Buffer.from(b64, "base64")); + return withOriginLock(origin, async () => { + const snap = rt.adapter.snapshot(); + 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: "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 with the wallet's ed25519 key (Solana signs the + // raw message bytes, no prefix). + const sigInfo = rt.adapter.signMessage(messageBytes); // {signature: base58} + const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature); + if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length"); + // Wire = compact-u16(1) || 64-byte sig || message. Single-signer only + // in this rev: multi-signer flows would need the dapp's other sigs. + const wire = new Uint8Array(1 + 64 + messageBytes.length); + wire[0] = 0x01; + wire.set(sigBytes, 1); + wire.set(messageBytes, 65); + const wireB58 = ctx.d.base58check.encodeBase58(wire); + 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-btc.js b/bundled-addons/bchwallet/lib/chain-btc.js index 539cc2d0..a21525bf 100644 --- a/bundled-addons/bchwallet/lib/chain-btc.js +++ b/bundled-addons/bchwallet/lib/chain-btc.js @@ -36,6 +36,24 @@ const NETWORKS = { ], 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({ @@ -52,10 +70,12 @@ module.exports = function makeBtcAdapter({ // 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. + // 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") return bjsNetworks.testnet; + if (id === "testnet" || id === "signet") return bjsNetworks.testnet; throw new Error("chain-btc: unknown network " + id); } diff --git a/bundled-addons/bchwallet/wallet-inject.js b/bundled-addons/bchwallet/wallet-inject.js index fc921e1a..ad1bed73 100644 --- a/bundled-addons/bchwallet/wallet-inject.js +++ b/bundled-addons/bchwallet/wallet-inject.js @@ -140,3 +140,321 @@ const tronLink = { theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb); theseus.contextBridge.exposeInMainWorld("tronLink", tronLink); + +// -------- Main-world bridges (window.ethereum, window.solana) --------------- +// +// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet +// object to touch the dapp's own JS values — Solana in particular passes +// `Transaction` instances whose `.serializeMessage()` method the wallet has +// to call. Electron's contextBridge shallow-copies function arguments +// between worlds and strips methods, so bridges that need to call methods +// on dapp-side objects have to live in the main world. We inject a `