Docs / Developer API
Examples
Each one is complete, runs with Node, and verifies before it signs. They ship with the SDK, in its examples folder.
Launcher bot
A bot plants a coin with its own keypair and prints the coin's page.
/**
* Plant a coin from a bot, with the bot's own key.
*
* KEYPAIR=./bot.json NAME="Tree of Life" TICKER=TREE IMAGE=./tree.png npx tsx examples/launcher-bot.ts
*
* Optional: RPC_URL (your own RPC), SAPLING_API_KEY (higher limits), PAYEE ("me" or "holders").
*/
import { readFileSync } from "node:fs";
import { basename, extname } from "node:path";
import { Keypair } from "@solana/web3.js";
import { Sapling } from "@saplingcash/sdk";
const env = (k: string): string => process.env[k] || (console.error(`Set ${k}.`), process.exit(1));
const TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif" };
const bot = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(env("KEYPAIR"), "utf8"))));
const sapling = new Sapling({ rpcUrl: process.env.RPC_URL || undefined, apiKey: process.env.SAPLING_API_KEY || undefined });
const image = env("IMAGE");
const plant = await sapling.plant({
wallet: bot.publicKey,
name: env("NAME"),
symbol: env("TICKER"),
image: { data: readFileSync(image), type: TYPES[extname(image).toLowerCase()] ?? "image/png", name: basename(image) },
payee: process.env.PAYEE === "holders" ? "holders" : "me",
});
const check = await plant.verify();
if (!check.ok) throw new Error(`Refused: ${[...check.problems, ...check.transactions.flatMap((t) => t.problems)].join("; ")}`);
await plant.sign(bot);
await plant.send();
console.log(`https://sapling.cash/coin/${plant.mint}`);Terminal buy
A buy button paid in SOL. buyWithWallet is what a web page calls with its wallet adapter; it shows what the buy gets before the wallet is asked, and follows a two-step buy to the end.
/**
* A terminal's buy button: the user's browser wallet pays in SOL. `buyWithWallet` is what a web page calls with its
* wallet adapter ({ publicKey, signTransaction }); the bottom of the file runs it from Node with a key file standing
* in for the wallet.
*
* KEYPAIR=./wallet.json MINT=<coin mint> LAMPORTS=100000000 npx tsx examples/terminal-buy.ts
*/
import { readFileSync } from "node:fs";
import { Keypair, type VersionedTransaction } from "@solana/web3.js";
import { Sapling, type Flow, type WalletSigner } from "@saplingcash/sdk";
export async function buyWithWallet(sapling: Sapling, wallet: WalletSigner, mint: string, lamports: bigint, slippageBps = 100): Promise<string[]> {
const signatures: string[] = [];
// a SOL buy is one transaction when it fits, otherwise the swap first and the buy once its ZEC has arrived
let step: Flow | null = await sapling.buy({ wallet: wallet.publicKey, mint, pay: "SOL", amount: lamports, slippageBps });
while (step) {
const check = await step.verify();
if (!check.ok) throw new Error(`Not signed: ${[...check.problems, ...check.transactions.flatMap((t) => t.problems)].join("; ")}`);
const e = step.expected;
if (e.tokensOut) console.log(`You get about ${Number(e.tokensOut) / 1e6} tokens, at least ${Number(e.minTokensOut ?? 0) / 1e6}.`);
else if (e.minZecOut) console.log(`First the swap: at least ${Number(e.minZecOut) / 1e8} ZEC.`);
await step.sign(wallet);
signatures.push(await step.send());
step = await step.next();
}
return signatures;
}
const env = (k: string): string => process.env[k] || (console.error(`Set ${k}.`), process.exit(1));
const key = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(env("KEYPAIR"), "utf8"))));
// the shape a wallet adapter has; a real page passes its adapter instead
const wallet: WalletSigner = {
publicKey: key.publicKey,
signTransaction: async <T extends VersionedTransaction>(tx: T) => {
tx.sign([key]);
return tx;
},
};
const sapling = new Sapling({ rpcUrl: process.env.RPC_URL || undefined, apiKey: process.env.SAPLING_API_KEY || undefined });
const sigs = await buyWithWallet(sapling, wallet, env("MINT"), BigInt(env("LAMPORTS")));
console.log(sigs.map((s) => `https://solscan.io/tx/${s}`).join("\n"));Sell all
Sells the whole position with a guaranteed minimum. verify() holds the transaction to the minimum your own slippageBps allows, so a build with a lowered minimum is refused before anything signs.
/**
* Sell a whole position for ZEC with a guaranteed minimum. The minimum is checked on this machine against a fresh
* quote from chain before anything signs, so a build with a lowered minimum (an easy sandwich) is refused.
*
* KEYPAIR=./wallet.json MINT=<coin mint> npx tsx examples/sell-all.ts
*
* Optional: SLIPPAGE_BPS (default 100), RECEIVE ("ZEC" or "SOL": the sale, then a swap of its ZEC to SOL).
*/
import { readFileSync } from "node:fs";
import { Keypair } from "@solana/web3.js";
import { Sapling, type Flow } from "@saplingcash/sdk";
const env = (k: string): string => process.env[k] || (console.error(`Set ${k}.`), process.exit(1));
const wallet = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(env("KEYPAIR"), "utf8"))));
const sapling = new Sapling({ rpcUrl: process.env.RPC_URL || undefined, apiKey: process.env.SAPLING_API_KEY || undefined });
// paid out in SOL, the sale comes first and next() builds the swap of the ZEC it guarantees
let step: Flow | null = await sapling.sell({
wallet: wallet.publicKey,
mint: env("MINT"),
amount: "all",
receive: process.env.RECEIVE === "SOL" ? "SOL" : "ZEC",
slippageBps: Number(process.env.SLIPPAGE_BPS ?? 100),
});
while (step) {
const check = await step.verify();
if (!check.ok) throw new Error(`Refused: ${[...check.problems, ...check.transactions.flatMap((t) => t.problems)].join("; ")}`);
const e = step.expected as Record<string, string | number | undefined>;
if (e.minZecOut) console.log(`Selling for at least ${Number(e.minZecOut) / 1e8} ZEC.`);
else if (e.minSolOut) console.log(`Swapping for at least ${Number(e.minSolOut) / 1e9} SOL.`);
await step.sign(wallet);
console.log(`https://solscan.io/tx/${await step.send()}`);
step = await step.next();
}Claim cranker
Pays waiting shares out to their payees. A claim always pays the payee recorded on chain, never the wallet that sends it, so the cranker pays only the network fee. Holder rewards need no cranking: Sapling pays them on every run.
/**
* Crank claim_payee for coins whose payee has ZEC waiting. Anyone may send it: the program pays the payee recorded
* on chain, never the caller. The caller only pays the network fee.
*
* KEYPAIR=./cranker.json npx tsx examples/claim-cranker.ts
*
* Optional: MIN_ZEC (base units, default 100000 = 0.001 ZEC), PAGES (default 5).
*/
import { readFileSync } from "node:fs";
import { Keypair } from "@solana/web3.js";
import { Sapling } from "@saplingcash/sdk";
const env = (k: string): string => process.env[k] || (console.error(`Set ${k}.`), process.exit(1));
const cranker = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(env("KEYPAIR"), "utf8"))));
const sapling = new Sapling({ rpcUrl: process.env.RPC_URL || undefined, apiKey: process.env.SAPLING_API_KEY || undefined });
const minZec = BigInt(process.env.MIN_ZEC ?? 100_000);
let cursor: string | undefined;
for (let page = 0; page < Number(process.env.PAGES ?? 5); page++) {
const coins = await sapling.coins.list({ sort: "volume", limit: 100, cursor });
for (const coin of coins.data) {
if (coin.holderRewards) continue; // holders-mode coins are paid out by holder-reward runs, not claims
const detail = await sapling.coins.get(coin.mint);
if (detail.payee.mode !== "wallet" || BigInt(detail.payee.unclaimedZec) < minZec) continue;
const claim = await sapling.claimPayee({ wallet: cranker.publicKey, mint: coin.mint });
const check = await claim.verify(); // the payee is read from chain, not from the API
if (!check.ok) {
console.warn(`${coin.symbol}: refused (${[...check.problems, ...check.transactions.flatMap((t) => t.problems)].join("; ")})`);
continue;
}
await claim.sign(cranker);
console.log(`${coin.symbol}: ${Number(detail.payee.unclaimedZec) / 1e8} ZEC to ${detail.payee.address}, ${await claim.send()}`);
}
if (!coins.next) break;
cursor = coins.next;
}