// 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, sha256 }) { if (!ed25519 || !base58 || !sha256) throw new Error("chain-sol: missing dep"); const spl = require("./sol-spl.js")({ ed25519, sha256, base58 }); // ---- 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: [], tokens: [], // [{mint, symbol, name, decimals, balance, tokenAccount, tokenProgram}] 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, tokens: this._state.tokens, 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, 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, tokens] = await Promise.all([ this._client.call("getBalance", [this.address]), this._client.call("getSlot", []), this._fetchTokens().catch((e) => { this.log("tokens fetch failed:", e?.message); return []; }), ]); // 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); this._state.tokens = tokens; } catch (e) { this._state.error = e?.message || String(e); this.log("refresh failed:", this._state.error); } finally { this._state.scanning = false; this._emit(); } } // getTokenAccountsByOwner + parse. Result shape: // {context, value: [{ pubkey, account: {data: {parsed: {info:{mint, tokenAmount:{amount,decimals,uiAmountString}}}, program, space}, executable, ...} }]} // We ask for jsonParsed encoding so the RPC does the layout heavy-lift. async _fetchTokens() { const known = spl.KNOWN_TOKENS[this._net.id] || {}; const call = (programB58) => this._client.call("getTokenAccountsByOwner", [ this.address, { programId: programB58 }, { encoding: "jsonParsed", commitment: "confirmed" }, ]); const results = await Promise.all([ call(spl.TOKEN_PROGRAM_ID_B58).catch(() => ({ value: [] })), call(spl.TOKEN_2022_PROGRAM_ID_B58).catch(() => ({ value: [] })), ]); const out = []; for (let p = 0; p < results.length; p++) { const list = results[p]?.value || []; const isTk22 = p === 1; for (const it of list) { const info = it?.account?.data?.parsed?.info; if (!info) continue; const mint = String(info.mint || ""); const dec = Number(info.tokenAmount?.decimals || 0); const rawAmount = String(info.tokenAmount?.amount || "0"); const meta = known[mint]; out.push({ mint, tokenAccount: String(it.pubkey || ""), tokenProgram: isTk22 ? spl.TOKEN_2022_PROGRAM_ID_B58 : spl.TOKEN_PROGRAM_ID_B58, symbol: meta?.symbol || mint.slice(0, 6) + "…", name: meta?.name || null, decimals: dec, balance: rawAmount, // string (u64) to preserve precision isKnown: !!meta, isToken2022: isTk22, }); } } // Sort known tokens first, then by balance desc. out.sort((a, b) => (b.isKnown - a.isKnown) || (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); return out; } 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 }; } // ---- SPL token send ----------------------------------------------- // Build a TransferChecked (+ optional CreateAssociatedTokenAccountIdempotent) // transaction moving `amount` (in raw token units) of a given mint to a // recipient's ATA. `mintB58` is the mint address as a base58 string; // decimals come from the sender's own token account (or the caller // passes them explicitly if the sender's ATA is empty). async planTokenTransfer({ mint, to, amount, decimals, tokenProgram }) { const mintB58 = String(mint || ""); const mintBytes = base58.decode(mintB58); if (mintBytes.length !== 32) throw new Error("bad mint address"); const recipientBytes = base58.decode(String(to || "").trim()); if (recipientBytes.length !== 32) throw new Error("bad recipient address"); // Resolve the token program for this mint from our own token list — // Token-2022 mints need the 2022 program in the transfer instruction. let tp = tokenProgram ? base58.decode(tokenProgram) : spl.TOKEN_PROGRAM_ID; let dec = decimals; const mine = (this._state.tokens || []).find((t) => t.mint === mintB58); if (mine) { tp = base58.decode(mine.tokenProgram); if (dec == null) dec = mine.decimals; } if (dec == null) throw new Error("token decimals unknown — no ATA on this wallet for that mint"); const amt = BigInt(amount); if (amt <= 0n) throw new Error("amount must be > 0"); if (mine && BigInt(mine.balance) < amt) throw new Error("insufficient token balance"); const sourceATA = spl.associatedTokenAddress(this._pub, mintBytes, tp); const destATA = spl.associatedTokenAddress(recipientBytes, mintBytes, tp); // Ask the RPC whether the destination ATA already exists. If not, // prepend a CreateIdempotent instruction so the transfer succeeds // in one round-trip — the recipient never has to have interacted // with this mint before. const destATA_B58 = base58.encode(destATA); const info = await this._client.call("getAccountInfo", [destATA_B58, { encoding: "base64" }]); const destExists = !!(info && info.value); const instructions = []; if (!destExists) { instructions.push(spl.createATAIdempotentInstruction({ payer: this._pub, ata: destATA, owner: recipientBytes, mint: mintBytes, tokenProgram: tp, })); } instructions.push(spl.transferCheckedInstruction({ sourceATA, mint: mintBytes, destATA, owner: this._pub, amount: amt, decimals: dec, tokenProgram: tp, })); const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {}; if (!blockhash) throw new Error("could not fetch a recent blockhash"); const recentBlockhash = base58.decode(String(blockhash)); return { _spl: { instructions, recentBlockhash, destExists, sourceATA, destATA }, recipients: [{ to: base58.encode(recipientBytes), value: amt.toString() }], // Fee estimate: 5000 lamports per signature + ~2039280 rent-exempt // if we're creating a new ATA. Real fee still comes from the network. fee: destExists ? 5000 : 5000 + 2039280, feeRate: 5000, inputs: [], change: 0, total: amt.toString(), mint: mintB58, decimals: dec, }; } async signAndBroadcastToken(plan) { const sp = plan && plan._spl; if (!sp) throw new Error("bad token plan"); const message = spl.buildMessage({ feePayer: this._pub, instructions: sp.instructions, recentBlockhash: sp.recentBlockhash, }); const sig = ed25519.sign(message, this._priv); const encodeCompactU16 = (n) => { 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 sigCount = encodeCompactU16(1); const wire = new Uint8Array(sigCount.length + 64 + message.length); wire.set(sigCount, 0); wire.set(sig, sigCount.length); wire.set(message, sigCount.length + 64); const wireB58 = base58.encode(wire); const txid = await this._client.call("sendTransaction", [wireB58]); if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); this.log("SPL broadcast", txid); setTimeout(() => this.refresh().catch(() => {}), 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 }; };