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).
334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
// Solana (SOL) chain adapter — mainnet-beta + devnet. Ed25519 keypair per
|
|
// SLIP-0010 (all-hardened path), base58 address, native SOL transfers via
|
|
// the system program. Sits directly on the JSON-RPC endpoint; no @solana
|
|
// SDK dep so the addon stays lean.
|
|
//
|
|
// Derivation: m/44'/501'/0'/0' — Phantom's default path. Every segment is
|
|
// hardened per SLIP-0010 (ed25519 forbids non-hardened derivation because
|
|
// the point-add trick that BIP32 uses on secp256k1 doesn't exist for
|
|
// Curve25519). Multi-index accounts under one wallet aren't exposed here;
|
|
// each Aegis "sub-account" gets its own vault-derive purpose instead.
|
|
//
|
|
// Not implemented in this rev:
|
|
// - SPL token balances / transfers (needs Associated Token Account math
|
|
// and the SPL Token program's transfer instruction).
|
|
// - Transaction history (getSignaturesForAddress + getTransaction is
|
|
// doable but heavy for a first cut; the panel links to Solana Explorer
|
|
// for now).
|
|
|
|
const NETWORKS = {
|
|
mainnet: {
|
|
id: "mainnet", label: "Mainnet",
|
|
defaultRpc: "https://api.mainnet-beta.solana.com",
|
|
explorerTx: "https://explorer.solana.com/tx/",
|
|
explorerAddr: "https://explorer.solana.com/address/",
|
|
explorerCluster: "",
|
|
faucet: null,
|
|
},
|
|
devnet: {
|
|
id: "devnet", label: "Devnet",
|
|
defaultRpc: "https://api.devnet.solana.com",
|
|
explorerTx: "https://explorer.solana.com/tx/",
|
|
explorerAddr: "https://explorer.solana.com/address/",
|
|
explorerCluster: "?cluster=devnet",
|
|
faucet: "https://faucet.solana.com/",
|
|
},
|
|
};
|
|
|
|
// System program's address is 32 bytes of zeros; base58 is "1111...1111".
|
|
const SYSTEM_PROGRAM = new Uint8Array(32);
|
|
|
|
module.exports = function makeSolAdapter({ ed25519, base58 }) {
|
|
if (!ed25519 || !base58) throw new Error("chain-sol: missing dep");
|
|
|
|
// ---- HMAC-SHA512 (for SLIP-0010) -------------------------------------
|
|
// Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes
|
|
// exports `hmac` in ./hmac. If unavailable, use Node's crypto — Electron
|
|
// main is Node, so require("crypto") always works.
|
|
const nodeCrypto = require("node:crypto");
|
|
function hmacSha512(key, msg) {
|
|
return new Uint8Array(nodeCrypto.createHmac("sha512", Buffer.from(key)).update(Buffer.from(msg)).digest());
|
|
}
|
|
|
|
// ---- SLIP-0010 ed25519 derivation ------------------------------------
|
|
const ED25519_MASTER_KEY = new TextEncoder().encode("ed25519 seed");
|
|
function slip10Master(seed32) {
|
|
const I = hmacSha512(ED25519_MASTER_KEY, seed32);
|
|
return { key: I.slice(0, 32), chainCode: I.slice(32) };
|
|
}
|
|
function slip10Derive(parent, indexHardened) {
|
|
// Data: 0x00 || parent.key || uint32BE(0x80000000 | index)
|
|
const idx = 0x80000000 | (indexHardened & 0x7fffffff);
|
|
const data = new Uint8Array(1 + 32 + 4);
|
|
data[0] = 0x00;
|
|
data.set(parent.key, 1);
|
|
// Write index as big-endian u32; JS bitwise is signed so |0 masks correctly.
|
|
data[33] = (idx >>> 24) & 0xff;
|
|
data[34] = (idx >>> 16) & 0xff;
|
|
data[35] = (idx >>> 8) & 0xff;
|
|
data[36] = idx & 0xff;
|
|
const I = hmacSha512(parent.chainCode, data);
|
|
return { key: I.slice(0, 32), chainCode: I.slice(32) };
|
|
}
|
|
function derivePath(seed32, segments) {
|
|
let node = slip10Master(seed32);
|
|
for (const s of segments) node = slip10Derive(node, s);
|
|
return node;
|
|
}
|
|
// "m/44'/501'/0'/0'" → [44, 501, 0, 0]. Every SLIP-0010 ed25519 segment
|
|
// is hardened; the parser accepts either the standard "'" suffix or a
|
|
// bare integer (both mean the same for this curve).
|
|
function parseAllHardened(path) {
|
|
const parts = String(path || "").trim().split("/").filter((p) => p && p !== "m");
|
|
return parts.map((p) => {
|
|
const m = /^(\d+)'?$/.exec(p);
|
|
if (!m) throw new Error("bad SOL derivation path: " + path);
|
|
return Number(m[1]);
|
|
});
|
|
}
|
|
|
|
// ---- Solana short-vec (compact-u16) ----------------------------------
|
|
// Up to 3 bytes; 7 data bits per byte with continuation bit in position 7.
|
|
function encodeCompactU16(n) {
|
|
if (n < 0 || n > 0xffff) throw new Error("compact-u16 out of range");
|
|
const out = [];
|
|
let rem = n;
|
|
while (true) {
|
|
let byte = rem & 0x7f;
|
|
rem >>= 7;
|
|
if (rem === 0) { out.push(byte); break; }
|
|
byte |= 0x80;
|
|
out.push(byte);
|
|
}
|
|
return Uint8Array.from(out);
|
|
}
|
|
|
|
const concat = (...ps) => {
|
|
const n = ps.reduce((a, p) => a + p.length, 0);
|
|
const o = new Uint8Array(n); let k = 0;
|
|
for (const p of ps) { o.set(p, k); k += p.length; }
|
|
return o;
|
|
};
|
|
const u64le = (n) => {
|
|
let v = BigInt(n); const o = new Uint8Array(8);
|
|
for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; }
|
|
return o;
|
|
};
|
|
|
|
// ---- transaction assembly --------------------------------------------
|
|
// For a native SOL transfer between two addresses:
|
|
// accounts (writable-signed | readonly-signed | writable-unsigned | readonly-unsigned):
|
|
// [ from (WS), to (WU), systemProgram (RU) ]
|
|
// header = [1 required-sig, 0 readonly-signed, 1 readonly-unsigned]
|
|
// instructions = [{ programIdIndex: 2, accounts: [0, 1], data: u32(2) || u64(lamports) }]
|
|
function buildSolTransferMessage({ fromPub, toPub, lamports, recentBlockhash }) {
|
|
// account keys must be de-duplicated in the message; distinct here.
|
|
const keys = [fromPub, toPub, SYSTEM_PROGRAM];
|
|
const header = Uint8Array.from([1, 0, 1]);
|
|
const keysSection = concat(
|
|
encodeCompactU16(keys.length),
|
|
...keys.map((k) => Uint8Array.from(k)),
|
|
);
|
|
// Instruction data: [2 (u32 LE = system Transfer discriminator), lamports (u64 LE)]
|
|
const instrData = concat(Uint8Array.from([2, 0, 0, 0]), u64le(lamports));
|
|
const instr = concat(
|
|
Uint8Array.from([2]), // programIdIndex
|
|
encodeCompactU16(2), // account key count
|
|
Uint8Array.from([0, 1]), // account indexes (from, to)
|
|
encodeCompactU16(instrData.length), // data length
|
|
instrData,
|
|
);
|
|
const instrSection = concat(encodeCompactU16(1), instr);
|
|
return concat(header, keysSection, recentBlockhash, instrSection);
|
|
}
|
|
|
|
// ---- JSON-RPC --------------------------------------------------------
|
|
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 SolWallet {
|
|
constructor(root32, networkId, {
|
|
walletId, storage, log = () => {}, onChange = () => {}, rpcUrl,
|
|
derivationPath = "m/44'/501'/0'/0'",
|
|
} = {}) {
|
|
if (!walletId) throw new Error("chain-sol: walletId required");
|
|
const net = NETWORKS[networkId];
|
|
if (!net) throw new Error(`chain-sol: unknown network ${networkId}`);
|
|
this.walletId = walletId;
|
|
this.chain = "sol";
|
|
this.network = net.id;
|
|
this._net = net;
|
|
this.log = log;
|
|
this.onChange = onChange;
|
|
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
|
this._path = derivationPath;
|
|
const derived = derivePath(root32, parseAllHardened(derivationPath));
|
|
this._priv = derived.key; // 32-byte ed25519 seed
|
|
this._pub = ed25519.getPublicKey(this._priv); // 32 bytes
|
|
this.address = base58.encode(this._pub);
|
|
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 {} }
|
|
|
|
snapshot() {
|
|
return {
|
|
chain: "sol", network: this._net.id, ticker: "SOL", decimals: 9,
|
|
address: this.address, addressIndex: 0, addressPath: this._path,
|
|
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,
|
|
explorerSuffix: this._net.explorerCluster, // panel appends this when opening
|
|
faucet: this._net.faucet,
|
|
};
|
|
}
|
|
|
|
async refresh() {
|
|
if (this._state.scanning) return;
|
|
this._state.scanning = true; this._state.error = null; this._emit();
|
|
try {
|
|
const [balRes, slot] = await Promise.all([
|
|
this._client.call("getBalance", [this.address]),
|
|
this._client.call("getSlot", []),
|
|
]);
|
|
// getBalance response: { context, value: lamports }
|
|
const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0);
|
|
this._state.balance = { confirmed: lamports, unconfirmed: 0 };
|
|
this._state.height = Number(slot || 0);
|
|
} 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);
|
|
}
|
|
|
|
// Solana fees are (usually) 5000 lamports per signature; reserve that
|
|
// in max-mode. Real fee comes from the network on broadcast.
|
|
async plan({ to, amount, sendMax }) {
|
|
const toBytes = base58.decode(String(to || "").trim());
|
|
if (!toBytes || toBytes.length !== 32) throw new Error("bad Solana address");
|
|
const bal = this._state.balance.confirmed || 0;
|
|
const FEE = 5000; // lamports per signature
|
|
let lamports;
|
|
if (sendMax) {
|
|
if (bal <= FEE) throw new Error("balance does not cover the fee");
|
|
lamports = bal - FEE;
|
|
} else {
|
|
lamports = Math.round(Number(amount) || 0);
|
|
if (!(lamports > 0)) throw new Error("amount must be > 0 lamports");
|
|
if (lamports + FEE > bal) throw new Error("insufficient funds");
|
|
}
|
|
// Fetch the fresh blockhash at plan time so signing can use it
|
|
// immediately — Solana blockhashes expire quickly (~150 slots ≈ 60s).
|
|
const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {};
|
|
if (!blockhash) throw new Error("could not fetch a recent blockhash");
|
|
const recent = base58.decode(String(blockhash));
|
|
return {
|
|
_draft: { toBytes, lamports, recentBlockhash: recent },
|
|
recipients: [{ to: base58.encode(toBytes), value: lamports }],
|
|
fee: FEE, feeRate: FEE,
|
|
inputs: [], change: 0,
|
|
total: lamports + FEE,
|
|
};
|
|
}
|
|
|
|
async signAndBroadcast(plan) {
|
|
const d = plan && plan._draft;
|
|
if (!d) throw new Error("bad plan");
|
|
const message = buildSolTransferMessage({
|
|
fromPub: this._pub, toPub: d.toBytes, lamports: d.lamports,
|
|
recentBlockhash: d.recentBlockhash,
|
|
});
|
|
const sig = ed25519.sign(message, this._priv); // 64 bytes
|
|
// Full transaction wire format: sig-count || sigs... || message
|
|
const sigCount = encodeCompactU16(1);
|
|
const wire = concat(sigCount, sig, message);
|
|
// Solana's sendTransaction accepts base58 (default) or base64 with
|
|
// {encoding:"base64"} in the second arg; we use base58 for parity
|
|
// with the rest of the ecosystem.
|
|
const wireBase58 = base58.encode(wire);
|
|
const txid = await this._client.call("sendTransaction", [wireBase58]);
|
|
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
|
this.log("broadcast", txid);
|
|
setTimeout(() => this.refresh(), 4000);
|
|
return { txid };
|
|
}
|
|
|
|
// Solana's convention: ed25519 signature over the raw message bytes,
|
|
// returned as {publicKey, signature} both base58. Dapps that follow
|
|
// the wallet-adapter standard verify against these.
|
|
signMessage(message) {
|
|
const bytes = new TextEncoder().encode(String(message));
|
|
const sig = ed25519.sign(bytes, this._priv);
|
|
return {
|
|
address: this.address,
|
|
publicKey: base58.encode(this._pub),
|
|
signature: base58.encode(sig),
|
|
};
|
|
}
|
|
|
|
recovery() {
|
|
return {
|
|
accountPath: this._path,
|
|
xpub: base58.encode(this._pub),
|
|
xprv: Buffer.from(this._priv).toString("hex"),
|
|
};
|
|
}
|
|
|
|
dispose() {
|
|
clearTimeout(this._pollTimer);
|
|
try { this._priv && this._priv.fill(0); } catch {}
|
|
try { this._root && this._root.fill(0); } catch {}
|
|
}
|
|
}
|
|
|
|
return { SolWallet, NETWORKS };
|
|
};
|