Skip to content

Integration Guide

Step-by-step guide for integrating with the Solana Token Distribution Protocol from a TypeScript client. Covers time-based and milestone-gated vesting streams, querying, error handling, and batch creation.

Target audience: Developers building on Solana who want to create token vesting streams in their own dApps, bots, or backend services.

  1. Prerequisites
  2. Installation
  3. Setup
  4. Time-Based Vesting
  5. Milestone-Gated Vesting
  6. Querying Streams
  7. Batch Creation
  8. Error Handling
  9. Working with Events
  10. Full End-to-End Example

  • Node.js v24+ and pnpm (or npm/yarn)
  • Solana CLI v3.1.12+
  • A Solana wallet funded with SOL (devnet: airdrop at faucet.solana.com)
  • An SPL Token mint (can be created with spl-token create-token)
  • SPL Token balance in your wallet

The examples below target devnet but work identically on mainnet with the appropriate RPC URL.


Terminal window
pnpm add @solana-tdp/sdk @coral-xyz/anchor @solana/web3.js @solana/spl-token

The SDK depends on Anchor 0.32+ and Web3.js v1.98+.


Create an Anchor Program instance with your wallet:

import { Program, AnchorProvider, Wallet } from "@coral-xyz/anchor";
import { Connection, clusterApiUrl, Keypair } from "@solana/web3.js";
import { SOLANA_TDP_PROGRAM_IDL, PROGRAM_ID } from "@solana-tdp/sdk";
import type { SolanaTdp } from "@solana-tdp/sdk";
// Connect to devnet
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
// Load your wallet (replace with your keypair loading logic)
const wallet = Keypair.fromSecretKey(new Uint8Array(JSON.parse(process.env.WALLET_KEYPAIR!)));
const provider = new AnchorProvider(connection, new Wallet(wallet), {
commitment: "confirmed",
});
const program = new Program<SolanaTdp>(SOLANA_TDP_PROGRAM_IDL, provider);

Note: The SDK exports buildReadProgram(connection) for read-only operations that don’t need a signer (fetching accounts, PDAs, computed vesting math).


A time-based stream locks tokens in a vault and releases them linearly over a time range, with an optional cliff.

Step 1: Derive PDAs

PDAs are deterministic — you can derive them before sending the transaction. The first stream for a (creator, recipient, mint) triple uses vesting_count = 0.

import { BN } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
import { getStreamPda, getVaultPda, getCreatorConfigPda } from "@solana-tdp/sdk";
const creator = wallet.publicKey;
const recipient = new PublicKey("RecipientWalletAddress...");
const mint = new PublicKey("TokenMintAddress...");
const vestingCount = new BN(0); // First stream: 0
const [streamPDA, streamBump] = getStreamPda(creator, recipient, mint, vestingCount, PROGRAM_ID);
const [vaultPDA, vaultBump] = getVaultPda(streamPDA, PROGRAM_ID);
const [configPDA] = getCreatorConfigPda(creator, PROGRAM_ID);

Step 2: Prepare time parameters

All timestamps are Unix seconds. The stream must start in the future, duration must be >= 60 seconds, and cliff must be between start and end (or 0 for no cliff).

const now = Math.floor(Date.now() / 1000);
const startTime = now + 60; // 1 minute from now
const cliffTime = startTime + 300; // 5-minute cliff after start
const endTime = startTime + 3600; // 1-hour vesting period
const amount = 1_000_000; // Raw token amount (accounting for decimals)

Step 3: Build and send the transaction

import { getCreateStreamAccounts } from "@solana-tdp/sdk";
const senderToken = getAssociatedTokenAddressSync(mint, creator);
const accounts = getCreateStreamAccounts(
creator,
recipient,
mint,
streamPDA,
vaultPDA,
senderToken,
configPDA,
);
const txSig = await program.methods
.createStream({
amount: new BN(amount),
startTime: new BN(startTime),
endTime: new BN(endTime),
cliffTime: new BN(cliffTime),
})
.accountsPartial(accounts)
.rpc(); // .rpc() signs and sends with provider.wallet in one call
console.log(`Stream created: ${txSig}`);
console.log(`Stream PDA: ${streamPDA.toBase58()}`);
console.log(`Vault PDA: ${vaultPDA.toBase58()}`);

