theseus/bundled-addons/aegis/lib/chain-eth.js
Local Dev cc8a87c5d6 Aegis: dapp overlays show fees and flag risky Solana, Ethereum and Tron calls
Solana: a ComputeBudget price the site added was paid on top of the
base fee but shown as "program ComputeB...: not decoded", so a
transaction could burn the balance in priority fees behind a plain
Sign button. The maximum network fee is now a row, and Assign, durable
nonces, Approve/ApproveChecked, SetAuthority, closing a token account
to someone else and very high fees get a warning and the danger button.
signAndSend also requires one signature slot per required signer.

Ethereum: only allowances of 2^255 and up counted as unlimited; 2^96
and up now does, and Permit2 approve, increaseApproval, NFT
safeTransferFrom and multicall are decoded. The estimate shown was
gas x the node's price even when the site set a far higher tip, which
is what is actually paid; it now uses base fee + the real tip (or the
full legacy gasPrice) and warns when the site's fee is far above the
network's or a fifth of the balance. A personal_sign over 32 raw bytes
is a hash a Safe or an order book will take as approval of something
unseen, so it gets the danger button and the PIN.

Tron: TRC10 sent along with a contract call (call_token_value) was
never read, contract types Aegis does not decode were shown by name as
if harmless, a truncated TRC20 call rendered as "undefined", and the
validity window was hidden. Those are now shown, flagged or refused;
the TronGrid draft check also refuses a memo, a permission id or an
expiration more than a day away.
2026-10-04 03:56:45 +02:00

