221 lines
10 KiB
JavaScript
221 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,
|
|||
|
|
};
|
|||
|
|
};
|