Step 4: Verify on-chain

import { fetchStream } from "@solana-tdp/sdk";
const result = await fetchStream(connection, streamPDA);
if (result) {
const { account } = result;
console.log(`Amount: ${account.amount.toString()}`);
console.log(`Creator: ${account.creator.toBase58()}`);
console.log(`Recipient: ${account.recipient.toBase58()}`);
console.log(`Start: ${account.startTime.toString()}`);
console.log(`Cliff: ${account.cliffTime.toString()}`);
console.log(`End: ${account.endTime.toString()}`);
}

Tokens vest linearly over time. Use the SDK’s getClaimable to compute how much is available.

Step 1: Check what’s claimable

import { fetchStream, getClaimable } from "@solana-tdp/sdk";
const stream = await fetchStream(connection, streamPDA);
if (!stream) throw new Error("Stream not found");
const slot = await connection.getSlot();
const clockTime = await connection.getBlockTime(slot);
if (!clockTime) throw new Error("Could not get block time");
const claimable = getClaimable(stream.account, clockTime);
console.log(`Claimable: ${claimable.toString()}`);
if (claimable.isZero()) {
console.log("Nothing to withdraw yet — cliff or vesting hasn't started");
return;
}

Step 2: Withdraw

import { getWithdrawAccounts } from "@solana-tdp/sdk";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
const recipientToken = getAssociatedTokenAddressSync(mint, recipient);
const accounts = getWithdrawAccounts(
recipient,
streamPDA,
vaultPDA,
recipientToken,
creator, // rent return destination
mint,
);
// Use recipient's wallet keypair as signer
const txSig = await program.methods
.withdraw({ amount: claimable })
.accountsPartial(accounts)
.signers([recipient]) // recipient must sign — pass their keypair
.rpc();
console.log(`Withdrew ${claimable.toString()} tokens: ${txSig}`);

Partial withdrawals: You can withdraw less than the full claimable amount by passing a lower amount value. This is useful for claim-and-stake or claim-and-swap patterns.

Final withdrawal behavior: When amount_withdrawn reaches amount, the stream and vault accounts are automatically closed. Rent-exempt SOL is returned to the creator.


Only the creator can cancel. The recipient receives whatever has vested (including unclaimed). The creator gets the unvested portion back.

import { getCancelAccounts } from "@solana-tdp/sdk";
const senderToken = getAssociatedTokenAddressSync(mint, creator);
const recipientToken = getAssociatedTokenAddressSync(mint, recipient);
const accounts = getCancelAccounts(
creator,
recipient,
streamPDA,
vaultPDA,
senderToken,
recipientToken,
mint,
);
const txSig = await program.methods.cancel().accountsPartial(accounts).rpc(); // provider.wallet is the creator, so it signs automatically
console.log(`Stream cancelled: ${txSig}`);

Cannot cancel after end_time: If the stream has fully vested, use withdraw instead. Attempting cancel after end_time returns error StreamExpired.


Milestone streams skip time-based vesting. A designated authority triggers a one-time release, and the recipient withdraws the full amount. Useful for deliverables, KYC gates, or DAO vote completions.

