theseus/bundled-addons/bchwallet/lib/sol-spl.js

221 lines
10 KiB
JavaScript
Raw Normal View History

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