theseus/bundled-addons/bchwallet/lib/chain-eth.js

358 lines
15 KiB
JavaScript
Raw Normal View History

feat(theseus/aegis): Ethereum + Solana adapters, DGB address-family picker, Aegis-branded shield Multi-currency coverage matches what aegis.x has been advertising: BCH, TRX, SC, DGB, ETH, SOL — six coins, two-step coin/network picker for each. Panel logos, favicon and fallback all read as Aegis. - Ethereum (lib/chain-eth.js): mainnet + Sepolia. BIP44 m/44'/60'/0'/0/0 → secp256k1 → EIP-55 checksummed hex address (verified against MetaMask's canonical abandon×11 vector 0x9858EfFD23…4EcaEda94). JSON-RPC backend (Cloudflare mainnet, PublicNode Sepolia by default; per-wallet override). EIP-1559 send with an inline RLP encoder + secp256k1 recoverable sign; broadcast via eth_sendRawTransaction. personal_sign message signing follows the \x19Ethereum Signed Message:\n prefix. - Solana (lib/chain-sol.js): mainnet-beta + devnet. SLIP-0010 ed25519 derivation at m/44'/501'/0'/0' (all-hardened), base58 address (@noble ed25519). SLIP-0010 layer verified against spec Test Vector 1 in scratchpad/verify-slip10.mjs. Native SOL transfer via the system program with compact-u16 message serialization + ed25519 sign + sendTransaction. Devnet gets a faucet.solana.com link in Receive; the panel appends ?cluster=devnet when opening the explorer. - DGB address family selector (lib/chain-dgb.js already carried the paths): the Settings block now shows a Native SegWit / Taproot / Wrapped SegWit / Legacy P2PKH picker. Selecting a family auto-fills the derivation-path input with that family's default; Apply rebuilds the wallet against the new path. Address families exposed via chainMeta.addressFamilies so the panel can render them from data. - Panel branding: inline SVG shield (hexagonal aspis, same silhouette as the aegis.x hero) replaces the "?" fallback in logoSvg() and is what the header shows before a wallet is selected. Data-URI favicon wired into panel.html so the Theseus sidebar tab icon reads as Aegis rather than a chain-specific coin mark. - QR payloads now follow each chain's own URI scheme (BIP21 for BCH/DGB, EIP-681 for ETH, Solana Pay for SOL) so external scanners route the scan to the right wallet. Not shipped: EIP-1193 provider (window.ethereum) and wallet-adapter protocol (window.solana). The signing paths exist; only the page-inject bridge glue is missing. History for ETH/SOL is also empty in this rev — both need indexer plumbing (Etherscan V2 for ETH, getSignaturesForAddress + getTransaction pagination for SOL).
2026-09-07 20:31:27 +02:00
// 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,
} = {}) {
if (!walletId) throw new Error("chain-eth: walletId required");
const net = 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.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.
snapshot() {
return {
chain: "eth", network: this._net.id, ticker: "ETH", 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 };
}
feat(theseus/aegis): EIP-712 signTypedData_v4 + Solana multi-signer send Two follow-ups to the dapp bridges. Both change wire shape only — no new UI, existing wallets keep signing byte-identically for the flows they already covered. - lib/eip712.js: full EIP-712 typed-data encoder — encodeType with alphabetically-sorted transitive sub-types, typeHash, encodeValue for string / address / bool / uint*/int* (any width) / bytes / bytesN / nested structs / dynamic and fixed arrays, hashStruct recursion, digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct). Verified against the spec §"Ether Mail" test vector — hashStruct on both the domain and the message plus the final digest all match the canonical values byte-for-byte (see scratchpad/verify-eip712.mjs). - chain-eth.js: exposes signTypedDataDigest(digest32) that signs the precomputed digest with r||s||v (v = 27+recid), the same envelope personal_sign uses. Aegis computes the digest server-side (in the addon) so a bug in the encoder can't be tricked by a malicious dapp into signing over data the user never saw. - index.js: eth.signTypedData handler shows domain (name · version · chainId), primary type, and a truncated JSON preview of the message in the approval overlay — every classic phishing signal (mismatched domain, unexpected primary type) is in front of the user before they hit Sign. Accepts either an already-parsed typedData object or the JSON-string form older MetaMask specs used. - wallet-inject.js router: eth_signTypedData_v4 (and _v3 for the same payload shape) route to eth.signTypedData. v1's flat "type[]" form is unwired — dapps that still use v1 should upgrade. - Solana signAndSend: bridge now passes the FULL wire (from tx.serialize({requireAllSignatures:false, verifySignatures:false})) instead of just the message. The addon parses compact-u16 signature count, finds this wallet's pubkey in the message's account-key list, signs the message, and patches ONLY its own slot in the signature array — any partial signatures the dapp had already filled with tx.partialSign() (session keys, escrow co-signers, permissioned authorities) are preserved. Multi-signer flows work now; single-signer is the degenerate case of sigCount=1. - Approval overlay for sol.signAndSend now shows required-signer count and the wallet's slot index so multi-signer requests are visibly distinct from a plain single-signer send.
2026-09-07 22:19:51 +02:00
// 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) };
}
feat(theseus/aegis): Ethereum + Solana adapters, DGB address-family picker, Aegis-branded shield Multi-currency coverage matches what aegis.x has been advertising: BCH, TRX, SC, DGB, ETH, SOL — six coins, two-step coin/network picker for each. Panel logos, favicon and fallback all read as Aegis. - Ethereum (lib/chain-eth.js): mainnet + Sepolia. BIP44 m/44'/60'/0'/0/0 → secp256k1 → EIP-55 checksummed hex address (verified against MetaMask's canonical abandon×11 vector 0x9858EfFD23…4EcaEda94). JSON-RPC backend (Cloudflare mainnet, PublicNode Sepolia by default; per-wallet override). EIP-1559 send with an inline RLP encoder + secp256k1 recoverable sign; broadcast via eth_sendRawTransaction. personal_sign message signing follows the \x19Ethereum Signed Message:\n prefix. - Solana (lib/chain-sol.js): mainnet-beta + devnet. SLIP-0010 ed25519 derivation at m/44'/501'/0'/0' (all-hardened), base58 address (@noble ed25519). SLIP-0010 layer verified against spec Test Vector 1 in scratchpad/verify-slip10.mjs. Native SOL transfer via the system program with compact-u16 message serialization + ed25519 sign + sendTransaction. Devnet gets a faucet.solana.com link in Receive; the panel appends ?cluster=devnet when opening the explorer. - DGB address family selector (lib/chain-dgb.js already carried the paths): the Settings block now shows a Native SegWit / Taproot / Wrapped SegWit / Legacy P2PKH picker. Selecting a family auto-fills the derivation-path input with that family's default; Apply rebuilds the wallet against the new path. Address families exposed via chainMeta.addressFamilies so the panel can render them from data. - Panel branding: inline SVG shield (hexagonal aspis, same silhouette as the aegis.x hero) replaces the "?" fallback in logoSvg() and is what the header shows before a wallet is selected. Data-URI favicon wired into panel.html so the Theseus sidebar tab icon reads as Aegis rather than a chain-specific coin mark. - QR payloads now follow each chain's own URI scheme (BIP21 for BCH/DGB, EIP-681 for ETH, Solana Pay for SOL) so external scanners route the scan to the right wallet. Not shipped: EIP-1193 provider (window.ethereum) and wallet-adapter protocol (window.solana). The signing paths exist; only the page-inject bridge glue is missing. History for ETH/SOL is also empty in this rev — both need indexer plumbing (Etherscan V2 for ETH, getSignaturesForAddress + getTransaction pagination for SOL).
2026-09-07 20:31:27 +02:00
// 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 };
};