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).
This commit is contained in:
Local Dev 2026-09-07 23:55:09 +02:00
parent 1b29706ba4
commit b5f7552277
5 changed files with 508 additions and 10 deletions

View file

@ -51,7 +51,7 @@ async function loadDeps(api) {
// base58check as encodeBase58 / decodeBase58 — expose them under a // base58check as encodeBase58 / decodeBase58 — expose them under a
// `{encode, decode}` shape the SOL adapter reads from. // `{encode, decode}` shape the SOL adapter reads from.
const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 }; const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 };
const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58 }); const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58, sha256 });
// DGB delegates address derivation + PSBT to the vendored @dgb-wallet/* // DGB delegates address derivation + PSBT to the vendored @dgb-wallet/*
// packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib, // packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib,
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via // bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
@ -660,6 +660,18 @@ function fmtValue(units, decimals) {
const n = Number(units) / Math.pow(10, decimals); const n = Number(units) / Math.pow(10, decimals);
return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
} }
// BigInt-safe display for SPL token amounts (raw units in u64 strings).
function fmtTokenAmount(rawStr, decimals) {
const s = String(rawStr || "0");
const neg = s.startsWith("-");
const abs = neg ? s.slice(1) : s;
const d = Number(decimals) || 0;
if (d === 0) return (neg ? "-" : "") + abs;
const pad = abs.padStart(d + 1, "0");
const whole = pad.slice(0, pad.length - d);
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
}
function registerPanelMessages(api) { function registerPanelMessages(api) {
api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); }); api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); });
@ -834,6 +846,42 @@ function registerPanelMessages(api) {
const plan = await Promise.resolve(rt.adapter.plan(p || {})); const plan = await Promise.resolve(rt.adapter.plan(p || {}));
return describePlan(plan, rt.entry.chain, rt.entry.network); return describePlan(plan, rt.entry.chain, rt.entry.network);
}); });
// SPL token plan/send — only meaningful when the selected wallet is SOL.
api.onMessage("planTokenSend", async (p, m) => {
fromPanel(m);
const rt = requireSelected();
if (rt.entry.chain !== "sol" || typeof rt.adapter.planTokenTransfer !== "function") {
throw new Error("token send is a Solana-only flow");
}
const plan = await rt.adapter.planTokenTransfer(p || {});
return {
recipients: plan.recipients, fee: String(plan.fee), feeRate: String(plan.feeRate),
inputs: 0, change: "0", total: plan.total, mint: plan.mint, decimals: plan.decimals,
};
});
api.onMessage("sendToken", async (p, m) => {
fromPanel(m);
const rt = requireSelected();
if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only");
const plan = await rt.adapter.planTokenTransfer(p || {});
const meta = chainMeta("sol", rt.entry.network);
const tokenInfo = (rt.adapter.snapshot().tokens || []).find((t) => t.mint === plan.mint) || {};
const rows = [
{ label: "To", value: plan.recipients[0].to, mono: true },
{ label: "Amount", value: `${fmtTokenAmount(plan.recipients[0].value, plan.decimals)} ${tokenInfo.symbol || "token"}`, strong: true },
{ label: "Mint", value: plan.mint, mono: true },
{ label: "Network fee", value: `~${fmtValue(Number(plan.fee), meta.decimals)} SOL${(plan._spl && plan._spl.destExists === false) ? " (includes new token account rent)" : ""}` },
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${rt.entry.network}` },
];
const pick = await api.approvalModal({
title: `Send ${tokenInfo.symbol || "SPL token"}?`,
origin: "Aegis wallet panel",
rows,
actions: [{ id: "send", label: "Send", primary: true }],
});
if (pick !== "send") throw new Error("cancelled");
return rt.adapter.signAndBroadcastToken(plan);
});
// Execute a send with approval overlay. // Execute a send with approval overlay.
api.onMessage("send", async (p, m) => { api.onMessage("send", async (p, m) => {
fromPanel(m); fromPanel(m);

View file

@ -38,8 +38,9 @@ const NETWORKS = {
// System program's address is 32 bytes of zeros; base58 is "1111...1111". // System program's address is 32 bytes of zeros; base58 is "1111...1111".
const SYSTEM_PROGRAM = new Uint8Array(32); const SYSTEM_PROGRAM = new Uint8Array(32);
module.exports = function makeSolAdapter({ ed25519, base58 }) { module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) {
if (!ed25519 || !base58) throw new Error("chain-sol: missing dep"); 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) ------------------------------------- // ---- HMAC-SHA512 (for SLIP-0010) -------------------------------------
// Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes // Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes
@ -193,6 +194,7 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) {
this._state = { this._state = {
balance: { confirmed: 0, unconfirmed: 0 }, balance: { confirmed: 0, unconfirmed: 0 },
history: [], history: [],
tokens: [], // [{mint, symbol, name, decimals, balance, tokenAccount, tokenProgram}]
height: 0, height: 0,
scanning: false, scanning: false,
error: null, error: null,
@ -214,13 +216,14 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) {
balance: this._state.balance, balance: this._state.balance,
height: this._state.height, height: this._state.height,
history: this._state.history, history: this._state.history,
tokens: this._state.tokens,
scanning: this._state.scanning, scanning: this._state.scanning,
error: this._state.error, error: this._state.error,
server: this._client.url, server: this._client.url,
rpcUrl: this._client.url, rpcUrl: this._client.url,
explorerTx: this._net.explorerTx, explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr, explorerAddr: this._net.explorerAddr,
explorerSuffix: this._net.explorerCluster, // panel appends this when opening explorerSuffix: this._net.explorerCluster,
faucet: this._net.faucet, faucet: this._net.faucet,
}; };
} }
@ -229,14 +232,16 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) {
if (this._state.scanning) return; if (this._state.scanning) return;
this._state.scanning = true; this._state.error = null; this._emit(); this._state.scanning = true; this._state.error = null; this._emit();
try { try {
const [balRes, slot] = await Promise.all([ const [balRes, slot, tokens] = await Promise.all([
this._client.call("getBalance", [this.address]), this._client.call("getBalance", [this.address]),
this._client.call("getSlot", []), this._client.call("getSlot", []),
this._fetchTokens().catch((e) => { this.log("tokens fetch failed:", e?.message); return []; }),
]); ]);
// getBalance response: { context, value: lamports } // getBalance response: { context, value: lamports }
const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0); const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0);
this._state.balance = { confirmed: lamports, unconfirmed: 0 }; this._state.balance = { confirmed: lamports, unconfirmed: 0 };
this._state.height = Number(slot || 0); this._state.height = Number(slot || 0);
this._state.tokens = tokens;
} catch (e) { } catch (e) {
this._state.error = e?.message || String(e); this._state.error = e?.message || String(e);
this.log("refresh failed:", this._state.error); this.log("refresh failed:", this._state.error);
@ -245,6 +250,47 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) {
this._emit(); 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) { schedulePoll(ms = 20_000) {
clearTimeout(this._pollTimer); clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
@ -302,6 +348,99 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) {
return { txid }; 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, // Solana's convention: ed25519 signature over the raw message bytes,
// returned as {publicKey, signature} both base58. Dapps that follow // returned as {publicKey, signature} both base58. Dapps that follow
// the wallet-adapter standard verify against these. // the wallet-adapter standard verify against these.

View file

@ -0,0 +1,220 @@
// Solana Program Library (SPL) token primitives — PDA derivation,
// Associated Token Account math, and the two Token-program instructions
// this wallet needs at the bytecode level: `TransferChecked` (send SPL
// with a decimals sanity check) and `CreateAssociatedTokenAccountIdempotent`
// (make the receiver's token account inline, so the user doesn't have to
// pre-create it on any address they've never seen before).
//
// Every account address on Solana is a 32-byte ed25519 public key. A
// Program-Derived Address (PDA) is a 32-byte value that is NOT on the
// ed25519 curve — the runtime uses that fact as proof that no one holds
// its private key, so only the owning program can spend from it. To
// derive a PDA we sha256(seeds || programId || bump || "ProgramDerivedAddress")
// for bump = 255…0 and pick the first value that isn't a valid curve
// point. `isOnCurve` here defers to @noble/curves/ed25519's ExtendedPoint,
// which throws on invalid points; everything that decodes is on-curve.
const PDA_MARKER = new TextEncoder().encode("ProgramDerivedAddress");
module.exports = function makeSolSpl({ ed25519, sha256, base58 }) {
if (!ed25519 || !sha256 || !base58) throw new Error("sol-spl: missing dep");
// ---- constants -------------------------------------------------------
const TOKEN_PROGRAM_ID_B58 = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const ASSOC_PROGRAM_ID_B58 = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
const TOKEN_2022_PROGRAM_ID_B58 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const TOKEN_PROGRAM_ID = base58.decode(TOKEN_PROGRAM_ID_B58);
const ASSOC_PROGRAM_ID = base58.decode(ASSOC_PROGRAM_ID_B58);
const TOKEN_2022_PROGRAM_ID = base58.decode(TOKEN_2022_PROGRAM_ID_B58);
const SYSTEM_PROGRAM_ID = new Uint8Array(32);
// Known-token registry — just enough to give the panel a sensible label
// for the tokens users actually see day-to-day. Everything else falls
// back to the mint address itself (truncated in the UI).
const KNOWN_TOKENS = {
mainnet: {
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { symbol: "USDC", decimals: 6, name: "USD Coin" },
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": { symbol: "USDT", decimals: 6, name: "Tether USD" },
"So11111111111111111111111111111111111111112": { symbol: "wSOL", decimals: 9, name: "Wrapped SOL" },
},
devnet: {
"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { symbol: "USDC", decimals: 6, name: "USD Coin (devnet)" },
},
};
// ---- ed25519 curve check --------------------------------------------
// A 32-byte pubkey is "on curve" if it decompresses to a valid Edwards
// point. @noble/curves v2 exposes Point.fromBytes(bytes) (throws on
// invalid); older versions exposed ExtendedPoint.fromHex(hex-string)
// — try each in order.
function isOnCurve(pubkey32) {
const P = ed25519.Point || ed25519.ExtendedPoint;
if (!P) return true; // no curve access — treat every value as
// on-curve (over-conservative; PDA loop falls
// through more than it should but never gets
// wrong).
try {
if (typeof P.fromBytes === "function") { P.fromBytes(pubkey32); return true; }
if (typeof P.fromHex === "function") {
const hex = Array.from(pubkey32, (b) => b.toString(16).padStart(2, "0")).join("");
P.fromHex(hex); return true;
}
} catch { return false; }
return true;
}
const 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;
};
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;
};
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);
};
// ---- PDA / ATA -------------------------------------------------------
function findProgramAddress(seeds, programId) {
for (let bump = 255; bump >= 0; bump--) {
const material = concat(
...seeds.map((s) => Uint8Array.from(s)),
Uint8Array.from([bump]),
programId,
PDA_MARKER,
);
const candidate = sha256(material);
if (!isOnCurve(candidate)) return { address: candidate, bump };
}
throw new Error("no PDA found (unreachable)");
}
// Standard ATA = PDA under ASSOC_PROGRAM_ID with seeds
// [ownerPubkey, TOKEN_PROGRAM_ID, mint].
// We match the seed layout the @solana/spl-token library uses so
// addresses agree with any wallet or block explorer.
function associatedTokenAddress(owner, mint, tokenProgram = TOKEN_PROGRAM_ID) {
return findProgramAddress([owner, tokenProgram, mint], ASSOC_PROGRAM_ID).address;
}
// ---- instruction encoders -------------------------------------------
// SPL Token: TransferChecked (discriminator 12) — asserts amount+decimals
// against the mint so a UI bug can't move 1000× the intended value.
// accounts: [sourceATA (writable), mint (readonly), destATA (writable), owner (signer)]
// data: [12, amount:u64_le, decimals:u8]
function transferCheckedInstruction({ sourceATA, mint, destATA, owner, amount, decimals, tokenProgram = TOKEN_PROGRAM_ID }) {
return {
programId: tokenProgram,
keys: [
{ pubkey: sourceATA, isSigner: false, isWritable: true },
{ pubkey: mint, isSigner: false, isWritable: false },
{ pubkey: destATA, isSigner: false, isWritable: true },
{ pubkey: owner, isSigner: true, isWritable: false },
],
data: concat(Uint8Array.from([12]), u64le(amount), Uint8Array.from([decimals])),
};
}
// Associated Token Account program: CreateIdempotent (discriminator 1)
// accounts: [payer(signer,writable), ata(writable), owner(readonly),
// mint(readonly), systemProgram(readonly), tokenProgram(readonly)]
// data: [1] (idempotent variant — no-op if the account exists)
function createATAIdempotentInstruction({ payer, ata, owner, mint, tokenProgram = TOKEN_PROGRAM_ID }) {
return {
programId: ASSOC_PROGRAM_ID,
keys: [
{ pubkey: payer, isSigner: true, isWritable: true },
{ pubkey: ata, isSigner: false, isWritable: true },
{ pubkey: owner, isSigner: false, isWritable: false },
{ pubkey: mint, isSigner: false, isWritable: false },
{ pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: tokenProgram, isSigner: false, isWritable: false },
],
data: Uint8Array.from([1]),
};
}
// ---- transaction message builder ------------------------------------
// Assembles the raw Solana message bytes for a single-fee-payer, single-
// signer transaction that may carry multiple instructions. Account keys
// are sorted per Solana's account-classification rules (writable-signed,
// readonly-signed, writable-unsigned, readonly-unsigned).
function buildMessage({ feePayer, instructions, recentBlockhash }) {
// 1. Collect every unique pubkey mentioned across instructions +
// include the fee payer + include each instruction's programId.
const keys = new Map(); // b58 → { pubkey, isSigner, isWritable }
const upsert = (pubkey, isSigner, isWritable) => {
const k = base58.encode(pubkey);
const cur = keys.get(k) || { pubkey, isSigner: false, isWritable: false };
cur.isSigner = cur.isSigner || isSigner;
cur.isWritable = cur.isWritable || isWritable;
keys.set(k, cur);
};
upsert(feePayer, true, true);
for (const ins of instructions) {
for (const k of ins.keys) upsert(k.pubkey, k.isSigner, k.isWritable);
upsert(ins.programId, false, false);
}
// 2. Classify + sort into the four buckets.
const bucket = { ws: [], rs: [], wu: [], ru: [] };
for (const v of keys.values()) {
if (v.isSigner && v.isWritable) bucket.ws.push(v);
else if (v.isSigner) bucket.rs.push(v);
else if (v.isWritable) bucket.wu.push(v);
else bucket.ru.push(v);
}
// Fee payer MUST be at index 0 (Solana requires the first signer to
// be writable & to pay the fee).
const payerB58 = base58.encode(feePayer);
bucket.ws.sort((a, b) => (base58.encode(a.pubkey) === payerB58 ? -1 : base58.encode(b.pubkey) === payerB58 ? 1 : 0));
const ordered = [...bucket.ws, ...bucket.rs, ...bucket.wu, ...bucket.ru];
// 3. Encode the message.
const header = Uint8Array.from([
bucket.ws.length + bucket.rs.length, // numRequiredSignatures
bucket.rs.length, // numReadonlySignedAccounts
bucket.ru.length, // numReadonlyUnsignedAccounts
]);
const keysSection = concat(
encodeCompactU16(ordered.length),
...ordered.map((v) => Uint8Array.from(v.pubkey)),
);
const indexOf = new Map(ordered.map((v, i) => [base58.encode(v.pubkey), i]));
const insSection = concat(
encodeCompactU16(instructions.length),
...instructions.map((ins) => {
const programIndex = indexOf.get(base58.encode(ins.programId));
const accountBytes = Uint8Array.from(ins.keys.map((k) => indexOf.get(base58.encode(k.pubkey))));
return concat(
Uint8Array.from([programIndex]),
encodeCompactU16(accountBytes.length),
accountBytes,
encodeCompactU16(ins.data.length),
ins.data,
);
}),
);
return concat(header, keysSection, recentBlockhash, insSection);
}
return {
// constants
TOKEN_PROGRAM_ID, ASSOC_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, SYSTEM_PROGRAM_ID,
TOKEN_PROGRAM_ID_B58, ASSOC_PROGRAM_ID_B58,
KNOWN_TOKENS,
// helpers
isOnCurve, findProgramAddress, associatedTokenAddress,
// instruction encoders
transferCheckedInstruction, createATAIdempotentInstruction,
// tx assembly
buildMessage,
};
};

View file

@ -152,8 +152,18 @@
<button class="btn sm" id="openFaucet" hidden>Faucet</button> <button class="btn sm" id="openFaucet" hidden>Faucet</button>
</div> </div>
</div> </div>
<div class="card" id="tokensCard" hidden style="margin-top:12px">
<div class="lbl">Tokens</div>
<div id="tokensList" class="kv"></div>
<div class="hint">SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.</div>
</div>
</section> </section>
<section id="tab-send" hidden> <section id="tab-send" hidden>
<div class="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div>
<select id="sendAsset"></select>
<div class="hint">Pick the native coin or an SPL token in this wallet.</div>
</div>
<div class="field"> <div class="field">
<div class="lbl">Recipient</div> <div class="lbl">Recipient</div>
<input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="…"> <input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="…">

View file

@ -10,6 +10,9 @@ let sendMax = false;
let planTimer = null; let planTimer = null;
let lastPlan = null; let lastPlan = null;
let settingsFilled = false; let settingsFilled = false;
// Selected asset for the Send tab. `null` = native coin. Otherwise a
// { mint, symbol, decimals } picked from the SOL wallet's SPL token list.
let sendAsset = null;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } }; const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } };
@ -282,15 +285,79 @@ function render() {
$("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : ""; $("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : "";
$("nextAddr").hidden = chain() !== "bch"; $("nextAddr").hidden = chain() !== "bch";
$("openFaucet").hidden = !s.faucet; $("openFaucet").hidden = !s.faucet;
// Render SPL tokens list (SOL wallets only). Sending a token clicks
// through to the Send tab with that asset pre-picked.
renderTokens();
applyUnitPicker(); applyUnitPicker();
$("feeField").hidden = chain() !== "bch"; $("feeField").hidden = chain() !== "bch";
renderHistory(); renderHistory();
} }
function renderTokens() {
const s = sel();
const tokens = (chain() === "sol" && s?.tokens) || [];
const card = $("tokensCard");
card.hidden = tokens.length === 0;
if (!tokens.length) return;
const el = $("tokensList");
el.innerHTML = tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const bal = fmtTokenAmount(t.balance, dec);
return `<div class="tx" style="grid-template-columns:1fr auto auto;cursor:default">
<div><div>${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + '</span>' : ""}</div><div class="hint mono">${esc(t.mint.slice(0, 10))}${esc(t.mint.slice(-6))}</div></div>
<div class="amt2 in" style="align-self:center">${esc(bal)}</div>
<button class="btn sm" data-mint="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${dec}" style="align-self:center">Send</button>
</div>`;
}).join("");
el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => {
sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) };
showTab("send");
}));
}
// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts.
function fmtTokenAmount(rawStr, decimals) {
const s = String(rawStr || "0");
const neg = s.startsWith("-");
const abs = neg ? s.slice(1) : s;
const d = Number(decimals) || 0;
if (d === 0) return (neg ? "-" : "") + abs;
const pad = abs.padStart(d + 1, "0");
const whole = pad.slice(0, pad.length - d);
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
}
function applyUnitPicker() { function applyUnitPicker() {
const s = sel(); if (!s) return; const s = sel(); if (!s) return;
if (!unit) unit = "big"; if (!unit) unit = "big";
const big = bigUnitLabel(), small = smallUnitLabel(); // ---- SPL asset picker (SOL wallets with tokens) ---------------------
const tokens = (chain() === "sol" && s.tokens) || [];
const assetField = $("sendAssetField");
if (tokens.length) {
assetField.hidden = false;
const sel_ = $("sendAsset");
// Rebuild whenever the asset set changes so a new token appears.
const key = tokens.map((t) => t.mint).join("|");
if (sel_.dataset.key !== key) {
sel_.dataset.key = key;
sel_.innerHTML = `<option value="">SOL — native</option>` + tokens.map((t) =>
`<option value="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${Number(t.decimals) || 0}">${esc(t.symbol)}${t.name ? " · " + esc(t.name) : ""}</option>`
).join("");
sel_.onchange = () => {
const opt = sel_.options[sel_.selectedIndex];
sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null;
applyUnitPicker(); schedulePlan();
};
}
// Reflect the current sendAsset back into the select.
sel_.value = sendAsset ? sendAsset.mint : "";
} else {
assetField.hidden = true;
sendAsset = null;
}
const isToken = sendAsset != null;
const big = isToken ? sendAsset.symbol : bigUnitLabel();
const small = isToken ? "raw" : smallUnitLabel();
$("unitPicker").innerHTML = $("unitPicker").innerHTML =
`<button data-u="big" class="${unit === "big" ? "on" : ""}" type="button">${esc(big)}</button>` + `<button data-u="big" class="${unit === "big" ? "on" : ""}" type="button">${esc(big)}</button>` +
`<button data-u="small" class="${unit === "small" ? "on" : ""}" type="button">${esc(small)}</button>`; `<button data-u="small" class="${unit === "small" ? "on" : ""}" type="button">${esc(small)}</button>`;
@ -316,8 +383,10 @@ function setUnit(u) {
function amountUnits() { function amountUnits() {
const raw = $("sendAmt").value.trim().replace(/,/g, ""); const raw = $("sendAmt").value.trim().replace(/,/g, "");
if (!raw) return 0; if (!raw) return 0;
const d = decimals(); // For SPL tokens the amount is a raw u64 string in the token's own
const bigDecimals = d > 15; // SC (24) needs BigInt to preserve precision. // smallest unit — same BigInt-safe path SC uses.
const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals();
const bigDecimals = sendAsset != null || d > 15;
if (unit === "small") { if (unit === "small") {
if (bigDecimals) return raw.replace(/\D+/g, "") || "0"; if (bigDecimals) return raw.replace(/\D+/g, "") || "0";
return Math.round(Number(raw)); return Math.round(Number(raw));
@ -325,7 +394,6 @@ function amountUnits() {
const [w, f = ""] = raw.split("."); const [w, f = ""] = raw.split(".");
const frac = (f + "0".repeat(d)).slice(0, d); const frac = (f + "0".repeat(d)).slice(0, d);
if (bigDecimals) { if (bigDecimals) {
// "1.234" (24 dp) → BigInt("1000000000000000000000000") + BigInt("234000…")
const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0"); const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0");
return total.toString(); return total.toString();
} }
@ -435,6 +503,16 @@ async function updatePlan() {
$("sendToHint").textContent = ""; $("sendToHint").textContent = "";
if (!to || (!sendMax && !amountUnits())) return; if (!to || (!sendMax && !amountUnits())) return;
try { try {
if (sendAsset) {
// SPL token flow — amount is raw units of the token's decimals.
const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() });
lastPlan = { _token: true, ...p };
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
$("sendBtn").disabled = false;
return;
}
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax }); const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax });
lastPlan = p; lastPlan = p;
@ -455,8 +533,11 @@ $("sendBtn").addEventListener("click", async () => {
const msg = $("sendMsg"); msg.hidden = true; const msg = $("sendMsg"); msg.hidden = true;
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…"; $("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try { try {
const isToken = sendAsset && lastPlan._token;
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
const r = await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax }); const r = isToken
? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() })
: await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax });
msg.className = "msg ok"; msg.className = "msg ok";
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`; msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid))); msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid)));