theseus/bundled-addons/bchwallet/lib/chain-sol.js
Local Dev 65c306d553 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

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