528 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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));
}
// Legacy (type 0) transaction with EIP-155 replay protection, for chains
// that never adopted EIP-1559 (no baseFeePerGas in their blocks). Such a
// chain rejects a type-2 envelope outright, so a network added through
// wallet_addEthereumChain could receive but never send.
// sign: keccak256(RLP([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]))
// signed: RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s]), v = recid + 35 + 2·chainId
function signTxLegacy({ chainId, nonce, gasPrice, gasLimit, to, value, data }, privKey) {
const base = [nonce, gasPrice, gasLimit, to, value, data];
const hash = keccak_256(rlpEncodeList([...base, chainId, new Uint8Array(0), new Uint8Array(0)]));
const sig = secp256k1.sign(hash, privKey, { prehash: false, lowS: true, format: "recovered" });
const v = BigInt(sig[0]) + 35n + 2n * BigInt(chainId);
return "0x" + toHex(rlpEncodeList([...base, v, stripLeadingZeros(sig.subarray(1, 33)), stripLeadingZeros(sig.subarray(33, 65))]));
}
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"] },
d73dd623: { name: "increaseApproval", args: ["spender", "amount"] },
// Uniswap Permit2 on-chain approve(token, spender, uint160 amount, uint48 expiration)
"87517c45": { name: "permit2Approve", args: ["token", "spender", "amount", "deadline"] },
"42842e0e": { name: "safeTransferFrom", args: ["from", "to", "tokenId"] },
b88d4fde: { name: "safeTransferFrom", args: ["from", "to", "tokenId"] },
f242432a: { name: "safeTransferFrom1155", args: ["from", "to", "tokenId", "amount"] },
"2eb2c2d6": { name: "safeBatchTransferFrom", args: ["from", "to"] },
ac9650d8: { name: "multicall", args: [] },
"5ae401dc": { name: "multicall", args: [] },
};
// An allowance this large is unlimited in all but name: 2^96 base units is
// 79 billion tokens at 18 decimals. Only "≥ 2^255" used to count, so
// approve(spender, 2^200) passed as an ordinary call.
const HUGE_ALLOWANCE = 1n << 96n;
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" || a === "tokenId") 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;
if (amt != null && /^(approve|increaseAllowance|increaseApproval|permit|permit2Approve)$/.test(out.name) && amt >= HUGE_ALLOWANCE) out.unlimited = true;
if (out.name === "permit2Approve" && amt != null && amt >= (1n << 159n)) out.unlimited = true;
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 nonceFixed = nonce != null; // the dapp chose it (e.g. to replace a pending tx)
const [nonceN, legacy, priorityHex, gasPriceHex] = await Promise.all([
nonceFixed ? Promise.resolve(Number(toBig(nonce))) : this._nextNonce(),
this._isLegacyChain(),
this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei
this._client.call("eth_gasPrice", []),
]);
// 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. A chain
// without EIP-1559 has one price and no spike to survive: 10% headroom.
let tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex);
const maxFee = legacy
? (gasPrice != null ? toBig(gasPrice) : (maxFeePerGas != null ? toBig(maxFeePerGas) : (gasPriceNow * 11n) / 10n))
: (maxFeePerGas != null ? toBig(maxFeePerGas) : (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip));
// A tip above the cap is invalid ("max priority fee higher than max
// fee"). It happened whenever a dapp sent only a low gasPrice.
if (tip > maxFee) tip = maxFee;
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;
// What a block will most likely charge. A legacy transaction pays its
// gasPrice in full; a 1559 one pays base fee + its tip, capped. Using
// the node's price here understated a dapp-chosen high tip: the
// overlay read "est. 0.0004" while the whole max was paid.
const priorityNow = hexToBig(priorityHex);
const baseNow = gasPriceNow > priorityNow ? gasPriceNow - priorityNow : gasPriceNow;
const likely = legacy ? maxFee : (baseNow + tip < maxFee ? baseNow + tip : maxFee);
const feeEstimate = gas * likely;
// A fee the site set far above what the network asks for.
let feeWarning = null;
const siteSetFee = maxFeePerGas != null || maxPriorityFeePerGas != null || gasPrice != null;
if (siteSetFee && (likely > gasPriceNow * 3n + 1_000_000_000n || (!legacy && tip > priorityNow * 3n + 2_000_000_000n))) {
feeWarning = "The site set a fee far above what the network currently charges.";
}
if (bal > 0n && feeMax * 5n > bal && feeMax > 0n) {
feeWarning = (feeWarning ? feeWarning + " " : "") + "The fee could be more than a fifth of this wallet's balance.";
}
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: [],
legacy, nonceFixed,
},
txType: legacy ? 0 : 2,
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
feeWarning,
feeRate: maxFee.toString(),
gasLimit: gas.toString(),
data: dataHex,
inputs: [],
change: "0",
total: (value + feeMax).toString(),
};
}
// The next nonce to use. "pending" from a load-balanced public RPC often
// does not know about a transaction this wallet broadcast seconds ago
// (it went to a different backend), so two sends in a row got the same
// nonce and the second either failed or silently replaced the first.
// What this wallet itself broadcast in the last few minutes counts too.
async _nextNonce() {
const rpc = Number(hexToBig(await this._client.call("eth_getTransactionCount", [this.address.toLowerCase(), "pending"])));
const recent = this._lastNonce != null && (Date.now() - this._lastNonceAt) < 180_000;
return recent ? Math.max(rpc, this._lastNonce + 1) : rpc;
}
// Does this chain lack EIP-1559? Decided from the latest block and
// remembered for ten minutes. Unknown (RPC error, no block) keeps the
// type-2 default that Ethereum and every major L2 accept.
async _isLegacyChain() {
if (this._legacyAt && Date.now() - this._legacyAt < 600_000) return this._legacy;
let legacy = false;
try {
const block = await this._client.call("eth_getBlockByNumber", ["latest", false]);
if (block && typeof block === "object") legacy = block.baseFeePerGas == null;
} catch { return false; }
this._legacy = legacy; this._legacyAt = Date.now();
return legacy;
}
async signAndBroadcast(plan) {
const d = plan && plan._draft;
if (!d) throw new Error("bad plan");
// One broadcast at a time per wallet, and the nonce is taken at the
// moment of signing: two approvals answered close together (or a plan
// drawn before an earlier send went out) must not share one.
const run = async () => {
const nonce = d.nonceFixed ? d.nonce : Math.max(d.nonce, await this._nextNonce());
const to = fromHex(d.to.slice(2));
const data = fromHex(normalizeData(d.data).slice(2));
const rawTxHex = d.legacy
? signTxLegacy({ chainId: d.chainId, nonce, gasPrice: d.maxFeePerGas, gasLimit: d.gasLimit, to, value: d.value, data }, this._priv)
: signTxEip1559([d.chainId, nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, to, d.value, data, []], 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));
if (this._lastNonce == null || nonce >= this._lastNonce || Date.now() - this._lastNonceAt >= 180_000) {
this._lastNonce = nonce; this._lastNonceAt = Date.now();
}
this.log("broadcast", txid);
setTimeout(() => { if (!this._disposed) this.refresh(); }, 3000);
return { txid, nonce };
};
const next = (this._sendChain || Promise.resolve()).then(run, run);
this._sendChain = next.catch(() => {});
return next;
}
// 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 };
};