import { BN } from "@coral-xyz/anchor";
import { PublicKey, Keypair } from "@solana/web3.js";
import {
getMilestoneStreamPda,
getVaultPda,
getCreatorConfigPda,
getCreateMilestoneStreamAccounts,
PROGRAM_ID,
} from "@solana-tdp/sdk";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
const creator = wallet.publicKey;
const recipient = new PublicKey("RecipientWallet...");
const milestoneAuthority = new PublicKey("AuthorityWallet...");
const mint = new PublicKey("TokenMint...");
const amount = 500_000;
const [streamPDA] = getMilestoneStreamPda(creator, recipient, mint, new BN(0), PROGRAM_ID);
const [vaultPDA] = getVaultPda(streamPDA, PROGRAM_ID);
const [configPDA] = getCreatorConfigPda(creator, PROGRAM_ID);
const senderToken = getAssociatedTokenAddressSync(mint, creator);
const accounts = getCreateMilestoneStreamAccounts(
creator,
recipient,
milestoneAuthority,
configPDA,
streamPDA,
vaultPDA,
senderToken,
mint,
);
const txSig = await program.methods
.createMilestoneStream({ amount: new BN(amount) })
.accountsPartial(accounts)
.rpc();
console.log(`Milestone stream created: ${streamPDA.toBase58()}`);

The milestone authority must sign this transaction:

import { getTriggerMilestoneAccounts } from "@solana-tdp/sdk";
const accounts = getTriggerMilestoneAccounts(milestoneAuthority, streamPDA);
const txSig = await program.methods
.triggerMilestone()
.accountsPartial(accounts)
.signers([milestoneAuthority]) // must match milestone_authority field
.rpc();
console.log(`Milestone triggered: ${txSig}`);

Security note: Triggering is one-way. Once milestone_reached is set to true, neither triggering again nor cancelling is possible.

After the milestone is triggered, the recipient withdraws the full amount in one transaction:

import { getWithdrawMilestoneAccounts } from "@solana-tdp/sdk";
const recipientToken = getAssociatedTokenAddressSync(mint, recipient);
const accounts = getWithdrawMilestoneAccounts(
recipient,
streamPDA,
vaultPDA,
recipientToken,
creator, // rent return destination
mint,
);
const txSig = await program.methods
.withdrawMilestone()
.accountsPartial(accounts)
.signers([recipient]) // recipient must sign — pass their keypair
.rpc();
console.log(`Milestone withdrawn: ${txSig}`);

The creator can cancel only before the milestone is triggered:

import { getCancelMilestoneAccounts } from "@solana-tdp/sdk";
const senderToken = getAssociatedTokenAddressSync(mint, creator);
const accounts = getCancelMilestoneAccounts(creator, streamPDA, vaultPDA, senderToken, mint);
const txSig = await program.methods.cancelMilestone().accountsPartial(accounts).rpc();
console.log(`Milestone cancelled, tokens returned: ${txSig}`);

The SDK provides fetch functions for all common queries:

import {
fetchStreams,
fetchStreamsByCreator,
fetchStreamsByRecipient,
fetchMilestoneStreams,
fetchMilestoneStreamsByCreator,
fetchMilestoneStreamsByRecipient,
fetchCreatorConfig,
getStatus,
getClaimable,
getMilestoneStatus,
} from "@solana-tdp/sdk";
// All time-based streams on the program
const allStreams = await fetchStreams(connection);
// Streams created by a specific wallet
const myStreams = await fetchStreamsByCreator(connection, creator);
// Streams where a specific wallet is the recipient
const incomingStreams = await fetchStreamsByRecipient(connection, recipient);
// Single stream by PDA
const stream = await fetchStream(connection, streamPDA);
// Milestone streams
const milestoneStreams = await fetchMilestoneStreams(connection);
// Creator config (vesting count)
const config = await fetchCreatorConfig(connection, creator);
// Compute stream status (active | completed | cancelled)
const slot = await connection.getSlot();
const clockTime = await connection.getBlockTime(slot);
const status = getStatus(stream!.account, clockTime!);
// Compute claimable amount
const claimable = getClaimable(stream!.account, clockTime!);
// Milestone stream status
const mStatus = getMilestoneStatus(milestoneAccount);

Performance note: fetchStreamsByCreator and fetchStreamsByRecipient use getProgramAccounts with memcmp filters. For production use with many streams, consider indexing events into a database.


