theseus/bundled-addons/bchwallet/lib/sol-spl.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

220 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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