theseus/bundled-addons/bchwallet/lib/chain-sol.js
Local Dev b5f7552277 feat(theseus/aegis): SPL token support (view balances + send)
SPL tokens now show up in the Solana wallet — balances on the Receive
card, an asset picker on Send that flips the amount input into the
token's own units. Sends build a TransferChecked + auto-create the
recipient's Associated Token Account (idempotently) in the same
transaction, so the user never has to fund an ATA by hand.

- lib/sol-spl.js: SPL primitives that don't need @solana/web3.js.
  TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID,
  findProgramAddress (PDA loop backed by an ed25519 is-on-curve check
  via @noble Point.fromBytes), associatedTokenAddress (matches the
  spl-token JS seed layout: [owner, tokenProgram, mint]),
  transferCheckedInstruction (discriminator 12, u64 amount, decimals
  byte), createATAIdempotentInstruction (associated-token program
  discriminator 1). A small known-mint registry ships inline for USDC /
  USDT / wSOL on mainnet + USDC on devnet — everything else falls back
  to a truncated mint address in the UI.
- Message assembler classifies every unique pubkey into writable-signed
  / readonly-signed / writable-unsigned / readonly-unsigned, sorts the
  fee payer first, and serializes header + accountKeys + blockhash +
  instructions using Solana's compact-u16 short-vec encoding. Same
  wire shape @solana/web3.js produces from Transaction.serializeMessage.
- lib/chain-sol.js: snapshot() now carries a tokens[] array of
  {mint, symbol, name, decimals, balance, tokenAccount, tokenProgram,
  isKnown, isToken2022}. Fetched via getTokenAccountsByOwner against
  both the classic Token program and Token-2022. New planTokenTransfer
  + signAndBroadcastToken handle a full send (TransferChecked +
  optional CreateATAIdempotent) in one wire.
- Panel: Send tab gained an Asset dropdown (SOL / <each token>) that
  only shows for SOL wallets with tokens. Picking a token flips the
  unit picker's big-unit to the token symbol, amount goes in the
  token's own decimals, planTokenSend + sendToken take over from
  planSend/send. Receive tab gained a Tokens card listing each SPL
  balance with a per-row Send button that pre-fills the asset picker.
- Verified in scratchpad: ATA derivation runs the PDA loop
  correctly (owner pubkey passes isOnCurve, derived ATA does not —
  the definitional property of a Program-Derived Address). Cross-check
  the ATA for any (owner, mint) on Phantom / Solscan / spl-token JS
  and the value matches.

Known limits:
- No token metadata lookup on-chain — mints outside the built-in
  registry show up with a truncated mint address as symbol. Wiring
  Metaplex Metadata program reads would let unknown tokens show
  their real names.
- Send is single-signer only (the wallet is the fee payer, sender
  and sole required signer). Multi-sig SPL transfers work via the
  dapp bridge (window.solana.signAndSendTransaction, which already
  handles partial signatures).
2026-09-07 23:55:09 +02:00

473 lines
20 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, 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 };
};