The on-chain program creates one stream per instruction. To create multiple streams atomically, pack multiple createStream instructions into a single transaction:

import { Transaction } from "@solana/web3.js";
const streams: Array<{
recipient: PublicKey;
amount: number;
}> = [
{ recipient: recipient1, amount: 1_000_000 },
{ recipient: recipient2, amount: 500_000 },
{ recipient: recipient3, amount: 2_000_000 },
];
const tx = new Transaction();
// Query the current vesting count to start from
const config = await fetchCreatorConfig(connection, creator);
let count = config ? config.vestingCount : new BN(0);
const startTime = Math.floor(Date.now() / 1000) + 120;
const endTime = startTime + 31_536_000; // 1 year
const senderToken = getAssociatedTokenAddressSync(mint, creator);
for (const { recipient, amount } of streams) {
const [streamPDA] = getStreamPda(creator, recipient, mint, count, PROGRAM_ID);
const [vaultPDA] = getVaultPda(streamPDA, PROGRAM_ID);
const [configPDA] = getCreatorConfigPda(creator, PROGRAM_ID);
const ix = await program.methods
.createStream({
amount: new BN(amount),
startTime: new BN(startTime),
endTime: new BN(endTime),
cliffTime: new BN(0), // no cliff
})
.accountsPartial(
getCreateStreamAccounts(
creator,
recipient,
mint,
streamPDA,
vaultPDA,
senderToken,
configPDA,
),
)
.instruction();
tx.add(ix);
count = count.add(new BN(1));
}
const txSig = await program.provider.sendAndConfirm(tx);
console.log(`Batch of ${streams.length} streams: ${txSig}`);

Transaction size limit: Solana transactions are limited to 1232 bytes. You can fit approximately 3–4 stream creations per transaction. For larger batches, split into multiple transactions.

Atomicity: All instructions in a single transaction execute atomically — either all succeed or none do.


Anchor errors are returned as program error codes in the 6000–6999 range. Handle them in your integration:

import { AnchorError } from "@coral-xyz/anchor";
async function safeCreateStream(
program: Program<SolanaTdp>,
/* ... params */
) {
try {
return await program.methods.createStream(/* ... */).accountsPartial(accounts).rpc();
} catch (err) {
if (err instanceof AnchorError) {
switch (err.error.errorCode.number) {
case 6000:
throw new Error("Amount must be greater than zero");
case 6001:
throw new Error("Start time must be before end time");
case 6003:
throw new Error("Stream duration must be at least 60 seconds");
case 6004:
throw new Error("Insufficient token balance for this stream");
case 6007:
throw new Error("Cliff time has not been reached yet");
case 6013:
throw new Error("Requested amount exceeds claimable tokens");
case 6014:
throw new Error(
"Unauthorized — only the stream creator/recipient/authority can call this",
);
default:
throw new Error(`Program error ${err.error.errorCode.number}: ${err.error.errorMessage}`);
}
}
throw err;
}
}

Common pitfalls:

IssueCauseFix
StartTimeInPast (6011)start_time is <= current clock timeSet start_time to now + 60 or later
DurationTooShort (6003)end_time - start_time < 60 secondsEnsure duration >= 60 seconds
InsufficientBalance (6004)Creator’s token balance < amountFund the token account first
TokenHasTransferHook (6006)Token-2022 mint has transfer-hook extensionUse a standard SPL Token mint without hooks
ExceedsClaimable (6013)Withdraw amount > what’s vestedUse getClaimable() to compute the exact amount
Unauthorized (6014)Wrong signer (wrong wallet signing)Check signers: creator for cancel, recipient for withdraw, milestone authority for trigger
Transaction simulation fails silentlyATA doesn’t exist for recipientThe program creates ATAs automatically via CPI — no manual ATA creation needed

Events are emitted by every instruction and can be parsed from transaction logs:

