diff --git a/CHANGELOG.md b/CHANGELOG.md index d104bccb1..761591c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- USDC (non-native quote-mint) support for the launch path: optional `quoteMint`/`quoteTokenProgram` on `createV2Instruction`, `buyInstructions`, `getBuyInstructionRaw`, and `createV2AndBuyInstructions` (plus an explicit `quoteAmount` cap), a new `buyV2` builder, `USDC_MINT`/`QUOTE_MINTS`/`isNativeQuote`, `BUYBACK_FEE_RECIPIENTS`/`pickBuybackFeeRecipient`, `OnlinePumpSdk` quote forwarding, and the `buy_v2` instruction in the bundled IDL. Existing SOL behavior is unchanged (all new params default to wrapped SOL). +- `scripts/devnet-usdc-smoke.ts`: a devnet/simulate verification harness for the USDC launch path. + +### Notes +- A USDC `createV2AndBuyInstructions` transaction (~1361 bytes) exceeds the 1232-byte single-transaction limit and must be sent as a v0 transaction with an Address Lookup Table. The SOL path fits without one. + ## [1.32.0] - 2026-04-23 Prepares the SDK for the **2026-04-28, 16:00 UTC** breaking on-chain program upgrade to the Pump bonding curve and PumpSwap AMM programs. See [docs/pump-public-docs/BREAKING_FEE_RECIPIENT.md](docs/pump-public-docs/BREAKING_FEE_RECIPIENT.md) for the protocol spec and [docs/MIGRATION.md](docs/MIGRATION.md#upgrading-to-v1320-latest) for call-site guidance. diff --git a/scripts/devnet-usdc-smoke.ts b/scripts/devnet-usdc-smoke.ts new file mode 100644 index 000000000..b75c63bd2 --- /dev/null +++ b/scripts/devnet-usdc-smoke.ts @@ -0,0 +1,223 @@ +/** + * USDC launch-path verification harness for `createV2AndBuyInstructions`. + * + * Builds an atomic create_v2 + buy_v2 (USDC quote mint) transaction and either + * SIMULATES it (default, no broadcast) or broadcasts it once on devnet. + * + * The USDC create_v2 + buy_v2 set serializes to ~1361 bytes across 32 unique + * accounts, which exceeds the 1232-byte single-transaction limit. It therefore + * MUST be sent as a v0 transaction with an Address Lookup Table (ALT). This + * harness builds an ephemeral ALT over the static (non-signer) accounts, waits + * for it to activate, then compiles the create+buy into a v0 message that + * references it. (The SOL path fits without an ALT.) + * + * This is NOT part of the unit suite — it requires an RPC and a funded keypair. + * + * Usage: + * # Simulate only (no broadcast). Needs an RPC and a keypair (payer/mint signer). + * # Still creates + confirms a real ephemeral ALT on-chain (a cheap tx), so the + * # keypair must be funded even in simulate mode. + * RPC_URL= KEYPAIR=~/.config/solana/id.json npx tsx scripts/devnet-usdc-smoke.ts + * + * # Broadcast once on devnet (free SOL via airdrop). Set the devnet-whitelisted + * # quote mint if USDC itself isn't whitelisted on devnet. + * RPC_URL=https://api.devnet.solana.com DEVNET_BROADCAST=1 \ + * DEVNET_QUOTE_MINT= KEYPAIR= \ + * npx tsx scripts/devnet-usdc-smoke.ts + */ +import { readFileSync } from "node:fs"; +import { + AddressLookupTableAccount, + AddressLookupTableProgram, + ComputeBudgetProgram, + Connection, + Keypair, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import BN from "bn.js"; + +// Import from the package index (not deep paths): the index evaluates `./sdk` +// before `./onlineSdk`, which is the order that avoids the sdk<->onlineSdk +// circular-init ordering issue. This is also how real consumers import. +import { OnlinePumpSdk, PUMP_SDK, USDC_MINT } from "../src"; + +function loadKeypair(path: string): Keypair { + return Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(readFileSync(path, "utf8"))), + ); +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Collect the unique account keys referenced across `ixs` (including each + * instruction's programId), EXCLUDING the static signer keys (`exclude`). The + * payer and the mint keypair must remain static signer keys in the compiled + * message — they cannot be sourced from an ALT — so we never put them in the + * lookup table. + */ +function altAddresses( + ixs: TransactionInstruction[], + exclude: PublicKey[], +): PublicKey[] { + const excluded = new Set(exclude.map((k) => k.toBase58())); + const seen = new Map(); + for (const ix of ixs) { + const candidates = [ix.programId, ...ix.keys.map((k) => k.pubkey)]; + for (const key of candidates) { + const b58 = key.toBase58(); + if (excluded.has(b58)) continue; + if (!seen.has(b58)) seen.set(b58, key); + } + } + return [...seen.values()]; +} + +/** + * Create an ephemeral ALT over `addresses`, then poll until it is fetchable and + * populated. A newly created table can only be referenced once the slot it was + * created in is confirmed, so we bound-poll `getAddressLookupTable` (re-fetching + * the slot too) with a short delay between tries. + * + * PRODUCTION NOTE: launching many coins should reuse ONE long-lived ALT of the + * static pump accounts (global, programs, fee recipients, etc.) rather than + * creating + paying for a fresh ephemeral table per launch. + */ +async function buildEphemeralAlt( + connection: Connection, + payer: Keypair, + addresses: PublicKey[], +): Promise { + const recentSlot = await connection.getSlot("finalized"); + const [createIx, altAddress] = AddressLookupTableProgram.createLookupTable({ + authority: payer.publicKey, + payer: payer.publicKey, + recentSlot, + }); + + const sendIxs = async (instructions: TransactionInstruction[]): Promise => { + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + const message = new TransactionMessage({ + payerKey: payer.publicKey, + recentBlockhash: blockhash, + instructions, + }).compileToV0Message(); + const tx = new VersionedTransaction(message); + tx.sign([payer]); + const sig = await connection.sendTransaction(tx); + await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, "confirmed"); + }; + + // `extendLookupTable` with many addresses overflows a single tx, so extend in + // batches (~20 addresses/tx). The create + first batch share one tx. + const BATCH = 20; + const extendFor = (addrs: PublicKey[]) => + AddressLookupTableProgram.extendLookupTable({ + lookupTable: altAddress, + authority: payer.publicKey, + payer: payer.publicKey, + addresses: addrs, + }); + await sendIxs([createIx, extendFor(addresses.slice(0, BATCH))]); + for (let i = BATCH; i < addresses.length; i += BATCH) { + await sendIxs([extendFor(addresses.slice(i, i + BATCH))]); + } + console.log("created ephemeral ALT:", altAddress.toBase58()); + + // Bound-poll until the ALT resolves and is populated. A freshly-extended table + // can only be referenced starting the slot AFTER its last extension, so once the + // addresses are present we also wait for the slot to advance before returning. + for (let attempt = 0; attempt < 30; attempt++) { + const fetched = await connection.getAddressLookupTable(altAddress); + const account = fetched.value; + if (account && account.state.addresses.length >= addresses.length) { + const warmAt = await connection.getSlot(); + while ((await connection.getSlot()) <= warmAt + 1) await sleep(400); + const ready = (await connection.getAddressLookupTable(altAddress)).value!; + console.log("ALT active with", ready.state.addresses.length, "addresses"); + return ready; + } + await sleep(800); + } + throw new Error(`ALT ${altAddress.toBase58()} did not activate in time`); +} + +async function main(): Promise { + const rpcUrl = process.env.RPC_URL; + const keypairPath = process.env.KEYPAIR; + if (!rpcUrl || !keypairPath) { + throw new Error("Set RPC_URL and KEYPAIR env vars."); + } + + const connection = new Connection(rpcUrl, "confirmed"); + const sdk = new OnlinePumpSdk(connection); + const payer = loadKeypair(keypairPath); + const mint = Keypair.generate(); + const quoteMint = process.env.DEVNET_QUOTE_MINT + ? new PublicKey(process.env.DEVNET_QUOTE_MINT) + : USDC_MINT; + + // Fetch global once and call the lower-level builder directly so we can pass an + // explicit `amount` (1.5% of supply). The OnlinePumpSdk wrapper instead derives + // `amount` from `solAmount` and does not take `global`/`amount`. + const global = await sdk.fetchGlobal(); + const ixs = await PUMP_SDK.createV2AndBuyInstructions({ + global, + mint: mint.publicKey, + name: "SMOKE", + symbol: "SMOKE", + uri: "https://example.com/smoke.json", + creator: payer.publicKey, + user: payer.publicKey, + amount: new BN("15000000000000"), // 1.5% of supply (token base units) + solAmount: new BN(0), + mayhemMode: false, + quoteMint, + quoteTokenProgram: TOKEN_PROGRAM_ID, + quoteAmount: new BN(process.env.QUOTE_AMOUNT ?? "15000000"), // max quote cost (6dp) + }); + + // The USDC create+buy is too large for a single tx; build an ALT over the + // static (non-signer) accounts. The payer and mint stay as static signer keys. + const altAccount = await buildEphemeralAlt( + connection, + payer, + altAddresses(ixs, [payer.publicKey, mint.publicKey]), + ); + + const { blockhash } = await connection.getLatestBlockhash(); + const message = new TransactionMessage({ + payerKey: payer.publicKey, + recentBlockhash: blockhash, + instructions: [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ...ixs, + ], + }).compileToV0Message([altAccount]); + const tx = new VersionedTransaction(message); + tx.sign([payer, mint]); + + console.log("create+buy v0 tx size (bytes):", tx.serialize().length); + + const sim = await connection.simulateTransaction(tx, { sigVerify: false }); + console.log("simulate err:", sim.value.err); + console.log((sim.value.logs ?? []).slice(-15).join("\n")); + if (sim.value.err) { + throw new Error("simulation failed — do NOT broadcast"); + } + + if (process.env.DEVNET_BROADCAST === "1") { + const sig = await connection.sendTransaction(tx); + console.log("devnet broadcast sig:", sig); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/__tests__/onlineSdk.test.ts b/src/__tests__/onlineSdk.test.ts index 92af15571..2fc4e418f 100644 --- a/src/__tests__/onlineSdk.test.ts +++ b/src/__tests__/onlineSdk.test.ts @@ -6,11 +6,13 @@ * These tests mock the RPC connection and underlying SDK calls so no network * access is required. */ -import { AccountInfo, PublicKey } from "@solana/web3.js"; +import { AccountInfo, Keypair, PublicKey } from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; import BN from "bn.js"; -import { OnlinePumpSdk, BuyQuote } from "../onlineSdk"; +import { OnlinePumpSdk, BuyQuote, OFFLINE_PUMP_PROGRAM } from "../onlineSdk"; import { PUMP_SDK } from "../sdk"; +import { USDC_MINT } from "../quoteMints"; import { bondingCurvePda } from "../pda"; import { makeGlobal, @@ -754,3 +756,72 @@ describe("OnlinePumpSdk.getTokenBalance", () => { expect(bal.toString()).toBe("5000000000"); }); }); + +// ─── USDC quote forwarding (Task 7b) ────────────────────────────────────────── +// Proves the OnlinePumpSdk wrappers forward the new quote params +// (quoteMint / quoteTokenProgram / quoteAmount) through to PUMP_SDK.*. +describe("OnlinePumpSdk USDC quote forwarding", () => { + // The wrappers delegate to the offline PUMP_SDK instruction builders, which + // need PUMP_SDK's offline anchor program. sdk.ts <-> onlineSdk.ts form a + // circular import; because this suite imports "../onlineSdk" first, the + // PUMP_SDK singleton constructed mid-cycle captured an undefined offline + // program. Heal it from the now-initialized OFFLINE_PUMP_PROGRAM so the + // offline builders the wrappers call work. (Existing suites import order is + // unchanged; the AMM-event decode tests still rely on it.) + beforeAll(() => { + if (!(PUMP_SDK as any).offlinePumpProgram) { + (PUMP_SDK as any).offlinePumpProgram = OFFLINE_PUMP_PROGRAM; + } + }); + + it("createV2Instruction forwards quoteMint (USDC → create_v2 has 19 keys)", async () => { + // createV2Instruction needs no on-chain fetch, so a bare sdk suffices. + const sdk = makeSdk(); + const mint = new Keypair().publicKey; + + const ix = await sdk.createV2Instruction({ + mint, + name: "n", + symbol: "n", + uri: "u", + creator: TEST_CREATOR, + user: USER, + quoteMint: USDC_MINT, + quoteTokenProgram: TOKEN_PROGRAM_ID, + }); + + // USDC appends the 3 quote remaining accounts (16 → 19) vs the SOL default. + expect(ix.keys).toHaveLength(19); + expect(ix.keys.some((k) => k.pubkey.equals(USDC_MINT))).toBe(true); + }); + + it("createV2AndBuyInstructions forwards quoteMint/quoteTokenProgram/quoteAmount (buy leg is buy_v2)", async () => { + const sdk = makeSdk(); + // The wrapper fetches global + feeConfig; both are spy-able (no RPC needed). + jest.spyOn(sdk, "fetchGlobal").mockResolvedValue(makeGlobal()); + jest.spyOn(sdk, "fetchFeeConfig").mockResolvedValue(makeFeeConfig()); + const mint = new Keypair().publicKey; + + const ixs = await sdk.createV2AndBuyInstructions({ + mint, + name: "n", + symbol: "n", + uri: "u", + creator: TEST_CREATOR, + user: USER, + solAmount: new BN(0), + quoteMint: USDC_MINT, + quoteTokenProgram: TOKEN_PROGRAM_ID, + quoteAmount: new BN("15000000"), + }); + + // create leg gains the 3 USDC quote accounts (19 keys). + expect(ixs[0]!.keys).toHaveLength(19); + // buy leg routes to buy_v2: official discriminator + 27 accounts. + const buyLeg = ixs.at(-1)!; + expect([...buyLeg.data.slice(0, 8)]).toEqual([ + 184, 23, 238, 97, 103, 197, 211, 61, + ]); + expect(buyLeg.keys).toHaveLength(27); + }); +}); diff --git a/src/__tests__/usdc.test.ts b/src/__tests__/usdc.test.ts new file mode 100644 index 000000000..5ca1e0207 --- /dev/null +++ b/src/__tests__/usdc.test.ts @@ -0,0 +1,377 @@ +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + NATIVE_MINT, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { + AccountInfo, + Connection, + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { USDC_MINT, QUOTE_MINTS, isNativeQuote } from "../quoteMints"; +import { getPumpProgram, PUMP_SDK, BONDING_CURVE_NEW_SIZE } from "../sdk"; +import { bondingCurvePda, creatorVaultPda } from "../pda"; +import pumpIdl from "../idl/pump.json"; +import { + BUYBACK_FEE_RECIPIENTS, + pickBuybackFeeRecipient, + BREAKING_FEE_RECIPIENTS, + getFeeRecipient, +} from "../fees"; +import { makeGlobal, makeBondingCurve } from "./fixtures"; + +describe("quoteMints", () => { + it("exposes the canonical mainnet USDC mint (6 decimals, SPL Token program)", () => { + expect(USDC_MINT.toBase58()).toBe("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + expect(QUOTE_MINTS.USDC.mint.equals(USDC_MINT)).toBe(true); + expect(QUOTE_MINTS.USDC.decimals).toBe(6); + expect(QUOTE_MINTS.USDC.tokenProgram.equals(TOKEN_PROGRAM_ID)).toBe(true); + expect(QUOTE_MINTS.USDC.tokenProgram.equals(TOKEN_2022_PROGRAM_ID)).toBe(false); + }); + it("models wSOL with 9 decimals under the SPL Token program", () => { + expect(QUOTE_MINTS.wSOL.mint.equals(NATIVE_MINT)).toBe(true); + expect(QUOTE_MINTS.wSOL.decimals).toBe(9); + expect(QUOTE_MINTS.wSOL.tokenProgram.equals(TOKEN_PROGRAM_ID)).toBe(true); + }); + it("isNativeQuote distinguishes wSOL from USDC", () => { + expect(isNativeQuote(NATIVE_MINT)).toBe(true); + expect(isNativeQuote(USDC_MINT)).toBe(false); + }); +}); + +describe("bundled IDL: buy_v2", () => { + // Assert against the raw bundled IDL JSON: Anchor's Program constructor + // (v0.31) normalizes instruction/field names to camelCase, so program.idl + // would expose "buyV2"/"maxSolCost" instead of the on-disk snake_case names. + // The program is still constructed to prove the bundled IDL loads cleanly. + const program = getPumpProgram(new Connection("http://localhost:8899")); + it("includes buy_v2 with the official discriminator and exactly 2 args", () => { + expect(program.idl.instructions.some((i) => i.name === "buyV2")).toBe(true); + const ix = (pumpIdl.instructions as any[]).find((i) => i.name === "buy_v2"); + expect(ix).toBeDefined(); + expect(ix.discriminator).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + expect(ix.args.map((a: any) => a.name)).toEqual(["amount", "max_sol_cost"]); + expect(ix.accounts).toHaveLength(27); + }); +}); + +describe("buyback fee recipients", () => { + it("are the 8 official buyback recipients (same set as BREAKING_FEE_RECIPIENTS)", () => { + expect(BUYBACK_FEE_RECIPIENTS.map((p) => p.toBase58())).toEqual( + BREAKING_FEE_RECIPIENTS.map((p) => p.toBase58()), + ); + expect(BUYBACK_FEE_RECIPIENTS).toHaveLength(8); + expect(BUYBACK_FEE_RECIPIENTS[0]!.toBase58()).toBe( + "5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD", + ); + }); + it("pickBuybackFeeRecipient returns one of the set", () => { + for (let i = 0; i < 100; i++) { + const picked = pickBuybackFeeRecipient(); + expect(BUYBACK_FEE_RECIPIENTS.some((p) => p.equals(picked))).toBe(true); + } + }); +}); + +describe("createV2Instruction quote mint", () => { + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + const baseArgs = { mint, name: "n", symbol: "n", uri: "u", creator, user, mayhemMode: false }; + + it("SOL (default): exactly 16 accounts, no remaining accounts appended", async () => { + const ix = await PUMP_SDK.createV2Instruction(baseArgs); + expect(ix.keys).toHaveLength(16); + }); + it("explicit NATIVE_MINT behaves like SOL (16 accounts)", async () => { + const ix = await PUMP_SDK.createV2Instruction({ ...baseArgs, quoteMint: NATIVE_MINT }); + expect(ix.keys).toHaveLength(16); + }); + it("USDC: appends exactly the 3 quote remaining accounts in order", async () => { + const ix = await PUMP_SDK.createV2Instruction({ ...baseArgs, quoteMint: USDC_MINT }); + expect(ix.keys).toHaveLength(19); + const [r0, r1, r2] = ix.keys.slice(16); + expect(r0!.pubkey.equals(USDC_MINT)).toBe(true); + expect(r0!.isWritable).toBe(false); + expect(r1!.pubkey.equals(getAssociatedTokenAddressSync(USDC_MINT, bondingCurvePda(mint), true, TOKEN_PROGRAM_ID))).toBe(true); + expect(r1!.isWritable).toBe(true); + expect(r2!.pubkey.equals(TOKEN_PROGRAM_ID)).toBe(true); + expect(r2!.isWritable).toBe(false); + }); + it("threads an explicit non-default quoteTokenProgram into createV2Instruction", async () => { + // Stand-in: drive the quote program off USDC's default (TOKEN_PROGRAM_ID) so + // the threading is observable. R2 (the quote_token_program remaining account) + // must equal it, and R1 (associated_quote_bonding_curve) must be the ATA + // derived *with* that program (a different ATA than the default would yield). + const ix = await PUMP_SDK.createV2Instruction({ + mint, name: "n", symbol: "n", uri: "u", creator, user, mayhemMode: false, + quoteMint: USDC_MINT, quoteTokenProgram: TOKEN_2022_PROGRAM_ID, + }); + expect(ix.keys).toHaveLength(19); + const [, r1, r2] = ix.keys.slice(16); + expect(r2!.pubkey.equals(TOKEN_2022_PROGRAM_ID)).toBe(true); + expect( + r1!.pubkey.equals( + getAssociatedTokenAddressSync(USDC_MINT, bondingCurvePda(mint), true, TOKEN_2022_PROGRAM_ID), + ), + ).toBe(true); + }); +}); + +describe("createV2AndBuyInstructions USDC quoteAmount", () => { + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + + it("encodes quoteAmount (not solAmount) as the buy_v2 cap", async () => { + const quoteAmount = new BN("12345678"); + const ixs = await PUMP_SDK.createV2AndBuyInstructions({ + global: makeGlobal(), mint, name: "n", symbol: "n", uri: "u", + creator, user, amount: new BN("15000000000000"), mayhemMode: false, + solAmount: new BN("999"), // deliberately different from quoteAmount + quoteMint: USDC_MINT, quoteTokenProgram: TOKEN_PROGRAM_ID, quoteAmount, + }); + const buyLeg = ixs.at(-1)!; + // buy_v2 data = disc[8] + amount u64 (LE) [8,16) + max_sol_cost u64 (LE) [16,24). + // max_sol_cost is the quote cap; assert it is the quoteAmount we passed, not solAmount. + expect(buyLeg.data).toHaveLength(24); + expect([...buyLeg.data.slice(0, 8)]).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + const capLE = buyLeg.data.subarray(16, 24); + expect(new BN(capLE, "le").toString()).toBe(quoteAmount.toString()); + }); +}); + +describe("createV2AndBuyInstructions tx-size / ALT constraint", () => { + // OPERATIONAL FINDING: the atomic USDC `create_v2 + buy_v2` instruction set is + // too large for a single legacy/v0 transaction without an Address Lookup Table — + // its combined unique account set is larger, and the compiled v0 message + // serializes to MORE than the 1232-byte packet limit. The SOL path fits without + // one. Senders launching a USDC coin with a dev-buy MUST attach an ALT. + // + // NOTE: compileToV0Message()/serialize() do NOT throw at this size (the RangeError + // only fires above ~256 account keys), so we assert the measured byte size and the + // unique-account count directly rather than relying on a throw. + const PACKET_DATA_SIZE = 1232; // Solana max serialized transaction size. + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + const common = { + global: makeGlobal(), mint, name: "n", symbol: "n", uri: "u", + creator, user, amount: new BN("15000000000000"), mayhemMode: false, + }; + + function uniqueAccounts(ixs: { programId: PublicKey; keys: { pubkey: PublicKey }[] }[]): number { + const seen = new Set(); + for (const ix of ixs) { + seen.add(ix.programId.toBase58()); + for (const k of ix.keys) seen.add(k.pubkey.toBase58()); + } + return seen.size; + } + + function serializedV0Size(ixs: any[]): number { + const message = new TransactionMessage({ + payerKey: user, + recentBlockhash: "11111111111111111111111111111111", // dummy blockhash + instructions: ixs, + }).compileToV0Message(); + return new VersionedTransaction(message).serialize().length; + } + + it("SOL fits in one legacy tx but USDC needs an Address Lookup Table", async () => { + const solIxs = await PUMP_SDK.createV2AndBuyInstructions({ ...common, solAmount: new BN("430000000") }); + const usdcIxs = await PUMP_SDK.createV2AndBuyInstructions({ + ...common, solAmount: new BN(0), + quoteMint: USDC_MINT, quoteTokenProgram: TOKEN_PROGRAM_ID, quoteAmount: new BN("15000000"), + }); + + const solUnique = uniqueAccounts(solIxs); + const usdcUnique = uniqueAccounts(usdcIxs); + // Measured (deterministic given fixed keypairs): SOL=24, USDC=32 unique accounts. + expect(usdcUnique).toBeGreaterThan(solUnique); + expect(usdcUnique).toBeGreaterThanOrEqual(32); + + const solSize = serializedV0Size(solIxs); + const usdcSize = serializedV0Size(usdcIxs); + // Measured (deterministic): SOL=1084 bytes (fits), USDC=1361 bytes (exceeds 1232). + expect(solSize).toBeLessThanOrEqual(PACKET_DATA_SIZE); + expect(usdcSize).toBeGreaterThan(PACKET_DATA_SIZE); + }); +}); + +describe("buyV2 builder (USDC)", () => { + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + + it("builds buy_v2 with 27 accounts, 2-arg data, USDC quote wiring", async () => { + const ix = await PUMP_SDK.buyV2({ + user, mint, creator, + amount: new BN("15000000000000"), + quoteAmount: new BN("15000000"), + quoteMint: USDC_MINT, + quoteTokenProgram: TOKEN_PROGRAM_ID, + feeRecipient: getFeeRecipient(makeGlobal(), false), + buybackFeeRecipient: pickBuybackFeeRecipient(), + }); + expect(ix.keys).toHaveLength(27); + expect(ix.keys[2]!.pubkey.equals(USDC_MINT)).toBe(true); + expect(ix.keys[3]!.pubkey.equals(TOKEN_2022_PROGRAM_ID)).toBe(true); + expect(ix.keys[4]!.pubkey.equals(TOKEN_PROGRAM_ID)).toBe(true); + expect(ix.keys[13]!.isSigner).toBe(true); + expect(ix.keys[12]!.pubkey.equals(getAssociatedTokenAddressSync(USDC_MINT, bondingCurvePda(mint), true, TOKEN_PROGRAM_ID))).toBe(true); + expect(ix.keys[16]!.pubkey.equals(creatorVaultPda(creator))).toBe(true); + expect(ix.data).toHaveLength(24); + expect([...ix.data.slice(0, 8)]).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + }); +}); + +describe("getBuyInstructionRaw routing", () => { + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + + it("SOL (default): emits legacy buy (disc 0x66063d12), unchanged", async () => { + const ix = await PUMP_SDK.getBuyInstructionRaw({ + user, mint, creator, amount: new BN(1), solAmount: new BN(1), + feeRecipient: getFeeRecipient(makeGlobal(), false), + }); + expect([...ix.data.slice(0, 8)]).toEqual([102, 6, 61, 18, 1, 218, 235, 234]); + expect(ix.keys.at(-1)!.isWritable).toBe(true); // breaking fee recipient appended last + }); + it("USDC: routes to buy_v2 (disc 0xb817ee…)", async () => { + const ix = await PUMP_SDK.getBuyInstructionRaw({ + user, mint, creator, amount: new BN("15000000000000"), solAmount: new BN("15000000"), + feeRecipient: getFeeRecipient(makeGlobal(), false), + quoteMint: USDC_MINT, quoteTokenProgram: TOKEN_PROGRAM_ID, + }); + expect([...ix.data.slice(0, 8)]).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + expect(ix.keys).toHaveLength(27); + }); +}); + +describe("buyInstructions routing (USDC)", () => { + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + + // A "new" bonding curve account (data >= BONDING_CURVE_NEW_SIZE) so the legacy + // path would not prepend extendAccount — though the USDC branch returns before + // that check anyway. Shape mirrors makeBcAccountInfo in onlineSdk.test.ts. + function makeBcAccountInfo(): AccountInfo { + return { + data: Buffer.alloc(BONDING_CURVE_NEW_SIZE), + executable: false, + lamports: 1_000_000, + owner: new PublicKey("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"), + rentEpoch: 0, + }; + } + + it("prepends user base + quote ATA creates and ends with buy_v2", async () => { + const ixs = await PUMP_SDK.buyInstructions({ + global: makeGlobal(), + bondingCurveAccountInfo: makeBcAccountInfo(), + bondingCurve: makeBondingCurve({ creator, isMayhemMode: false }), + associatedUserAccountInfo: null, + mint, + user, + amount: new BN("15000000000000"), + solAmount: new BN("15000000"), + slippage: 1, + tokenProgram: TOKEN_PROGRAM_ID, + quoteMint: USDC_MINT, + quoteTokenProgram: TOKEN_PROGRAM_ID, + }); + + // Last instruction is buy_v2 (27 accounts, official discriminator). + const last = ixs.at(-1)!; + expect([...last.data.slice(0, 8)]).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + expect(last.keys).toHaveLength(27); + + // At least 2 ATA-program create instructions precede it: the user's base + // Token-2022 ATA and the user's USDC quote ATA. Without these, a standalone + // USDC buy for a fresh wallet would fail. + const ataCreates = ixs.filter((ix) => + ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID), + ); + expect(ataCreates.length).toBeGreaterThanOrEqual(2); + + // The base ATA create targets the user's Token-2022 ATA for the base mint. + const baseAta = getAssociatedTokenAddressSync(mint, user, true, TOKEN_2022_PROGRAM_ID); + expect( + ataCreates.some((ix) => ix.keys.some((k) => k.pubkey.equals(baseAta))), + ).toBe(true); + + // The quote ATA create targets the user's USDC (SPL Token) ATA. + const quoteAta = getAssociatedTokenAddressSync(USDC_MINT, user, true, TOKEN_PROGRAM_ID); + expect( + ataCreates.some((ix) => ix.keys.some((k) => k.pubkey.equals(quoteAta))), + ).toBe(true); + }); + + it("still creates the quote ATA when the base ATA already exists", async () => { + const ixs = await PUMP_SDK.buyInstructions({ + global: makeGlobal(), + bondingCurveAccountInfo: makeBcAccountInfo(), + bondingCurve: makeBondingCurve({ creator, isMayhemMode: false }), + // base ATA present -> skip base create, but quote create must remain. + associatedUserAccountInfo: makeBcAccountInfo(), + mint, + user, + amount: new BN("15000000000000"), + solAmount: new BN("15000000"), + slippage: 1, + tokenProgram: TOKEN_PROGRAM_ID, + quoteMint: USDC_MINT, + quoteTokenProgram: TOKEN_PROGRAM_ID, + }); + + const last = ixs.at(-1)!; + expect([...last.data.slice(0, 8)]).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + + const quoteAta = getAssociatedTokenAddressSync(USDC_MINT, user, true, TOKEN_PROGRAM_ID); + const ataCreates = ixs.filter((ix) => + ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID), + ); + expect(ataCreates.length).toBeGreaterThanOrEqual(1); + expect( + ataCreates.some((ix) => ix.keys.some((k) => k.pubkey.equals(quoteAta))), + ).toBe(true); + }); +}); + +describe("createV2AndBuyInstructions", () => { + const mint = new Keypair().publicKey; + const creator = new Keypair().publicKey; + const user = new Keypair().publicKey; + const common = { + global: makeGlobal(), mint, name: "n", symbol: "n", uri: "u", + creator, user, amount: new BN("15000000000000"), mayhemMode: false, + }; + it("SOL: 4 instructions, create_v2 has 16 keys, buy leg is legacy buy", async () => { + const ixs = await PUMP_SDK.createV2AndBuyInstructions({ ...common, solAmount: new BN("430000000") }); + expect(ixs).toHaveLength(4); + expect(ixs[0]!.keys).toHaveLength(16); + expect([...ixs[3]!.data.slice(0, 8)]).toEqual([102, 6, 61, 18, 1, 218, 235, 234]); + }); + it("USDC: create_v2 has 19 keys; user USDC ATA created; buy leg is buy_v2 capped by quoteAmount", async () => { + const ixs = await PUMP_SDK.createV2AndBuyInstructions({ + ...common, solAmount: new BN(0), + quoteMint: USDC_MINT, quoteTokenProgram: TOKEN_PROGRAM_ID, quoteAmount: new BN("15000000"), + }); + expect(ixs[0]!.keys).toHaveLength(19); + const buyLeg = ixs.at(-1)!; + expect([...buyLeg.data.slice(0, 8)]).toEqual([184, 23, 238, 97, 103, 197, 211, 61]); + expect(buyLeg.keys).toHaveLength(27); + // the user's USDC ATA is created (associated-token-program ix targeting the user's USDC ATA) + const userUsdcAta = getAssociatedTokenAddressSync(USDC_MINT, user, true, TOKEN_PROGRAM_ID); + expect(ixs.some((ix) => ix.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) && ix.keys.some((k) => k.pubkey.equals(userUsdcAta)))).toBe(true); + }); +}); diff --git a/src/fees.ts b/src/fees.ts index e13854b77..4e799a2f9 100644 --- a/src/fees.ts +++ b/src/fees.ts @@ -176,6 +176,18 @@ export function pickBreakingFeeRecipient(): PublicKey { ]!; } +/** + * The 8 buyback fee recipients (official `FEE_RECIPIENTS.md`). `buy_v2`/`sell_v2` + * take a `buybackFeeRecipient` from this set. These are the same 8 addresses that + * the 2026-04-28 upgrade appends to legacy buy/sell (a.k.a. {@link BREAKING_FEE_RECIPIENTS}). + */ +export const BUYBACK_FEE_RECIPIENTS = BREAKING_FEE_RECIPIENTS; + +/** Pick one of the 8 buyback fee recipients at random (for `buy_v2`/`sell_v2`). */ +export function pickBuybackFeeRecipient(): PublicKey { + return pickBreakingFeeRecipient(); +} + /** * Pre-computed WSOL ATAs for each of the 8 breaking fee recipients, keyed by * recipient base58 address. Use this in high-throughput paths (e.g. AMM trading diff --git a/src/idl/pump.json b/src/idl/pump.json index 113850176..74f1394e9 100644 --- a/src/idl/pump.json +++ b/src/idl/pump.json @@ -790,6 +790,794 @@ ] }, { + "name": "buy_v2", + "discriminator": [ + 184, + 23, + 238, + 97, + 103, + 197, + 211, + 61 + ], + "accounts": [ + { + "name": "global", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108 + ] + } + ] + } + }, + { + "name": "base_mint" + }, + { + "name": "quote_mint" + }, + { + "name": "base_token_program" + }, + { + "name": "quote_token_program" + }, + { + "name": "associated_token_program", + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + }, + { + "name": "fee_recipient", + "writable": true + }, + { + "name": "associated_quote_fee_recipient", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "fee_recipient" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "buyback_fee_recipient", + "writable": true + }, + { + "name": "associated_quote_buyback_fee_recipient", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "buyback_fee_recipient" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "bonding_curve", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 98, + 111, + 110, + 100, + 105, + 110, + 103, + 45, + 99, + 117, + 114, + 118, + 101 + ] + }, + { + "kind": "account", + "path": "base_mint" + } + ] + } + }, + { + "name": "associated_base_bonding_curve", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "bonding_curve" + }, + { + "kind": "account", + "path": "base_token_program" + }, + { + "kind": "account", + "path": "base_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "associated_quote_bonding_curve", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "bonding_curve" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "associated_base_user", + "writable": true + }, + { + "name": "associated_quote_user", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "user" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "creator_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 99, + 114, + 101, + 97, + 116, + 111, + 114, + 45, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "bonding_curve.creator", + "account": "BondingCurve" + } + ] + } + }, + { + "name": "associated_creator_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "creator_vault" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "sharing_config", + "docs": [ + "seeds; the account is intentionally not deserialized here because it may be uninitialized", + "for mints that have not created a fee sharing config. Handlers must check", + "`data_is_empty()` / owner before reading." + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 104, + 97, + 114, + 105, + 110, + 103, + 45, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "base_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 12, + 53, + 255, + 169, + 5, + 90, + 142, + 86, + 141, + 168, + 247, + 188, + 7, + 86, + 21, + 39, + 76, + 241, + 201, + 44, + 164, + 31, + 64, + 0, + 156, + 81, + 106, + 164, + 20, + 194, + 124, + 112 + ] + } + } + }, + { + "name": "global_volume_accumulator", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 95, + 118, + 111, + 108, + 117, + 109, + 101, + 95, + 97, + 99, + 99, + 117, + 109, + 117, + 108, + 97, + 116, + 111, + 114 + ] + } + ] + } + }, + { + "name": "user_volume_accumulator", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 117, + 115, + 101, + 114, + 95, + 118, + 111, + 108, + 117, + 109, + 101, + 95, + 97, + 99, + 99, + 117, + 109, + 117, + 108, + 97, + 116, + 111, + 114 + ] + }, + { + "kind": "account", + "path": "user" + } + ] + } + }, + { + "name": "associated_user_volume_accumulator", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "user_volume_accumulator" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "fee_config", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 102, + 101, + 101, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "const", + "value": [ + 1, + 86, + 224, + 246, + 147, + 102, + 90, + 207, + 68, + 219, + 21, + 104, + 191, + 23, + 91, + 170, + 81, + 137, + 203, + 151, + 245, + 210, + 255, + 59, + 101, + 93, + 43, + 182, + 253, + 109, + 24, + 176 + ] + } + ], + "program": { + "kind": "account", + "path": "fee_program" + } + } + }, + { + "name": "fee_program", + "address": "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + }, + { + "name": "event_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 95, + 95, + 101, + 118, + 101, + 110, + 116, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121 + ] + } + ] + } + }, + { + "name": "program", + "address": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "max_sol_cost", + "type": "u64" + } + ] + }, { "name": "buy_exact_sol_in", "docs": [ "Given a budget of spendable SOL, buy at least min_tokens_out tokens.", diff --git a/src/idl/pump.ts b/src/idl/pump.ts index c67723618..83341231f 100644 --- a/src/idl/pump.ts +++ b/src/idl/pump.ts @@ -712,6 +712,768 @@ export interface Pump { }, ]; }, + { + name: "buyV2"; + discriminator: [184, 23, 238, 97, 103, 197, 211, 61]; + accounts: [ + { + name: "global"; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + }, + ]; + }; + }, + { + name: "baseMint"; + }, + { + name: "quoteMint"; + }, + { + name: "baseTokenProgram"; + }, + { + name: "quoteTokenProgram"; + }, + { + name: "associatedTokenProgram"; + address: "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + }, + { + name: "feeRecipient"; + writable: true; + }, + { + name: "associatedQuoteFeeRecipient"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "feeRecipient"; + }, + { + kind: "account"; + path: "quoteTokenProgram"; + }, + { + kind: "account"; + path: "quoteMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "buybackFeeRecipient"; + writable: true; + }, + { + name: "associatedQuoteBuybackFeeRecipient"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "buybackFeeRecipient"; + }, + { + kind: "account"; + path: "quoteTokenProgram"; + }, + { + kind: "account"; + path: "quoteMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "bondingCurve"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 98, + 111, + 110, + 100, + 105, + 110, + 103, + 45, + 99, + 117, + 114, + 118, + 101, + ]; + }, + { + kind: "account"; + path: "baseMint"; + }, + ]; + }; + }, + { + name: "associatedBaseBondingCurve"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "bondingCurve"; + }, + { + kind: "account"; + path: "baseTokenProgram"; + }, + { + kind: "account"; + path: "baseMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "associatedQuoteBondingCurve"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "bondingCurve"; + }, + { + kind: "account"; + path: "quoteTokenProgram"; + }, + { + kind: "account"; + path: "quoteMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "user"; + writable: true; + signer: true; + }, + { + name: "associatedBaseUser"; + writable: true; + }, + { + name: "associatedQuoteUser"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "user"; + }, + { + kind: "account"; + path: "quoteTokenProgram"; + }, + { + kind: "account"; + path: "quoteMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "creatorVault"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 99, + 114, + 101, + 97, + 116, + 111, + 114, + 45, + 118, + 97, + 117, + 108, + 116, + ]; + }, + { + kind: "account"; + path: "bonding_curve.creator"; + account: "bondingCurve"; + }, + ]; + }; + }, + { + name: "associatedCreatorVault"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "creatorVault"; + }, + { + kind: "account"; + path: "quoteTokenProgram"; + }, + { + kind: "account"; + path: "quoteMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "sharingConfig"; + docs: [ + "seeds; the account is intentionally not deserialized here because it may be uninitialized", + "for mints that have not created a fee sharing config. Handlers must check", + "`data_is_empty()` / owner before reading.", + ]; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 115, + 104, + 97, + 114, + 105, + 110, + 103, + 45, + 99, + 111, + 110, + 102, + 105, + 103, + ]; + }, + { + kind: "account"; + path: "baseMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 12, + 53, + 255, + 169, + 5, + 90, + 142, + 86, + 141, + 168, + 247, + 188, + 7, + 86, + 21, + 39, + 76, + 241, + 201, + 44, + 164, + 31, + 64, + 0, + 156, + 81, + 106, + 164, + 20, + 194, + 124, + 112, + ]; + }; + }; + }, + { + name: "globalVolumeAccumulator"; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 103, + 108, + 111, + 98, + 97, + 108, + 95, + 118, + 111, + 108, + 117, + 109, + 101, + 95, + 97, + 99, + 99, + 117, + 109, + 117, + 108, + 97, + 116, + 111, + 114, + ]; + }, + ]; + }; + }, + { + name: "userVolumeAccumulator"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 117, + 115, + 101, + 114, + 95, + 118, + 111, + 108, + 117, + 109, + 101, + 95, + 97, + 99, + 99, + 117, + 109, + 117, + 108, + 97, + 116, + 111, + 114, + ]; + }, + { + kind: "account"; + path: "user"; + }, + ]; + }; + }, + { + name: "associatedUserVolumeAccumulator"; + writable: true; + pda: { + seeds: [ + { + kind: "account"; + path: "userVolumeAccumulator"; + }, + { + kind: "account"; + path: "quoteTokenProgram"; + }, + { + kind: "account"; + path: "quoteMint"; + }, + ]; + program: { + kind: "const"; + value: [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89, + ]; + }; + }; + }, + { + name: "feeConfig"; + pda: { + seeds: [ + { + kind: "const"; + value: [102, 101, 101, 95, 99, 111, 110, 102, 105, 103]; + }, + { + kind: "const"; + value: [ + 1, + 86, + 224, + 246, + 147, + 102, + 90, + 207, + 68, + 219, + 21, + 104, + 191, + 23, + 91, + 170, + 81, + 137, + 203, + 151, + 245, + 210, + 255, + 59, + 101, + 93, + 43, + 182, + 253, + 109, + 24, + 176, + ]; + }, + ]; + program: { + kind: "account"; + path: "feeProgram"; + }; + }; + }, + { + name: "feeProgram"; + address: "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + }, + { + name: "eventAuthority"; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 95, + 95, + 101, + 118, + 101, + 110, + 116, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121, + ]; + }, + ]; + }; + }, + { + name: "program"; + address: "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"; + }, + ]; + args: [ + { + name: "amount"; + type: "u64"; + }, + { + name: "maxSolCost"; + type: "u64"; + }, + ]; + }, { name: "buyExactSolIn"; docs: [ diff --git a/src/index.ts b/src/index.ts index 843eb0950..b120dffca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,8 @@ export { BREAKING_FEE_RECIPIENT_WSOL_ATAS, isBreakingFeeRecipient, pickBreakingFeeRecipient, + BUYBACK_FEE_RECIPIENTS, + pickBuybackFeeRecipient, buildAmmBreakingFeeRecipientAccounts, validateBcInstruction, validateAmmInstruction, @@ -145,6 +147,7 @@ export { export type { Fees, FeeTier } from "./state"; export { totalUnclaimedTokens, currentDayTokens } from "./tokenIncentives"; export * from "./errors"; +export * from "./quoteMints"; export { calculateBuyPriceImpact, calculateSellPriceImpact, diff --git a/src/onlineSdk.ts b/src/onlineSdk.ts index 359e48f73..7aeb9838d 100644 --- a/src/onlineSdk.ts +++ b/src/onlineSdk.ts @@ -292,6 +292,8 @@ export class OnlinePumpSdk { solAmount, slippage, tokenProgram = TOKEN_PROGRAM_ID, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, }: { bondingCurveAccountInfo: AccountInfo; bondingCurve: BondingCurve; @@ -302,6 +304,8 @@ export class OnlinePumpSdk { solAmount: BN; slippage: number; tokenProgram?: PublicKey; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; }): Promise { const global = await this.fetchGlobal(); return PUMP_SDK.buyInstructions({ @@ -315,6 +319,8 @@ export class OnlinePumpSdk { solAmount, slippage, tokenProgram, + quoteMint, + quoteTokenProgram, }); } @@ -2379,6 +2385,8 @@ export class OnlinePumpSdk { user, mayhemMode = false, cashback = false, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, }: { mint: PublicKey; name: string; @@ -2388,6 +2396,8 @@ export class OnlinePumpSdk { user: PublicKey; mayhemMode?: boolean; cashback?: boolean; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; }): Promise { return PUMP_SDK.createV2Instruction({ mint, @@ -2398,6 +2408,8 @@ export class OnlinePumpSdk { user, mayhemMode, cashback, + quoteMint, + quoteTokenProgram, }); } @@ -2425,6 +2437,9 @@ export class OnlinePumpSdk { solAmount, mayhemMode = false, cashback = false, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, + quoteAmount, }: { mint: PublicKey; name: string; @@ -2435,6 +2450,9 @@ export class OnlinePumpSdk { solAmount: BN; mayhemMode?: boolean; cashback?: boolean; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; + quoteAmount?: BN; }): Promise { const [global, feeConfig] = await Promise.all([ this.fetchGlobal(), @@ -2473,6 +2491,9 @@ export class OnlinePumpSdk { solAmount, mayhemMode, cashback, + quoteMint, + quoteTokenProgram, + quoteAmount, }); } diff --git a/src/quoteMints.ts b/src/quoteMints.ts new file mode 100644 index 000000000..458c2c3ee --- /dev/null +++ b/src/quoteMints.ts @@ -0,0 +1,24 @@ +import { NATIVE_MINT, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { PublicKey } from "@solana/web3.js"; + +/** Canonical mainnet USDC mint (legacy SPL Token, 6 decimals). */ +export const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + +export interface QuoteMintInfo { + mint: PublicKey; + decimals: number; + /** Token program that owns the quote mint. Both wSOL and USDC use the legacy SPL Token program. */ + tokenProgram: PublicKey; + ticker: string; +} + +/** Supported quote mints. The SDK's `quoteMint`/`quoteTokenProgram` params default to wSOL. */ +export const QUOTE_MINTS: Record<"wSOL" | "USDC", QuoteMintInfo> = { + wSOL: { mint: NATIVE_MINT, decimals: 9, tokenProgram: TOKEN_PROGRAM_ID, ticker: "SOL" }, + USDC: { mint: USDC_MINT, decimals: 6, tokenProgram: TOKEN_PROGRAM_ID, ticker: "USDC" }, +}; + +/** True when a quote mint is native SOL (wrapped SOL), i.e. the legacy/default path. */ +export function isNativeQuote(quoteMint: PublicKey): boolean { + return quoteMint.equals(NATIVE_MINT); +} diff --git a/src/sdk.ts b/src/sdk.ts index 5ef48088c..3f107341b 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -32,6 +32,7 @@ import { buildAmmBreakingFeeRecipientAccounts, getFeeRecipient, pickBreakingFeeRecipient, + pickBuybackFeeRecipient, } from "./fees"; import { Pump } from "./idl/pump"; import pumpIdl from "./idl/pump.json"; @@ -130,6 +131,7 @@ import { SUPPORTED_SOCIAL_PLATFORMS, platformToString, } from "./state"; +import { isNativeQuote } from "./quoteMints"; /** Create an Anchor Program instance for the Pump bonding curve program. */ export function getPumpProgram(connection: Connection): Program { @@ -344,6 +346,8 @@ export class PumpSdk { user, mayhemMode, cashback = false, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, }: { mint: PublicKey; name: string; @@ -353,7 +357,20 @@ export class PumpSdk { user: PublicKey; mayhemMode: boolean; cashback?: boolean; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; }): Promise { + const remaining = isNativeQuote(quoteMint) + ? [] + : [ + { pubkey: quoteMint, isWritable: false, isSigner: false }, + { + pubkey: getAssociatedTokenAddressSync(quoteMint, bondingCurvePda(mint), true, quoteTokenProgram), + isWritable: true, + isSigner: false, + }, + { pubkey: quoteTokenProgram, isWritable: false, isSigner: false }, + ]; return await this.offlinePumpProgram.methods .createV2(name, symbol, uri, creator, mayhemMode, [cashback ?? false]) .accountsPartial({ @@ -366,9 +383,21 @@ export class PumpSdk { mayhemState: getMayhemStatePda(mint), mayhemTokenVault: getTokenVaultPda(mint), }) + .remainingAccounts(remaining) .instruction(); } + /** + * Build the instructions to buy a coin on the bonding curve. + * + * When `quoteMint` is a non-native mint (e.g. USDC), the coin is bought against + * that quote mint; the buy leg routes to `buy_v2`. In that mode the maximum + * quote cost is expressed in the quote mint's base units (USDC = 6 decimals) — + * NOT lamports — and is passed straight through as `buy_v2`'s `quoteAmount` + * (the caller is responsible for the cap; no legacy slippage math is applied). + * The default (`quoteMint = NATIVE_MINT`) preserves the existing SOL behavior + * unchanged. + */ async buyInstructions({ global, bondingCurveAccountInfo, @@ -380,6 +409,8 @@ export class PumpSdk { solAmount, slippage, tokenProgram = TOKEN_PROGRAM_ID, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, }: { global: Global; bondingCurveAccountInfo: AccountInfo; @@ -391,7 +422,56 @@ export class PumpSdk { solAmount: BN; slippage: number; tokenProgram: PublicKey; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; }): Promise { + // Non-native quote (e.g. USDC) routes to buy_v2. buy_v2 does NOT init the + // user's base/quote ATAs (associated_base_user / associated_quote_user have + // no init in the IDL), so we must prepend idempotent ATA creates ourselves: + // - base: Token-2022 ATA (create_v2 coins use TOKEN_2022 for the base + // mint), gated on a missing account like the legacy native path. + // - quote: the quote mint's ATA, always (the USDC ATA may not exist); + // idempotent so it's a no-op when it already does. + if (!isNativeQuote(quoteMint)) { + const setupIxs: TransactionInstruction[] = []; + + if (!associatedUserAccountInfo) { + setupIxs.push( + createAssociatedTokenAccountIdempotentInstruction( + user, + getAssociatedTokenAddressSync(mint, user, true, TOKEN_2022_PROGRAM_ID), + user, + mint, + TOKEN_2022_PROGRAM_ID, + ), + ); + } + + setupIxs.push( + createAssociatedTokenAccountIdempotentInstruction( + user, + getAssociatedTokenAddressSync(quoteMint, user, true, quoteTokenProgram), + user, + quoteMint, + quoteTokenProgram, + ), + ); + + return [ + ...setupIxs, + await this.buyV2({ + user, + mint, + creator: bondingCurve.creator, + amount, + quoteAmount: solAmount, + quoteMint, + quoteTokenProgram, + feeRecipient: getFeeRecipient(global, bondingCurve.isMayhemMode), + }), + ]; + } + const instructions: TransactionInstruction[] = []; if (bondingCurveAccountInfo.data.length < BONDING_CURVE_NEW_SIZE) { @@ -440,6 +520,25 @@ export class PumpSdk { return instructions; } + /** + * Build the create + dev-buy instructions for a new coin. + * + * When `quoteMint` is a non-native mint (e.g. USDC), the coin is created and + * bought against that quote mint; the buy leg routes to `buy_v2`. In that mode, + * `quoteAmount` is the maximum quote cost in the quote mint's base units + * (USDC = 6 decimals) — NOT lamports. When `quoteAmount` is omitted it falls + * back to `solAmount` interpreted as quote base units, so prefer passing + * `quoteAmount` explicitly in USDC mode. The default (`quoteMint = NATIVE_MINT`) + * preserves the existing SOL behavior unchanged. + * + * Transaction-size note: for a non-native (USDC) quote mint, the returned + * `create_v2 + buy_v2` instructions serialize to ~1361 bytes across 32 unique + * accounts, which exceeds the 1232-byte single-legacy-transaction limit. They + * MUST be sent as a v0 `VersionedTransaction` with an Address Lookup Table + * covering the static (non-signer) accounts. The SOL path (~1084 bytes) fits in + * one legacy transaction without an ALT. See `scripts/devnet-usdc-smoke.ts` for + * a worked example of building the ALT and compiling the v0 message. + */ async createV2AndBuyInstructions({ global, mint, @@ -452,6 +551,9 @@ export class PumpSdk { solAmount, mayhemMode, cashback = false, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, + quoteAmount, }: { global: Global; mint: PublicKey; @@ -464,6 +566,9 @@ export class PumpSdk { solAmount: BN; mayhemMode: boolean; cashback?: boolean; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; + quoteAmount?: BN; }): Promise { const associatedUser = getAssociatedTokenAddressSync( mint, @@ -471,7 +576,8 @@ export class PumpSdk { true, TOKEN_2022_PROGRAM_ID, ); - return [ + + const instructions: TransactionInstruction[] = [ await this.createV2Instruction({ mint, name, @@ -481,6 +587,8 @@ export class PumpSdk { user, mayhemMode, cashback, + quoteMint, + quoteTokenProgram, }), await this.extendAccountInstruction({ account: bondingCurvePda(mint), @@ -493,6 +601,34 @@ export class PumpSdk { mint, TOKEN_2022_PROGRAM_ID, ), + ]; + + if (!isNativeQuote(quoteMint)) { + // buy_v2 does not init the user's quote ATA, so the dev-buy would fail + // without it. The base Token-2022 ATA is already created above. + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + user, + getAssociatedTokenAddressSync(quoteMint, user, true, quoteTokenProgram), + user, + quoteMint, + quoteTokenProgram, + ), + await this.buyV2({ + user, + mint, + creator, + amount, + quoteAmount: quoteAmount ?? solAmount, + quoteMint, + quoteTokenProgram, + feeRecipient: getFeeRecipient(global, mayhemMode), + }), + ); + return instructions; + } + + instructions.push( await this.buyInstruction({ global, mint, @@ -505,7 +641,8 @@ export class PumpSdk { tokenProgram: TOKEN_2022_PROGRAM_ID, mayhemMode, }), - ]; + ); + return instructions; } /** @@ -571,6 +708,8 @@ export class PumpSdk { slippage, tokenProgram = TOKEN_PROGRAM_ID, mayhemMode = false, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, }: { global: Global; mint: PublicKey; @@ -582,7 +721,24 @@ export class PumpSdk { slippage: number; tokenProgram: PublicKey; mayhemMode: boolean; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; }) { + // Non-native quote (e.g. USDC) routes to buy_v2. solAmount is the max quote + // cost the caller already capped; pass it straight as quoteAmount (no legacy + // slippage math). + if (!isNativeQuote(quoteMint)) { + return await this.buyV2({ + user, + mint, + creator, + amount, + quoteAmount: solAmount, + quoteMint, + quoteTokenProgram, + feeRecipient: getFeeRecipient(global, mayhemMode), + }); + } return await this.getBuyInstructionInternal({ user, associatedUser, @@ -851,6 +1007,8 @@ export class PumpSdk { solAmount, feeRecipient = getStaticRandomFeeRecipient(), tokenProgram = TOKEN_PROGRAM_ID, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, }: { user: PublicKey; mint: PublicKey; @@ -859,7 +1017,23 @@ export class PumpSdk { solAmount: BN; feeRecipient: PublicKey; tokenProgram?: PublicKey; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; }): Promise { + // Non-native quote (e.g. USDC) routes to buy_v2; solAmount is the max quote + // cost (caller controls the cap), passed straight through as quoteAmount. + if (!isNativeQuote(quoteMint)) { + return await this.buyV2({ + user, + mint, + creator, + amount, + quoteAmount: solAmount, + quoteMint, + quoteTokenProgram, + feeRecipient, + }); + } return await this.getBuyInstructionInternal({ user, associatedUser: getAssociatedTokenAddressSync( @@ -876,6 +1050,67 @@ export class PumpSdk { }); } + /** + * Build a `buy_v2` instruction (V2 buy supporting non-native quote mints, e.g. USDC). + * For SOL-paired coins prefer the legacy `buy` path. `quoteAmount` is the max QUOTE + * cost in the quote mint's base units (USDC = 6dp), NOT lamports. + */ + async buyV2({ + user, + mint, + creator, + amount, + quoteAmount, + quoteMint = NATIVE_MINT, + quoteTokenProgram = TOKEN_PROGRAM_ID, + baseTokenProgram = TOKEN_2022_PROGRAM_ID, + feeRecipient, + buybackFeeRecipient = pickBuybackFeeRecipient(), + }: { + user: PublicKey; + mint: PublicKey; + creator: PublicKey; + amount: BN; + quoteAmount: BN; + quoteMint?: PublicKey; + quoteTokenProgram?: PublicKey; + baseTokenProgram?: PublicKey; + feeRecipient: PublicKey; + buybackFeeRecipient?: PublicKey; + }): Promise { + const creatorVault = creatorVaultPda(creator); + return await this.offlinePumpProgram.methods + .buyV2(amount, quoteAmount) + .accountsPartial({ + baseMint: mint, + quoteMint, + baseTokenProgram, + quoteTokenProgram, + feeRecipient, + buybackFeeRecipient, + user, + // associated_base_user has no PDA metadata in the IDL, so Anchor cannot + // offline-resolve it: pass the user's base-mint ATA explicitly. + associatedBaseUser: getAssociatedTokenAddressSync( + mint, + user, + true, + baseTokenProgram, + ), + // creator_vault is seeded by bonding_curve.creator (an on-chain field + // Anchor cannot read offline), so we derive it from the passed creator. + creatorVault, + // associated_creator_vault depends on creator_vault; derive it too. + associatedCreatorVault: getAssociatedTokenAddressSync( + quoteMint, + creatorVault, + true, + quoteTokenProgram, + ), + }) + .instruction(); + } + private async getBuyInstructionInternal({ user, associatedUser,