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 };
|
|
|
|
|
};
|