import { parseEvents, findEvent } from "@solana-tdp/sdk";
async function getEventsFromTx(txSig: string) {
const events = await parseEvents(program.provider, program, txSig);
const streamCreated = findEvent(events, "StreamCreated");
console.log("Stream:", streamCreated.data.stream.toBase58());
console.log("Amount:", streamCreated.data.amount.toString());
return events;
}

Events persist on-chain in transaction logs. Since stream accounts are fully closed on completion or cancellation, events are the authoritative source for indexing stream history.


Complete example: create a stream, wait for vesting, withdraw, and verify:

import { Program, AnchorProvider, Wallet } from "@coral-xyz/anchor";
import { Connection, clusterApiUrl, Keypair, PublicKey } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
import {
SOLANA_TDP_PROGRAM_IDL,
PROGRAM_ID,
getStreamPda,
getVaultPda,
getCreatorConfigPda,
getCreateStreamAccounts,
getWithdrawAccounts,
fetchStream,
getClaimable,
} from "@solana-tdp/sdk";
import type { SolanaTdp } from "@solana-tdp/sdk";
async function main() {
// ── Setup ────────────────────────────────────────────────────
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
const creator = loadWallet(); // Your wallet loading logic
const recipient = new PublicKey("Recipient's wallet address");
const mint = new PublicKey("SPL token mint address");
const provider = new AnchorProvider(connection, new Wallet(creator), {
commitment: "confirmed",
});
const program = new Program<SolanaTdp>(SOLANA_TDP_PROGRAM_IDL, provider);
// ── Derive PDAs ──────────────────────────────────────────────
const [streamPDA] = getStreamPda(creator.publicKey, recipient, mint, new BN(0), PROGRAM_ID);
const [vaultPDA] = getVaultPda(streamPDA, PROGRAM_ID);
const [configPDA] = getCreatorConfigPda(creator.publicKey, PROGRAM_ID);
const senderToken = getAssociatedTokenAddressSync(mint, creator.publicKey);
// ── Create stream ────────────────────────────────────────────
const now = Math.floor(Date.now() / 1000);
const amount = 1_000_000; // 1 token with 6 decimals
const createAccounts = getCreateStreamAccounts(
creator.publicKey,
recipient,
mint,
streamPDA,
vaultPDA,
senderToken,
configPDA,
);
const createTxSig = await program.methods
.createStream({
amount: new BN(amount),
startTime: new BN(now + 60), // 1 min from now
endTime: new BN(now + 60 + 3600), // 1 hour duration
cliffTime: new BN(0), // no cliff
})
.accountsPartial(createAccounts)
.rpc();
console.log(`Stream created: ${createTxSig}`);
// ── Wait for vesting ─────────────────────────────────────────
console.log("Waiting for vesting...");
// In production: poll connection.getBlockTime(connection.getSlot())
// until the desired vesting percentage is reached
// ── Withdraw ─────────────────────────────────────────────────
const stream = await fetchStream(connection, streamPDA);
const slot = await connection.getSlot();
const clockTime = await connection.getBlockTime(slot);
const claimable = getClaimable(stream!.account, clockTime!);
console.log(`Claimable: ${claimable.toString()}`);
const recipientToken = getAssociatedTokenAddressSync(mint, recipient);
const withdrawAccounts = getWithdrawAccounts(
recipient,
streamPDA,
vaultPDA,
recipientToken,
creator.publicKey,
mint,
);
const withdrawTxSig = await program.methods
.withdraw({ amount: claimable })
.accountsPartial(withdrawAccounts)
.rpc();
console.log(`Withdrawn: ${withdrawTxSig}`);
// ── Verify ───────────────────────────────────────────────────
const finalStream = await fetchStream(connection, streamPDA);
if (finalStream) {
console.log(`Withdrawn: ${finalStream.account.amountWithdrawn.toString()}`);
console.log(
`Remaining: ${finalStream.account.amount.sub(finalStream.account.amountWithdrawn).toString()}`,
);
} else {
console.log("Stream fully claimed — account closed");
}
}
main().catch(console.error);