Program reference
tdp-solana
The on-chain Anchor program behind Qior — it locks SPL tokens in escrow and releases them on a vesting schedule.
tdp-solana is a token vesting program written in Rust with Anchor. A creator locks tokens on-chain for a recipient, and the program releases them over time according to a schedule — no party has to trust the other, because the program holds the tokens and enforces the rules.
It supports three distribution patterns: cliff and linear time-based vesting, milestone vesting (full unlock when the creator marks a milestone reached), and cancellation (vested tokens go to the recipient, still-locked tokens return to the creator). This page is the canonical reference for the program internals; the frontend docs cover the integration path.
Devnet program ID: BiwY71TrdBzgv2yfa6KfUxUMY8UCpeiUMGnwmCMTsfs9
Where to start?
Prerequisites
You need the Rust toolchain, the Solana CLI, Anchor CLI v0.31.x, and Node.js (v18+) with yarn. Verify each tool is installed:
rustc --version
solana --version
anchor --version # v0.31.x
node --version # v18+
yarn --versionBuild & deploy
Clone the repo, install the JS dependencies (used by the test harness), then build. The first build generates a program keypair at target/deploy/tdp_solana-keypair.json; anchor keys sync writes that ID into lib.rs and Anchor.toml.
git clone https://github.com/mancer-team2/programs.git
cd programs
yarn install
anchor build # compiles the program + generates the keypair
anchor keys sync # writes the program ID into lib.rs and Anchor.tomlPoint the CLI at devnet, fund the wallet, and deploy:
solana config set --url devnet
solana airdrop 2
anchor deploy --provider.cluster devnetTesting
The suite is pure-logic unit tests plus LiteSVM integration tests. The integration tests need the compiled .so, so run anchor build first — if the artifact is missing the LiteSVM tests skip themselves and the unit tests still run.
# Pure-logic unit tests always run.
# LiteSVM integration tests need the compiled .so, so build first.
anchor build
cargo testStream account
A single Stream account holds the entire state of a vesting stream: the parties, the escrow, the schedule, and the bookkeeping for what has been withdrawn. It is a PDA, so its address is deterministic and the program can sign for the escrow it owns.
#[account]
#[derive(InitSpace)]
pub struct Stream {
pub creator: Pubkey, // funded the escrow, may cancel/close
pub recipient: Pubkey, // entitled to withdraw vested tokens
pub mint: Pubkey, // SPL token being vested
pub escrow_token_account: Pubkey, // PDA-owned escrow holding the tokens
pub stream_id: u64, // creator-scoped id, used in the PDA seed
pub total_amount: u64, // tokens originally deposited
pub withdrawn_amount: u64, // cumulative amount already claimed
pub start_time: i64, // unix ts vesting begins
pub cliff_time: i64, // nothing claimable before this ts
pub end_time: i64, // unix ts at 100% unlocked
pub cancelable: bool, // creator allowed to cancel
pub canceled: bool, // stream has been canceled
pub vesting_type: VestingType, // Cliff | Linear | Milestone
pub milestone_reached: bool, // milestone flag (milestone vesting only)
pub milestone_time: i64, // gate ts for milestone unlock
pub bump: u8, // Stream PDA bump
pub escrow_bump: u8, // escrow authority PDA bump
pub created_at: i64, // unix ts the account was created
}Vesting types
The vesting rule is an explicit enum stored on the stream — the program never infers it from timestamps. Each variant constrains how the schedule fields are validated at creation and how the unlocked amount is computed at withdrawal.
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)]
pub enum VestingType {
Cliff, // all-or-nothing at end_time (cliff_time == end_time)
Linear, // proportional over [start_time, end_time] (cliff_time == start_time)
Milestone, // all-at-once once the creator marks the milestone reached
}PDAs & escrow
Each stream is keyed by (creator, recipient, stream_id), so a creator can run many independent streams to the same recipient. The escrow token account is owned by a second PDA — the escrow authority — which has no private key, so only the program can move the locked tokens.
// Stream account PDA
seeds = [
b"stream",
creator.key().as_ref(),
recipient.as_ref(),
&stream_id.to_le_bytes(),
]
// Escrow authority PDA — owns the escrow token account.
// Only the program can sign for it (no private key).
seeds = [b"escrow_authority", stream.key().as_ref()]Instructions
The program exposes five instructions. The signer column is the wallet that must authorize each call.
create_streamCreatorLocks tokens in a PDA escrow and writes the vesting schedule.
withdrawRecipientTransfers currently vested tokens from escrow to the recipient.
set_milestoneCreatorFlips the milestone flag, unlocking a milestone-based stream.
cancel_streamCreatorSettles a cancelable stream: vested → recipient, locked → creator.
close_streamCreatorCloses a drained, fully settled stream and reclaims rent.
Stream lifecycle
The five instructions form one flow. A stream moves from creation through withdrawals to a final close; cancellation is an optional early exit for cancelable streams.
Create
create_streamThe creator locks total_amount in a fresh PDA escrow and writes the schedule. The stream starts with withdrawn_amount = 0 and canceled = false.
Unlock & withdraw
withdrawAs tokens vest, the recipient claims them — repeatedly, across any number of calls. Each claim adds to withdrawn_amount. For milestone streams the creator first calls set_milestone to release the full amount.
Cancel (optional)
cancel_streamWhile a cancelable stream is still partly locked, the creator can settle it early: vested-but-unclaimed tokens go to the recipient, still-locked tokens return to the creator, and canceled is set to true.
Close
close_streamOnce the escrow is empty and the stream is settled (fully withdrawn, or canceled), the creator closes the escrow and Stream accounts and reclaims the rent.
create_stream
Signer: creator. Initializes the Stream PDA and a fresh escrow token account, then transfers total_amountfrom the creator's token account into escrow in the same transaction.
pub fn create_stream(
ctx: Context<CreateStream>,
stream_id: u64,
recipient: Pubkey,
total_amount: u64,
start_time: i64,
cliff_time: i64,
end_time: i64,
cancelable: bool,
vesting_type: VestingType,
milestone_time: i64,
) -> Result<()>Parameters are validated before any tokens move:
require!(total_amount > 0, VestingError::InvalidAmount);
require!(recipient != Pubkey::default(), VestingError::InvalidRecipient);
match vesting_type {
VestingType::Cliff => {
require!(start_time < end_time, VestingError::InvalidSchedule);
require!(cliff_time == end_time, VestingError::InvalidCliff);
}
VestingType::Linear => {
require!(start_time < end_time, VestingError::InvalidSchedule);
require!(cliff_time == start_time, VestingError::InvalidCliff);
}
VestingType::Milestone => {
require!(milestone_time > 0, VestingError::InvalidMilestoneTime);
}
}
require!(creator_token_balance >= total_amount, VestingError::InsufficientFunds);Note the schedule shape per type: Cliff requires cliff_time == end_time (all-or-nothing), Linear requires cliff_time == start_time (vesting from the start), and Milestone only requires milestone_time > 0 and ignores start/cliff/end.
withdraw
Signer: recipient.Computes how much has vested at the current time, subtracts what was already claimed, and transfers the difference from escrow to the recipient's associated token account (created if needed). The escrow authority PDA signs the transfer.
// 1. Recompute what has vested right now.
let vested = calculate_vested_amount_by_type(/* schedule + vesting_type */, now)?;
// 2. Subtract what was already claimed.
let withdrawable = vested.checked_sub(stream.withdrawn_amount)?;
require!(withdrawable > 0, VestingError::NothingToWithdraw);
// 3. Effects before interactions: bump the claimed total first.
stream.withdrawn_amount = stream.withdrawn_amount.checked_add(withdrawable)?;
// 4. PDA-signed transfer escrow -> recipient ATA.
token::transfer(/* escrow_authority signs */, withdrawable)?;Guards: the signer must equal stream.recipient (Unauthorized), the stream must not be canceled (AlreadyCancelled), the mint and escrow must match the stream, and there must be something to claim (NothingToWithdraw). Withdrawals are incremental — the recipient can claim repeatedly as more vests.
set_milestone
Signer: creator. Marks a milestone-based stream as reached, which unlocks the full amount for withdrawal once current_time >= milestone_time. It only flips a boolean — no tokens move.
Guards: the signer must be the creator (Unauthorized), the stream must use VestingType::Milestone (NotMilestoneStream), it must not be canceled (AlreadyCancelled), and the milestone must not already be reached (FullyVested).
cancel_stream
Signer: creator. Cancels a cancelable stream and settles the escrow in one call. Vested-but-unclaimed tokens are sent to the recipient; still-locked tokens are returned to the creator. The canceled flag is set before the transfers (effects before interactions).
// to_recipient = vested - withdrawn (unlocked but not yet claimed)
// to_creator = total - vested (still locked)
pub fn split_cancel_amounts(
total_amount: u64,
vested_amount: u64,
withdrawn_amount: u64,
) -> Result<(u64, u64)> {
let to_recipient = vested_amount.checked_sub(withdrawn_amount)?;
let to_creator = total_amount.checked_sub(vested_amount)?;
Ok((to_recipient, to_creator))
}Guards: the signer must be the creator, the stream must be cancelable (StreamNotCancelable) and not already canceled (AlreadyCancelled), and it must not be fully vested (FullyVested) — there would be nothing left to return.
close_stream
Signer: creator. Closes a drained stream and reclaims rent. The escrow token account must be empty (enforced by an account constraint) and the stream must be fully settled — either canceled, or fully withdrawn (withdrawn_amount == total_amount), otherwise it errors with StreamNotSettled.
It closes the empty escrow token account and the Stream account, sending both rent refunds to the creator.
Vesting math
The unlocked amount is recomputed on every withdrawal and cancellation from the stored schedule — nothing is cached. Linear vesting uses checked u128 arithmetic to avoid overflow when scaling by elapsed time.
// Linear: 0 before the cliff, total after end_time, else proportional.
if current_time < cliff_time { return Ok(0); }
if current_time >= end_time { return Ok(total_amount); }
let elapsed = (current_time - start_time) as u128;
let duration = (end_time - start_time) as u128;
let vested = (total_amount as u128) * elapsed / duration; // all checked_*
// Cliff: total once current_time >= end_time, otherwise 0.
// Milestone: total once milestone_reached && current_time >= milestone_time.Errors
All custom errors returned by the program, defined as VestingError in error.rs. Anchor surfaces these by name to clients.
InvalidAmountAmount must be greater than zero
InvalidRecipientRecipient cannot be the default pubkey
InvalidSchedulestart_time must be before end_time
InvalidCliffcliff_time must be between start_time and end_time
InvalidMilestoneTimemilestone_time must be greater than zero
CliffNotReachedCliff period has not been reached yet
NothingToWithdrawNo tokens available to withdraw
UnauthorizedSigner is not authorized for this action
AlreadyCancelledStream has already been canceled
StreamNotCancelableStream is not cancelable
FullyVestedStream is already fully vested
NotMilestoneStreamStream is not configured for milestone unlocking
StreamExpiredStream schedule has already ended
StreamNotSettledStream is not fully settled yet
InvalidTokenAccountToken account mint does not match stream mint
InsufficientFundsInsufficient token balance to fund stream
InvalidPdaPDA derivation does not match expected address
MathOverflowArithmetic overflow or underflow
Architecture decisions
PDA-owned escrow
Locked tokens sit in a token account owned by an escrow_authority PDA with no private key. Only the program can move them, and only according to the vesting schedule — there is no admin key that can drain a stream.
Explicit vesting enum
The vesting rule is stored as VestingType and validated at creation. The program never guesses Cliff, Linear, or Milestone from timestamps, which keeps the create-time checks and the unlock math unambiguous.
Checked arithmetic everywhere
Amount math uses checked_add, checked_sub, and u128 intermediates for the linear formula, returning MathOverflow rather than wrapping. An overdrawn state is treated as an error, not silently clamped.
Effects before interactions
State changes (withdrawn_amount, the canceled flag) are written before any token transfer CPI, following the checks-effects-interactions pattern to avoid re-entrancy-style bugs.
Settle before close
A stream can only be closed once its escrow is empty and it is fully settled (canceled or fully withdrawn), so rent is never reclaimed while tokens or obligations remain.