Docs / Developer API
Build it yourself
You do not need the API to plant a Sapling coin. Sapling's index registers any planting of exactly the shape below, whoever built it. Read every number from GET /v1/params when your code runs; never type one in.
Read the params
const params = await fetch("https://api.sapling.cash/v1/params").then((r) => r.json());Program ids, the ZEC mint, the treasury, the launch fee, the creator fee and its split, pump.fun's fee recipients and reserves, the lookup table, priority caps and the name, ticker and image rules are all in it. The split is read from the vault program on chain.
The shape
One transaction, holding these and nothing else:
create_v2on pump.fun (params.programs.pump), exactly one, at the top level.- quote mint: ZEC (
params.zecMint); creator: the coin's CoinAuthority, the program address of the seeds["coin-authority", mint]under the vault program (params.programs.saplingVault). This is what sends the creator fee of every trade into Sapling's split;creator_fee_bps:params.planting.creatorFeeBps;- mayhem mode, cashback and pump.fun's holder rewards off;
user: the fee payer, the wallet planting the coin;- name at most 32 bytes, ticker matching
params.planting.ticker.pattern, neither passing for Sapling's own $SAPLING nor using a slur (refused names are not registered); - uri https or ipfs, at most 100 characters.
- quote mint: ZEC (
- A first buy, if you want one: one
buy_v2of this mint by the payer, with the payer's own token account created idempotently. - The launch fee: a System transfer of at least
params.planting.launchFeeLamportsfrom the payer toparams.treasury. declare_payeeon the vault program, exactly one: the deployer is the payer, the record is at the coin's payee address, and the mint key signs it. The payee is the payer, another wallet, or none for holder rewards (permanent). It receives the deployer's 40%.- Nothing else but a compute-unit limit and price, with the priority fee within
params.planting.maxPriorityLamports. - Signed by the payer and the mint key, the wallet first if it is a browser wallet. All in the same transaction, so it lands whole or not at all.
A planting with another creator, another quote mint or fee, without the launch fee or without the declaration is a plain pump.fun coin: Sapling does not list it, and its fees are not split. Check the shape before you sign: see verify before you sign.
A complete script
Node, with @solana/web3.js, @solana/spl-token, @pump-fun/pump-sdk and fetch only: it reads the params, uploads the image and metadata through POST /v1/plant/upload (or pin your own: Sapling reads the description, links and image from the uri you sign), builds the instructions by hand, signs, sends and confirms, then waits until GET /v1/coins/{mint} lists the coin.
/**
* Plant a Sapling coin without the SDK and without our builder: @solana/web3.js, @pump-fun/pump-sdk, @solana/spl-token
* and fetch. Every fee, address and table comes from GET /v1/params at run time. One transaction, in this shape, is
* what the indexer registers as a Sapling coin:
*
* create_v2 (quote ZEC, creator = the coin's CoinAuthority PDA, the creator fee from params, mayhem, cashback and
* holder rewards off) · optional first buy (the payer's coin account + buy_v2) · the launch fee to the treasury ·
* declare_payee (sapling_vault) · nothing else but a compute-unit price.
*
* KEYPAIR=./payer.json NAME="Tree of Life" TICKER=TREE IMAGE=./tree.png RPC_URL=https://... npx tsx examples/plant-by-hand.ts
*
* Optional: URI (your own metadata instead of the upload), PAYEE ("me", "holders" or an address), FIRST_BUY_ZEC (base
* units), SLIPPAGE_BPS (default 100), SAPLING_API (default https://api.sapling.cash).
*
* The numbers come from /v1/params, but the four addresses money and the coin's creator depend on are written here and
* checked against what the API answers, in full: a wrong host, a hijacked DNS or a compromised API cannot send the
* launch fee or the first buy elsewhere, or make another program the coin's creator. On another network, set
* EXPECT_VAULT, EXPECT_TREASURY, EXPECT_ZEC_MINT and EXPECT_PUMP to that network's own.
*/
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { ComputeBudgetProgram, Connection, Keypair, PublicKey, SystemProgram, TransactionInstruction, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, getAssociatedTokenAddressSync } from "@solana/spl-token";
import { OnlinePumpSdk, PUMP_SDK, getBuyTokenAmountFromSolAmount } from "@pump-fun/pump-sdk";
import BN from "bn.js"; // comes with @pump-fun/pump-sdk
const env = (k: string): string => process.env[k] || (console.error(`Set ${k}.`), process.exit(1));
const API = (process.env.SAPLING_API ?? "https://api.sapling.cash").replace(/\/$/, "");
const conn = new Connection(env("RPC_URL"), "confirmed");
const payer = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(env("KEYPAIR"), "utf8"))));
const mint = Keypair.generate(); // the coin's mint key: yours, made here
const [name, symbol] = [env("NAME").trim(), env("TICKER").trim().toUpperCase()];
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, init);
const body = await res.json();
if (!res.ok) throw new Error(`${path}: ${body?.error?.message ?? res.status}`);
return body as T;
}
// the addresses this script trusts, whatever the API says (Solana mainnet; full length, never a prefix)
const PINNED = {
vault: process.env.EXPECT_VAULT ?? "A9zFnNZpVjBbcE1SAPY92Z6H7WhicysLNPYPpDz15UxF",
treasury: process.env.EXPECT_TREASURY ?? "FauKh2enCxjqMDaEEmo3qMmA9toxyZPFvPhGEZNMzChr",
zecMint: process.env.EXPECT_ZEC_MINT ?? "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS",
pump: process.env.EXPECT_PUMP ?? "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P",
};
// 1. every number, from the API; every address it answers, checked against the pinned ones above
const params = await api<{
programs: { saplingVault: string; pump: string };
zecMint: string;
treasury: string;
lookupTable: string | null;
planting: { launchFeeLamports: string; creatorFeeBps: number; open: boolean; maxPriorityLamports: string };
pump: { feeRecipient: string; buybackFeeRecipient: string };
}>("/v1/params");
if (!params.planting.open) throw new Error("Planting is closed right now.");
for (const [what, got, want] of [["vault program", params.programs.saplingVault, PINNED.vault], ["treasury", params.treasury, PINNED.treasury], ["ZEC mint", params.zecMint, PINNED.zecMint], ["pump.fun program", params.programs.pump, PINNED.pump]] as const) {
if (got !== want) throw new Error(`The API names ${got} as the ${what}; this script trusts only ${want}. Nothing was signed.`);
}
const vault = new PublicKey(params.programs.saplingVault);
const zec = new PublicKey(params.zecMint);
const payeeEnv = process.env.PAYEE ?? "me";
const payee: PublicKey | null = payeeEnv === "me" ? payer.publicKey : payeeEnv === "holders" ? null : new PublicKey(payeeEnv);
// 2. the metadata: uploaded through the API (or your own uri: the indexer reads the description, links and image from it)
let uri = process.env.URI;
if (!uri) {
const form = new FormData();
for (const [k, v] of Object.entries({ name, symbol, description: process.env.DESCRIPTION ?? "", twitter: "", website: "", telegram: "" })) form.set(k, v);
form.set("payer", payer.publicKey.toBase58());
form.set("mint", mint.publicKey.toBase58());
form.set("holders", payee === null ? "1" : "0");
// the image's type travels with it: PNG, JPEG, WebP or GIF
const types: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
const type = types[env("IMAGE").split(".").pop()!.toLowerCase()] ?? "image/png";
form.set("image", new Blob([readFileSync(env("IMAGE"))], { type }), basename(env("IMAGE")));
uri = (await api<{ uri: string }>("/v1/plant/upload", { method: "POST", body: form })).uri;
}
// 3. the instructions, by hand
const creator = PublicKey.findProgramAddressSync([Buffer.from("coin-authority"), mint.publicKey.toBuffer()], vault)[0];
const creatorFeeBps = new BN(params.planting.creatorFeeBps);
const ixs: TransactionInstruction[] = [];
ixs.push(await PUMP_SDK.createV2Instruction({ mint: mint.publicKey, name, symbol, uri, creator, user: payer.publicKey, mayhemMode: false, quoteMint: zec, quoteTokenProgram: TOKEN_PROGRAM_ID, creatorFeeBps, holderReward: false }));
if (process.env.FIRST_BUY_ZEC) {
const zecIn = BigInt(process.env.FIRST_BUY_ZEC);
const maxZec = zecIn + (zecIn * BigInt(process.env.SLIPPAGE_BPS ?? 100)) / 10_000n;
const online = new OnlinePumpSdk(conn);
const [global, feeConfig, quoteControl] = await Promise.all([online.fetchGlobal(), online.fetchFeeConfig(), online.fetchQuoteControl()]);
const tokens = getBuyTokenAmountFromSolAmount({ global, feeConfig, mintSupply: null, bondingCurve: null, amount: new BN(zecIn.toString()), quoteMint: zec, quoteControl, creatorFeeBps });
const coinAccount = getAssociatedTokenAddressSync(mint.publicKey, payer.publicKey, false, TOKEN_2022_PROGRAM_ID);
ixs.push(createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, coinAccount, payer.publicKey, mint.publicKey, TOKEN_2022_PROGRAM_ID));
ixs.push(await PUMP_SDK.getBuyV2InstructionRaw({ user: payer.publicKey, mint: mint.publicKey, creator, amount: tokens, quoteAmount: new BN(maxZec.toString()), feeRecipient: new PublicKey(params.pump.feeRecipient), buybackFeeRecipient: new PublicKey(params.pump.buybackFeeRecipient), tokenProgram: TOKEN_2022_PROGRAM_ID, quoteMint: zec, quoteTokenProgram: TOKEN_PROGRAM_ID }));
}
ixs.push(SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: new PublicKey(params.treasury), lamports: BigInt(params.planting.launchFeeLamports) }));
// declare_payee (sapling_vault IDL): discriminator, then Option<Pubkey> payee (None = holder rewards, permanent)
const coinPayee = PublicKey.findProgramAddressSync([Buffer.from("payee"), mint.publicKey.toBuffer()], vault)[0];
ixs.push(
new TransactionInstruction({
programId: vault,
keys: [
{ pubkey: payer.publicKey, isSigner: true, isWritable: true }, // deployer
{ pubkey: mint.publicKey, isSigner: true, isWritable: false }, // mint
{ pubkey: coinPayee, isSigner: false, isWritable: true }, // coin_payee ["payee", mint]
{ pubkey: zec, isSigner: false, isWritable: false }, // zec_mint
{ pubkey: getAssociatedTokenAddressSync(zec, coinPayee, true, TOKEN_PROGRAM_ID), isSigner: false, isWritable: true }, // fee_zec
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // zec_token_program
{ pubkey: TOKEN_2022_PROGRAM_ID, isSigner: false, isWritable: false }, // coin_token_program
{ pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
],
data: Buffer.concat([Buffer.from([51, 67, 68, 118, 241, 128, 196, 27]), payee ? Buffer.concat([Buffer.from([1]), payee.toBuffer()]) : Buffer.from([0])]),
}),
);
// 4. one v0 transaction with the planting's lookup table; a priority fee only if it still fits (the cap is in params)
const tables = params.lookupTable ? [(await conn.getAddressLookupTable(new PublicKey(params.lookupTable))).value!] : [];
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash("confirmed");
const microLamports = Math.min(50_000, Math.floor((Number(params.planting.maxPriorityLamports) * 1e6) / (200_000 * (ixs.length + 1))));
const compile = (withPrice: boolean) =>
new VersionedTransaction(new TransactionMessage({ payerKey: payer.publicKey, recentBlockhash: blockhash, instructions: [...(withPrice ? [ComputeBudgetProgram.setComputeUnitPrice({ microLamports })] : []), ...ixs] }).compileToV0Message(tables));
let tx = compile(true);
try {
tx.serialize();
} catch {
tx = compile(false); // near the size limit with a first buy: no priority fee
}
tx.sign([payer, mint]);
// 5. send, confirm, and wait until the indexer lists the coin
const signature = await conn.sendRawTransaction(tx.serialize());
const done = await conn.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
if (done.value.err) throw new Error(`Failed on chain: ${JSON.stringify(done.value.err)}`);
console.log(`Planted: ${signature}`);
for (let i = 0; i < 60; i++) {
const res = await fetch(`${API}/v1/coins/${mint.publicKey.toBase58()}`);
if (res.ok) {
console.log(`Listed: https://sapling.cash/coin/${mint.publicKey.toBase58()}`);
process.exit(0);
}
await new Promise((r) => setTimeout(r, 2_000));
}
console.log(`Not listed yet. Check GET ${API}/v1/coins/${mint.publicKey.toBase58()} again in a minute.`);