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).
220 lines
10 KiB
JavaScript
220 lines
10 KiB
JavaScript
// 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,
|
||
};
|
||
};
|