Setup 5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643 Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4 Bundled since 0.3.27: 32d4b75 - Aegis (bchwallet) gains its own update card in Settings > General beside Ariadne. Check for updates hits the same signed OTA endpoint the boot timer uses; Restart to apply appears when a signed newer version is staged. Uses the existing addons-check-updates + a new app-restart IPC. New Aegis versions ship without a Theseus release. 32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to the right of the tab (mode: 'right') instead of a detached window. Matches stock Chrome. Users who prefer detached can drag out via the DevTools own toolbar. b71c925 - Search-engine favicons in Settings > Search now use Google's /s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough hosts (Brave, Bing, Yandex, etc.) that half the list was falling through to the emoji placeholder. Deployed. Verified LIVE 0.3.28.
361 lines
15 KiB
JavaScript
361 lines
15 KiB
JavaScript
// 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 };
|
|
};
|