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).
349 lines
14 KiB
JavaScript
349 lines
14 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,
|
|
} = {}) {
|
|
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 };
|
|
}
|
|
|
|
// 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 };
|
|
};
|