// 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 }; };