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:

terminal
rustc --version
solana --version
anchor --version   # v0.31.x
node --version     # v18+
yarn --version

Build & 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.

terminal
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.toml

Point the CLI at devnet, fund the wallet, and deploy:

terminal
solana config set --url devnet
solana airdrop 2
anchor deploy --provider.cluster devnet

Testing

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.

terminal
# Pure-logic unit tests always run.
# LiteSVM integration tests need the compiled .so, so build first.
anchor build
cargo test

Stream 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.

state/stream.rsRS
#[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.

state/stream.rsRS
#[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.

seedsRS
// 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_streamCreator

Locks tokens in a PDA escrow and writes the vesting schedule.

withdrawRecipient

Transfers currently vested tokens from escrow to the recipient.

set_milestoneCreator

Flips the milestone flag, unlocking a milestone-based stream.

cancel_streamCreator

Settles a cancelable stream: vested → recipient, locked → creator.

close_streamCreator

Closes 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.

1

Create

create_stream

The creator locks total_amount in a fresh PDA escrow and writes the schedule. The stream starts with withdrawn_amount = 0 and canceled = false.

2

Unlock & withdraw

withdraw

As 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.

3

Cancel (optional)

cancel_stream

While 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.

4

Close

close_stream

Once 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.

instructions/create_stream.rsRS
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:

validate_create_stream_paramsRS
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.

instructions/withdraw.rsRS
// 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).

instructions/cancel_stream.rsRS
// 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.

instructions/withdraw.rsRS
// 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.

InvalidAmount

Amount must be greater than zero

InvalidRecipient

Recipient cannot be the default pubkey

InvalidSchedule

start_time must be before end_time

InvalidCliff

cliff_time must be between start_time and end_time

InvalidMilestoneTime

milestone_time must be greater than zero

CliffNotReached

Cliff period has not been reached yet

NothingToWithdraw

No tokens available to withdraw

Unauthorized

Signer is not authorized for this action

AlreadyCancelled

Stream has already been canceled

StreamNotCancelable

Stream is not cancelable

FullyVested

Stream is already fully vested

NotMilestoneStream

Stream is not configured for milestone unlocking

StreamExpired

Stream schedule has already ended

StreamNotSettled

Stream is not fully settled yet

InvalidTokenAccount

Token account mint does not match stream mint

InsufficientFunds

Insufficient token balance to fund stream

InvalidPda

PDA derivation does not match expected address

MathOverflow

Arithmetic 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.