aegis/lib/chain-eth.js

440 lines
20 KiB
JavaScript
Raw Normal View History

// 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 };
}
// Accept wei as bigint, integer string, hex string or number.
function toBig(v) {
if (typeof v === "bigint") return v;
const s = String(v ?? "0").trim();
if (/^0x[0-9a-f]+$/i.test(s)) return BigInt(s);
if (/^-?\d+$/.test(s)) return BigInt(s);
return BigInt(Math.round(Number(s) || 0));
}
// Calldata as "0x" + even-length lowercase hex; "" / null / "0x" → "0x".
function normalizeData(data) {
const s = String(data ?? "").trim().toLowerCase();
if (!s || s === "0x") return "0x";
const h = s.startsWith("0x") ? s.slice(2) : s;
if (!/^[0-9a-f]*$/.test(h) || h.length % 2) throw new Error("tx.data must be even-length hex");
return "0x" + h;
}
// ---- calldata decode (overlay only) -----------------------------------
// The handful of selectors behind almost every phishing loss. Anything
// else is shown as "<selector> + N bytes" so the user at least sees that
// it is a contract call they cannot read.
const SELECTORS = {
a9059cbb: { name: "transfer", args: ["to", "amount"] },
"095ea7b3": { name: "approve", args: ["spender", "amount"] },
"23b872dd": { name: "transferFrom", args: ["from", "to", "amount"] },
a22cb465: { name: "setApprovalForAll", args: ["operator", "approved"] },
"39509351": { name: "increaseAllowance", args: ["spender", "amount"] },
d505accf: { name: "permit", args: ["owner", "spender", "value", "deadline"] },
};
const UINT256_MAX = (1n << 256n) - 1n;
function decodeCalldata(dataHex) {
const h = normalizeData(dataHex).slice(2);
if (h.length < 8) return null;
const selector = h.slice(0, 8);
const spec = SELECTORS[selector];
const words = [];
for (let i = 8; i + 64 <= h.length; i += 64) words.push(h.slice(i, i + 64));
const out = { selector, name: spec ? spec.name : null, bytes: h.length / 2 };
if (!spec || words.length < spec.args.length) return out;
spec.args.forEach((a, i) => {
const w = words[i];
if (a === "amount" || a === "value" || a === "deadline") out[a] = BigInt("0x" + w);
else if (a === "approved") out[a] = BigInt("0x" + w) !== 0n;
else out[a] = eip55(w.slice(24));
});
const amt = out.amount ?? out.value;
if (amt != null) out.unlimited = amt >= (1n << 255n) || amt === UINT256_MAX;
return out;
}
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);
// dispose() during an in-flight refresh must not re-arm the poll.
if (this._disposed) return;
this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
}
// Draft a transaction. The panel passes {to, amount, sendMax}; the dapp
// bridge (eth_sendTransaction) additionally passes data, gasLimit, fee
// caps and nonce exactly as the dapp supplied them. Every field the dapp
// set is honoured — the overlay then shows what will really be signed.
// Zero value is fine when there is calldata (ERC-20 transfer/approve).
async plan({ to, amount, sendMax, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, nonce }) {
const dest = decodeAddress(to);
const dataHex = normalizeData(data);
const dataBytes = fromHex(dataHex.slice(2));
const from = this.address.toLowerCase();
const [nonceHex, priorityHex, gasPriceHex] = await Promise.all([
nonce != null ? Promise.resolve(bigToHex(toBig(nonce))) : this._client.call("eth_getTransactionCount", [from, "pending"]),
this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei
this._client.call("eth_gasPrice", []),
]);
const nonceN = Number(hexToBig(nonceHex));
const tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex);
// eth_gasPrice on a 1559 chain already includes a tip, so it is the
// best single-number estimate of what a block will actually charge.
const gasPriceNow = hexToBig(gasPriceHex);
// Cap: dapp-supplied maxFeePerGas (or legacy gasPrice) wins; otherwise
// 2 × current price + tip so the tx survives a base-fee spike.
const maxFee = maxFeePerGas != null ? toBig(maxFeePerGas)
: (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip);
const bal = BigInt(this._state.balance.confirmed || "0");
let value = sendMax ? 0n : toBig(amount);
if (value < 0n) throw new Error("amount must be >= 0 wei");
if (!sendMax && value === 0n && dataBytes.length === 0) throw new Error("amount must be > 0 wei");
// Gas: dapp value if given; 21000 for a plain transfer; otherwise ask
// the node. A revert here surfaces as a clear error BEFORE the overlay,
// which doubles as a cheap "would this even succeed" simulation.
let gas;
if (gasLimit != null) gas = toBig(gasLimit);
else if (dataBytes.length === 0) gas = 21000n;
else {
let est;
try { est = await this._client.call("eth_estimateGas", [{ from, to: dest, value: bigToHex(value), data: dataHex }]); }
catch (e) { throw new Error("transaction would fail (eth_estimateGas): " + (e?.message || e)); }
gas = (hexToBig(est) * 12n) / 10n; // 20% headroom, as MetaMask does
}
if (gas < 21000n) throw new Error("gas limit below 21000");
const feeMax = gas * maxFee;
const feeEstimate = gas * (gasPriceNow < maxFee ? gasPriceNow : maxFee);
if (sendMax) {
if (bal <= feeMax) throw new Error("balance does not cover the gas fee");
value = bal - feeMax;
} else if (value + feeMax > bal) {
throw new Error("insufficient funds");
}
return {
_draft: {
chainId: this._net.chainId, nonce: nonceN, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee,
gasLimit: gas, to: dest, value, data: dataHex, accessList: [],
},
recipients: [{ to: dest, value: value.toString() }],
fee: feeMax.toString(), // worst case — what the balance check uses
feeEstimate: feeEstimate.toString(), // what a block will most likely charge
feeRate: maxFee.toString(),
gasLimit: gas.toString(),
data: dataHex,
inputs: [],
change: "0",
total: (value + feeMax).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(normalizeData(d.data).slice(2)), [],
];
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).
// `message` is the exact bytes to sign (Uint8Array) or a plain string;
// the caller (index.js) is responsible for hex-decoding what dapps send.
signMessage(message) {
const enc = new TextEncoder();
const body = message instanceof Uint8Array ? message : enc.encode(String(message));
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() {
this._disposed = true;
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, decodeCalldata, normalizeData, toBig };
};