Skip to main content

Tokenlock

Overview

The Tokenlock program implements token vesting and lockup schedules for Solana RWA tokens. It enables scheduled token releases with configurable vesting schedules, suitable for employee compensation, investor lockups, and founder allocations.

Architecture

  • Release Schedules: Reusable vesting templates defining release timing and portions
  • Timelocks: Individual vesting instances linked to specific recipients
  • Escrow Management: Holds tokens in a program-controlled escrow account
  • Cancelation Support: Optional cancellation for employee vestings
  • Transfer Restrictions Bypass: Escrow transfers skip transfer restrictions

Key Features

  • Flexible vesting schedules with cliff and linear vesting
  • Multiple timelocks per recipient
  • Cancelable and non-cancelable timelocks
  • Timelock transfer between recipients
  • Cancel timelock with proportional distribution (locked to canceler, unlocked to recipient)
  • Integration with Access Control and Transfer Restrictions

Core Concepts

Release Schedules

Release schedules are reusable templates that define how tokens vest over time. They are created by admins and referenced when minting timelocks.

Schedule Structure:

pub struct ReleaseSchedule {
pub signer_hash: [u8; 32], // Hash of creator + UUID
pub release_count: u32, // Number of releases
pub delay_until_first_release_in_seconds: u64, // Cliff period
pub initial_release_portion_in_bips: u32, // Initial release (basis points)
pub period_between_releases_in_seconds: u64, // Release interval
}

Key Properties:

  • Basis Points (BIPS): 1 BIPS = 0.01%, so 10000 BIPS = 100%
  • Release Count: Number of total releases (including initial release)
  • Cliff Period: Delay before first release
  • Initial Release: Portion released at commencement + cliff (0-10000 BIPS)
  • Release Period: Time between subsequent releases

Example Schedules:

TypeReleasesCliffInitial %PeriodDescription
Employee 4-Year Vest481 yr0%1 moMonthly vesting after 1-year cliff
Investor 2-Year Lockup12 yr100%N/AFull release after 2 years
Founder 4-Year + Cliff491 yr25%1 mo25% at 1 year, then monthly for 3yr

Timelocks

Timelocks are individual vesting instances that reference a release schedule and specify the recipient, amount, and commencement date.

Timelock Structure:

pub struct Timelock {
pub timelock_id: u32, // Unique ID within TimelockData
pub total_amount: u64, // Total locked tokens
pub balance_at_commencement: u64, // Remaining balance when created
pub schedule_id: u16, // Reference to ReleaseSchedule
pub commencement_timestamp: u64, // When vesting starts
pub cancelable_by_count: u8, // Number of cancelers
pub cancelable_by: [Pubkey; 10], // Addresses that can cancel (max 10)
}

Key Properties:

  • Each timelock references a release schedule
  • Multiple timelocks can use the same schedule
  • Timelocks are linked to a target recipient account
  • Unlocked tokens can be transferred while locked tokens remain in escrow

Vesting Calculation

The number of unlocked tokens at any given time is calculated based on:

  1. Time Elapsed: current_time - commencement_timestamp
  2. Release Schedule: Determines which releases have occurred
  3. Initial Release: total_amount * initial_release_portion_in_bips / 10000
  4. Subsequent Releases: Remaining amount divided equally across releases

Example Calculation:

Schedule:
- 48 releases
- 0 BIPS initial (0%)
- 365 days cliff
- 30 days between releases

Timelock:
- 48,000 tokens
- Commenced: Jan 1, 2024

Unlocked at different times:
- Jan 1, 2024 (day 0): 0 tokens (before cliff)
- Jan 1, 2025 (day 365): 1,000 tokens (first release after cliff)
- Feb 1, 2025 (day 395): 2,000 tokens (2 releases)
- Jan 1, 2028 (day 1460): 48,000 tokens (all 48 releases)

Usage

Initialization

Initialize the Tokenlock program for a specific token mint and escrow account.

TypeScript Example:

import * as anchor from "@coral-xyz/anchor";
import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";

// Create tokenlock data account (must be a new account)
const tokenlockAccount = anchor.web3.Keypair.generate();

// Create escrow account (associated token account for escrow owner PDA)
const [escrowOwnerPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("tokenlock"),
mint.toBuffer(),
tokenlockAccount.publicKey.toBuffer(),
],
tokenlockProgramId
);

const escrowAccount = getAssociatedTokenAddressSync(
mint,
escrowOwnerPDA,
true, // allowOwnerOffCurve
TOKEN_2022_PROGRAM_ID
);

await tokenlockProgram.methods
.initializeTokenlock(
new anchor.BN(365 * 24 * 60 * 60), // max_release_delay: 1 year in seconds
new anchor.BN(1) // min_timelock_amount: minimum 1 token
)
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
escrowAccount,
mintAddress: mint,
transferRestrictionsData: transferRestrictionDataPDA,
authorityWalletRole: contractAdminWalletRolePDA,
accessControl: accessControlPDA,
authority: contractAdmin.publicKey,
tokenProgram: TOKEN_2022_PROGRAM_ID,
})
.signers([contractAdmin, tokenlockAccount])
.rpc();

Requirements:

  • max_release_delay ≥ 1: Maximum allowed cliff period
  • min_timelock_amount ≥ 1: Minimum tokens per timelock
  • Caller must have Contract Admin role
  • Tokenlock account must be a new Keypair
  • Sets escrow account owner to PDA for security

Important: After initialization, register the escrow account in Access Control and Transfer Restrictions programs using set_lockup_escrow_account.

Creating Release Schedules

Admins can create reusable release schedules.

TypeScript Example (4-Year Employee Vesting):

const uuid = Buffer.from(crypto.randomBytes(16)); // Random UUID

await tokenlockProgram.methods
.createReleaseSchedule(
Array.from(uuid), // uuid: [u8; 16]
48, // release_count: 48 monthly releases
new anchor.BN(365 * 24 * 60 * 60), // cliff: 1 year
0, // initial_release_portion_in_bips: 0 BIPS (0%)
new anchor.BN(30 * 24 * 60 * 60) // period: 30 days between releases
)
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
authority: reserveAdmin.publicKey,
authorityWalletRole: reserveAdminWalletRolePDA,
accessControl: accessControlPDA,
})
.signers([reserveAdmin])
.rpc();

TypeScript Example (2-Year Investor Lockup):

await tokenlockProgram.methods
.createReleaseSchedule(
Array.from(uuid),
1, // release_count: single release
new anchor.BN(2 * 365 * 24 * 60 * 60), // cliff: 2 years
10000, // initial_release_portion_in_bips: 10000 BIPS (100%)
new anchor.BN(0) // period: not used for single release
)
.accountsStrict({
/* same accounts */
})
.signers([reserveAdmin])
.rpc();

Requirements:

  • Caller must have any admin role (Contract, Reserve, Transfer, or Wallet Admin)
  • release_count ≥ 1
  • delay_until_first_release ≤ max_release_delay
  • initial_release_portion_in_bips ≤ 10000 (100%)
  • If release_count == 1: initial_release_portion_in_bips must be 10000 (100%)
  • If release_count > 1:
    • period_between_releases > 0
    • initial_release_portion_in_bips < 10000 (cannot vest 100% initially)
  • Maximum 100 schedules per tokenlock account

Errors:

  • SchedulesCountReachedMax: Too many schedules
  • FirstReleaseDelayBiggerThanMaxDelay: Cliff exceeds max
  • ReleaseCountLessThanOne: Invalid release count
  • ReleasePeriodZero: Period is 0 for multi-release schedule
  • CantVestAllForMultipleReleases: 100% initial release with multiple releases

Initializing Timelock Accounts

Before minting timelocks, create a timelock account for the recipient.

TypeScript Example:

const timelockAccount = anchor.web3.Keypair.generate();

await tokenlockProgram.methods
.initializeTimelock()
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
timelockAccount: timelockAccount.publicKey,
targetAccount: recipientWallet.publicKey,
payer: payer.publicKey,
authority: authority.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([payer, timelockAccount])
.rpc();

Requirements:

  • Timelock account must be a new Keypair
  • Target account is the wallet that will receive unlocked tokens
  • One timelock account per recipient
  • Account is dynamically resized as timelocks are added

Minting Timelocks

Reserve Admins can mint tokens directly into timelocks (bypassing transfer restrictions).

TypeScript Example:

const scheduleId = 0; // First schedule
const amount = 48_000 * 10 ** 6; // 48,000 tokens (with 6 decimals)
const commencementTimestamp = Math.floor(Date.now() / 1000); // Start now
const cancelableBy = [
cancellerWallet.publicKey, // Who can cancel (e.g., company)
]; // Empty array for non-cancelable
const uuid = Buffer.from(crypto.randomBytes(16));

await tokenlockProgram.methods
.mintReleaseSchedule(
Array.from(uuid),
new anchor.BN(amount),
new anchor.BN(commencementTimestamp),
scheduleId,
cancelableBy
)
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
timelockAccount: timelockAccount.publicKey,
escrowAccount,
escrowAccountOwner: escrowOwnerPDA,
payer: payer.publicKey,
authority: reserveAdmin.publicKey,
authorityWalletRole: reserveAdminWalletRolePDA,
accessControl: accessControlPDA,
mintAddress: mint,
to: recipientWallet.publicKey,
tokenProgram: TOKEN_2022_PROGRAM_ID,
accessControlProgram: accessControlProgramId,
systemProgram: anchor.web3.SystemProgram.programId,
})
.remainingAccounts([
/* security_associated_account if required */
])
.signers([reserveAdmin, payer])
.rpc();

Requirements:

  • Caller must have Reserve Admin role
  • amount ≥ min_timelock_amount
  • amount ≥ release_count (at least 1 token per release)
  • schedule_id must reference an existing schedule
  • commencement_timestamp must be within max_release_delay of current time
  • Maximum 10 cancelable_by addresses
  • Mints tokens and locks them in escrow
  • Does not require security associated account (bypass transfer restrictions)

Errors:

  • AmountLessThanMinMintingAmount: Amount too small
  • InvalidScheduleId: Schedule doesn't exist
  • PerReleaseTokenLessThanOne: Not enough tokens per release
  • CommencementTimeoutOfRange: Commencement time invalid
  • Max10CancelableAddresses: Too many cancelers

Transferring Unlocked Tokens

Recipients can transfer unlocked tokens from timelocks to any address.

TypeScript Example:

import { addExtraAccountMetasForExecute } from "@solana-program/transfer-hook";

const transferAmount = 1000 * 10 ** 6; // 1000 tokens

const destinationAccount = getAssociatedTokenAddressSync(
mint,
destinationWallet.publicKey,
false,
TOKEN_2022_PROGRAM_ID
);

// Helper function to get transfer restriction accounts
const { securityAssociatedAccountFrom, securityAssociatedAccountTo, transferRule } =
await getTransferRestrictionAccounts(
escrowAccount,
destinationAccount,
mint
);

const instruction = await tokenlockProgram.methods
.transfer(new anchor.BN(transferAmount))
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
timelockAccount: timelockAccount.publicKey,
escrowAccount,
pdaAccount: escrowOwnerPDA,
authority: recipientWallet.publicKey,
to: destinationAccount,
mintAddress: mint,
tokenProgram: TOKEN_2022_PROGRAM_ID,
transferRestrictionsProgram: transferRestrictionsProgramId,
authorityAccount: recipientWallet.publicKey,
securityAssociatedAccountFrom,
securityAssociatedAccountTo,
transferRule,
})
.instruction();

// Add transfer hook extra accounts
await addExtraAccountMetasForExecute(
connection,
instruction,
transferHookProgramId,
escrowAccount,
mint,
destinationAccount,
escrowOwnerPDA,
transferAmount,
commitment
);

await tokenlockProgram.provider.sendAndConfirm(
new anchor.web3.Transaction().add(instruction),
[recipientWallet]
);

Requirements:

  • Caller must be the target account owner
  • Transfer amount must not exceed unlocked balance
  • Subject to transfer restrictions (except for escrow → escrow transfers)
  • Multiple timelocks are processed in order (FIFO-like)

Errors:

  • AmountBiggerThanUnlocked: Insufficient unlocked tokens
  • AmountMustBeBiggerThanZero: Transfer amount is 0

Transferring Timelocks

Recipients can transfer timelocks to another recipient (changes target account).

TypeScript Example:

const timelockId = 0; // First timelock

const newTimelockAccount = anchor.web3.Keypair.generate();

// Initialize new timelock account for new recipient
await tokenlockProgram.methods
.initializeTimelock()
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
timelockAccount: newTimelockAccount.publicKey,
targetAccount: newRecipientWallet.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([payer, newTimelockAccount])
.rpc();

// Transfer timelock
const instruction = await tokenlockProgram.methods
.transferTimelock(new anchor.BN(amount), timelockId)
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
sourceTimelockAccount: timelockAccount.publicKey,
targetTimelockAccount: newTimelockAccount.publicKey,
escrowAccount,
pdaAccount: escrowOwnerPDA,
authority: recipientWallet.publicKey,
to: destinationAccount, // Must provide for transfer restrictions
mintAddress: mint,
tokenProgram: TOKEN_2022_PROGRAM_ID,
transferRestrictionsProgram: transferRestrictionsProgramId,
authorityAccount: recipientWallet.publicKey,
securityAssociatedAccountFrom,
securityAssociatedAccountTo,
transferRule,
})
.instruction();

// Add transfer hook extra accounts
await addExtraAccountMetasForExecute(/* ... */);

await tokenlockProgram.provider.sendAndConfirm(
new anchor.web3.Transaction().add(instruction),
[recipientWallet]
);

Requirements:

  • Caller must be current target account owner
  • Timelock ID must be valid
  • Subject to transfer restrictions for unlocked portion
  • Locked tokens remain in escrow under new recipient

Errors:

  • InvalidTimelockId: Timelock doesn't exist
  • TimelockHasntValue: Timelock is empty

Canceling Timelocks

Authorized addresses can cancel timelocks, returning locked tokens to a designated reclaimer.

Cancel Flow:

  1. Unlocked tokens → Transferred to original recipient
  2. Locked tokens → Transferred to reclaimer address (specified by canceler)

TypeScript Example:

const timelockId = 0;
const reclaimerAccount = getAssociatedTokenAddressSync(
mint,
reclaimerWallet.publicKey,
false,
TOKEN_2022_PROGRAM_ID
);

const instruction = await tokenlockProgram.methods
.cancelTimelock(timelockId)
.accountsStrict({
tokenlockAccount: tokenlockAccount.publicKey,
timelockAccount: timelockAccount.publicKey,
escrowAccount,
pdaAccount: escrowOwnerPDA,
target: recipientWallet.publicKey,
targetAssoc: recipientTokenAccount,
authority: cancelerWallet.publicKey, // Must be in cancelable_by
reclaimer: reclaimerAccount,
mintAddress: mint,
tokenProgram: TOKEN_2022_PROGRAM_ID,
transferRestrictionsProgram: transferRestrictionsProgramId,
securityAssociatedAccountFrom: escrowSAA,
securityAssociatedAccountTo: reclaimerSAA,
transferRule: ruleToReclaimer,
})
.instruction();

// Add transfer hook extra accounts for BOTH transfers (unlocked and locked)
await addExtraAccountMetasForExecute(
connection,
instruction,
transferHookProgramId,
escrowAccount,
mint,
reclaimerAccount,
escrowOwnerPDA,
lockedAmount // Approximate amount for locked tokens
);

await addExtraAccountMetasForExecute(
connection,
instruction,
transferHookProgramId,
escrowAccount,
mint,
recipientTokenAccount,
escrowOwnerPDA,
unlockedAmount // Approximate amount for unlocked tokens
);

await tokenlockProgram.provider.sendAndConfirm(
new anchor.web3.Transaction().add(instruction),
[cancelerWallet]
);

Requirements:

  • Caller must be in the timelock's cancelable_by list
  • Timelock must have remaining balance
  • Subject to transfer restrictions (must have valid rules for both transfers)
  • Reclaimer receives locked portion, recipient receives unlocked portion

Errors:

  • HasntCancelTimelockPermission: Caller not authorized
  • InvalidTimelockId: Timelock doesn't exist
  • TimelockHasntValue: Timelock already fully released

Note: Cancellation requires adding transfer hook extra accounts for both the unlocked transfer (to recipient) and locked transfer (to reclaimer).

API Reference

Instructions

initialize_tokenlock(ctx: Context\<InitializeTokenLock\>, max_release_delay: u64, min_timelock_amount: u64) -> Result<()>

Initializes the Tokenlock program for a specific token mint.

Parameters:

  • max_release_delay (u64): Maximum allowed cliff period in seconds (must be ≥ 1)
  • min_timelock_amount (u64): Minimum tokens per timelock (must be ≥ 1)

Accounts:

  • tokenlock_account (Account<TokenLockData\>, zero): New tokenlock data account (must be Keypair)
  • escrow_account (InterfaceAccount<TokenAccount\>, mut): Escrow token account
  • mint_address (InterfaceAccount<Mint\>): Token-2022 mint
  • transfer_restrictions_data (Account<TransferRestrictionData\>): Transfer restrictions data
  • authority_wallet_role (Account<WalletRole\>): Authority's role account
  • access_control (Account<AccessControl\>): Access control account
  • authority (Signer, mut): Must have Contract Admin role
  • token_program (Program): SPL Token-2022 Program

Requirements:

  • max_release_delay ≥ 1
  • min_timelock_amount ≥ 1
  • Caller must have Contract Admin role
  • Tokenlock account must be uninitialized
  • Escrow owner is set to PDA: ["tokenlock", mint.key(), tokenlock_account.key()]

Effects:

  • Initializes tokenlock data account
  • Records escrow account and configuration
  • Sets up PDA as escrow owner

Errors:

  • MaxReleaseDelayLessThanOne: Invalid max delay
  • MinTimelockAmountLessThanOne: Invalid min amount
  • Unauthorized: Caller lacks Contract Admin role

PDA Seeds: ["tokenlock", mint.key(), tokenlock_account.key()] → escrow owner


create_release_schedule(ctx: Context\<ManagementTokenlock\>, uuid: [u8; 16], release_count: u32, delay_until_first_release_in_seconds: u64, initial_release_portion_in_bips: u32, period_between_releases_in_seconds: u64) -> Result<()>

Creates a new release schedule template.

Parameters:

  • uuid ([u8; 16]): Unique identifier for this schedule
  • release_count (u32): Number of releases (must be ≥ 1)
  • delay_until_first_release_in_seconds (u64): Cliff period before first release
  • initial_release_portion_in_bips (u32): Initial release percentage (0-10000 BIPS)
  • period_between_releases_in_seconds (u64): Time between subsequent releases

Accounts:

  • tokenlock_account (AccountInfo, mut): Tokenlock data account
  • authority (Signer, mut): Must have any admin role
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • access_control (Account<AccessControl>): Access control account

Requirements:

  • Caller must have Contract, Reserve, Transfer, or Wallet Admin role
  • release_count ≥ 1
  • delay_until_first_release_in_seconds ≤ max_release_delay
  • initial_release_portion_in_bips ≤ 10000
  • If release_count == 1: initial_release_portion_in_bips == 10000
  • If release_count > 1:
    • period_between_releases_in_seconds > 0
    • initial_release_portion_in_bips < 10000
  • Maximum 100 schedules per tokenlock account
  • UUID must be unique (hashed with authority pubkey)

Effects:

  • Adds new release schedule to tokenlock account
  • Schedule is identified by its index (schedule_id)

Errors:

  • Unauthorized: Caller lacks admin role
  • FirstReleaseDelayBiggerThanMaxDelay: Cliff too long
  • ReleaseCountLessThanOne: Invalid release count
  • InitReleasePortionBiggerThan100Percent: Initial portion > 100%
  • ReleasePeriodZero: Period is 0 for multi-release
  • InitReleasePortionMustBe100Percent: Single release not 100%
  • CantVestAllForMultipleReleases: 100% initial with multiple releases
  • SchedulesCountReachedMax: Too many schedules (max 100)
  • HashAlreadyExists: UUID collision

initialize_timelock(ctx: Context\<InitializeTimeLock\>) -> Result<()>

Initializes a timelock account for a recipient.

Accounts:

  • tokenlock_account (Account<TokenLockData>): Tokenlock data account
  • timelock_account (Account<TimelockData>, init): New timelock account (must be Keypair)
  • target_account (AccountInfo): Recipient wallet
  • payer (Signer, mut): Pays for account creation
  • authority (Signer): Must have any admin role
  • system_program (Program): Solana System Program

Requirements:

  • Timelock account must be a new Keypair
  • Target account is linked to this timelock account
  • Account is dynamically resized as timelocks are added
  • Caller must have any admin role

Effects:

  • Creates timelock data account
  • Links to target account
  • Initializes empty timelocks array

mint_release_schedule(ctx: Context\<MintReleaseSchedule\>, uuid: [u8; 16], amount: u64, commencement_timestamp: u64, schedule_id: u16, cancelable_by: Vec<Pubkey>) -> Result<()>

Mints tokens directly into a timelock with a release schedule.

Parameters:

  • uuid ([u8; 16]): Unique identifier for this timelock
  • amount (u64): Total tokens to lock
  • commencement_timestamp (u64): Unix timestamp when vesting starts
  • schedule_id (u16): Index of release schedule to use
  • cancelable_by (Vec<Pubkey>): Addresses that can cancel (max 10, empty for non-cancelable)

Accounts:

  • tokenlock_account (AccountInfo): Tokenlock data account
  • timelock_account (Account<TimelockData>, mut, realloc): Recipient's timelock account
  • escrow_account (InterfaceAccount<TokenAccount>, mut): Escrow token account
  • escrow_account_owner (AccountInfo): Escrow owner PDA
  • payer (Signer, mut): Pays for account expansion
  • authority (Signer): Must have Reserve Admin role
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • access_control (Account<AccessControl>): Access control account
  • mint_address (InterfaceAccount<Mint>, mut): Token-2022 mint
  • to (AccountInfo): Target recipient wallet (must match timelock account target)
  • token_program (Program): SPL Token-2022 Program
  • access_control_program (Program): Access Control Program
  • system_program (Program): Solana System Program

Requirements:

  • Caller must have Reserve Admin role
  • amount ≥ min_timelock_amount
  • amount ≥ release_count (at least 1 token per release)
  • schedule_id must reference existing schedule
  • commencement_timestamp within now ± max_release_delay
  • Maximum 10 cancelable_by addresses
  • No duplicate addresses in cancelable_by
  • Timelock account is automatically expanded to fit new timelock

Effects:

  • Mints amount tokens to escrow account
  • Creates new timelock entry in recipient's timelock account
  • Assigns unique timelock ID
  • Bypasses transfer restrictions (minting to escrow)

Errors:

  • Unauthorized: Caller lacks Reserve Admin role
  • AmountLessThanMinMintingAmount: Amount too small
  • InvalidScheduleId: Schedule doesn't exist
  • PerReleaseTokenLessThanOne: Not enough tokens per release
  • CommencementTimeoutOfRange: Invalid commencement time
  • Max10CancelableAddresses: Too many cancelers
  • DuplicatedCancelable: Duplicate address in cancelable_by
  • InsufficientDataSpace: Timelock account cannot expand further

transfer(ctx: Context\<TransferFrom\>, value: u64) -> Result<()>

Transfers unlocked tokens from timelocks to a destination account.

Parameters:

  • value (u64): Amount to transfer

Accounts:

  • tokenlock_account (AccountInfo): Tokenlock data account
  • timelock_account (Account<TimelockData>, mut): Source timelock account
  • escrow_account (InterfaceAccount<TokenAccount>, mut): Escrow token account
  • pda_account (AccountInfo): Escrow owner PDA
  • authority (Signer): Must be target account owner
  • to (InterfaceAccount<TokenAccount>, mut): Destination token account
  • mint_address (InterfaceAccount<Mint>): Token-2022 mint
  • token_program (Program): SPL Token-2022 Program
  • transfer_restrictions_program (Program): Transfer Restrictions Program
  • authority_account (AccountInfo): Authority account (for transfer restrictions)
  • security_associated_account_from (AccountInfo): Source SAA
  • security_associated_account_to (AccountInfo): Destination SAA
  • transfer_rule (AccountInfo): Transfer rule account

Remaining Accounts:

Must include transfer hook extra account metas (use addExtraAccountMetasForExecute).

Requirements:

  • Caller must own the target account
  • Transfer amount must not exceed total unlocked balance across all timelocks
  • Subject to transfer restrictions
  • Processes timelocks in order, transferring from each until amount is satisfied

Effects:

  • Transfers unlocked tokens from escrow to destination
  • Updates timelock balances
  • Removes fully exhausted timelocks

Errors:

  • AmountMustBeBiggerThanZero: Transfer amount is 0
  • AmountBiggerThanUnlocked: Insufficient unlocked balance

transfer_timelock(ctx: Context\<TransferTimelock\>, value: u64, timelock_id: u32) -> Result<()>

Transfers a timelock to a different recipient.

Parameters:

  • value (u64): Amount to transfer (not used, kept for compatibility)
  • timelock_id (u32): ID of timelock to transfer

Accounts:

  • tokenlock_account (AccountInfo): Tokenlock data account
  • source_timelock_account (Account<TimelockData>, mut): Source timelock account
  • target_timelock_account (Account<TimelockData>, mut, realloc): Target timelock account
  • escrow_account (InterfaceAccount<TokenAccount>, mut): Escrow token account
  • pda_account (AccountInfo): Escrow owner PDA
  • authority (Signer): Must be source target account owner
  • to (InterfaceAccount<TokenAccount>, mut): Required for transfer restrictions check
  • mint_address (InterfaceAccount<Mint>): Token-2022 mint
  • token_program (Program): SPL Token-2022 Program
  • transfer_restrictions_program (Program): Transfer Restrictions Program
  • authority_account (AccountInfo): Authority account
  • security_associated_account_from (AccountInfo): Source SAA
  • security_associated_account_to (AccountInfo): Destination SAA
  • transfer_rule (AccountInfo): Transfer rule account
  • payer (Signer, mut): Pays for target account expansion
  • system_program (Program): Solana System Program

Remaining Accounts:

Must include transfer hook extra account metas.

Requirements:

  • Caller must own source timelock account's target
  • Timelock ID must be valid
  • Target timelock account must be initialized
  • Subject to transfer restrictions for unlocked portion

Effects:

  • Moves timelock from source to target timelock account
  • Locked tokens remain in escrow
  • Recipient can now access unlocked tokens

Errors:

  • InvalidTimelockId: Timelock doesn't exist
  • TimelockHasntValue: Timelock is empty

cancel_timelock(ctx: Context\<CancelTimelock\>, timelock_id: u32) -> Result<()>

Cancels a timelock, distributing unlocked tokens to recipient and locked tokens to reclaimer.

Parameters:

  • timelock_id (u32): ID of timelock to cancel

Accounts:

  • tokenlock_account (AccountInfo): Tokenlock data account
  • timelock_account (Account<TimelockData>, mut): Timelock account
  • escrow_account (InterfaceAccount<TokenAccount>, mut): Escrow token account
  • pda_account (AccountInfo): Escrow owner PDA
  • target (AccountInfo): Original recipient wallet
  • target_assoc (InterfaceAccount<TokenAccount>, mut): Recipient token account
  • authority (Signer): Must be in timelock's cancelable_by
  • reclaimer (InterfaceAccount<TokenAccount>, mut): Reclaimer token account (receives locked portion)
  • mint_address (InterfaceAccount<Mint>): Token-2022 mint
  • token_program (Program): SPL Token-2022 Program
  • transfer_restrictions_program (Program): Transfer Restrictions Program
  • security_associated_account_from (AccountInfo): Escrow SAA
  • security_associated_account_to (AccountInfo): Reclaimer SAA
  • transfer_rule (AccountInfo): Transfer rule (escrow → reclaimer)

Remaining Accounts:

Must include transfer hook extra account metas for both transfers:

  1. Unlocked tokens: escrow → recipient
  2. Locked tokens: escrow → reclaimer

Requirements:

  • Caller must be in timelock's cancelable_by list
  • Timelock must have remaining balance
  • Transfer rules must exist for both transfers
  • Subject to transfer restrictions

Effects:

  • Calculates unlocked and locked amounts
  • Transfers unlocked tokens to original recipient
  • Transfers locked tokens to reclaimer
  • Removes timelock from account

Errors:

  • HasntCancelTimelockPermission: Caller not authorized
  • InvalidTimelockId: Timelock doesn't exist
  • TimelockHasntValue: Timelock already fully released

Account Structures

TokenLockData

pub struct TokenLockData {
pub mint_address: Pubkey, // Token mint
pub escrow_account: Pubkey, // Escrow token account
pub access_control: Pubkey, // Access control account
pub transfer_restrictions_data: Pubkey, // Transfer restrictions data
pub max_release_delay: u64, // Max cliff period (seconds)
pub min_timelock_amount: u64, // Min tokens per timelock
pub schedule_count: u16, // Number of release schedules
pub schedules: Vec<ReleaseSchedule`, // Release schedule array (max 100)
}

TimelockData

pub struct TimelockData {
pub tokenlock_account: Pubkey, // Parent tokenlock account
pub target_account: Pubkey, // Recipient wallet
pub timelock_count: u32, // Number of timelocks
pub timelocks: Vec<Timelock`, // Timelock array (dynamically sized)
}

ReleaseSchedule

pub struct ReleaseSchedule {
pub signer_hash: [u8; 32], // Hash(authority + uuid)
pub release_count: u32, // Number of releases
pub delay_until_first_release_in_seconds: u64, // Cliff period
pub initial_release_portion_in_bips: u32, // Initial % (0-10000)
pub period_between_releases_in_seconds: u64, // Release interval
}

Timelock

pub struct Timelock {
pub timelock_id: u32, // Unique ID
pub total_amount: u64, // Total locked tokens
pub balance_at_commencement: u64, // Remaining balance
pub schedule_id: u16, // Release schedule reference
pub commencement_timestamp: u64, // Vesting start time
pub cancelable_by_count: u8, // Number of cancelers
pub cancelable_by: [Pubkey; 10], // Authorized cancelers
}

Errors

TokenlockErrors

ErrorCodeDescription
InvalidTokenlockAccount6000Invalid tokenlock account data
MaxReleaseDelayLessThanOne6001Max release delay must be ≥ 1
MinTimelockAmountLessThanOne6002Min timelock amount must be ≥ 1
AmountLessThanMinMintingAmount6003Amount < min timelock amount
InsufficientTokenLockDataSpace6004Tokenlock account full (max 100 schedules)
InsufficientDataSpace6005Timelock account full
InvalidScheduleId6006Schedule ID doesn't exist
PerReleaseTokenLessThanOne6007Not enough tokens per release
CommencementTimeoutOfRange6008Commencement time out of range
InitialReleaseTimeoutOfRange6009Initial release timeout out of range
Max10CancelableAddresses6010Maximum 10 cancelable addresses
InvalidTimelockId6011Timelock ID doesn't exist
TimelockHasntValue6012Timelock has no value left
HasntCancelTimelockPermission6013Not authorized to cancel
AmountBiggerThanUnlocked6014Amount exceeds unlocked balance
AmountMustBeBiggerThanZero6015Amount must be > 0
BadTransfer6016Transfer failed
FirstReleaseDelayLessThanZero6017First release delay < 0
ReleasePeriodLessThanZero6018Release period < 0
FirstReleaseDelayBiggerThanMaxDelay6019Cliff exceeds max delay
ReleaseCountLessThanOne6020Release count < 1
InitReleasePortionBiggerThan100Percent6021Initial portion > 100%
ReleasePeriodZero6022Period is 0 for multi-release
InitReleasePortionMustBe100Percent6023Single release must be 100%
BalanceIsInsufficient6024Insufficient balance
MisMatchedToken6025Token mint mismatch
MisMatchedEscrow6026Escrow account mismatch
HashAlreadyExists6027Schedule UUID collision
DuplicatedCancelable6028Duplicate address in cancelable_by
SchedulesCountReachedMax6029Max 100 schedules reached
CancelablesCountReachedMax6030Max cancelable addresses reached
IncorrectTokenlockAccount6031Wrong tokenlock account
IncorrectEscrowAccount6032Wrong escrow account
Unauthorized6033Caller lacks required role
InvalidAccessControlAccount6034Invalid access control account
InvalidTransferRestrictionData6035Invalid transfer restriction data
InvalidAccountOwner6036Invalid account owner
CantVestAllForMultipleReleases6037Cannot vest 100% initially for multi-release

Integration Notes

With Access Control

  • Contract Admin initializes tokenlock
  • Reserve Admin mints timelocks
  • All operations require role validation

With Transfer Restrictions

  • Set lockup_escrow_account in Transfer Restrictions and Access Control programs
  • Escrow transfers bypass transfer restrictions
  • Transfers from escrow to users are subject to transfer restrictions

Vesting Calculation

The program calculates unlocked amounts using:

pub fn get_unlocked_amount(
schedule: &ReleaseSchedule,
timelock: &Timelock,
current_timestamp: u64
) -> u64 {
let elapsed = current_timestamp.saturating_sub(timelock.commencement_timestamp);

// Before cliff
if elapsed < schedule.delay_until_first_release_in_seconds {
return 0;
}

// Calculate releases occurred
let time_since_first_release = elapsed - schedule.delay_until_first_release_in_seconds;
let releases_occurred = 1 + (time_since_first_release / schedule.period_between_releases_in_seconds).min(schedule.release_count as u64 - 1);

// Initial release
let initial_unlocked = (timelock.total_amount * schedule.initial_release_portion_in_bips as u64) / 10000;

// Subsequent releases
let remaining = timelock.total_amount - initial_unlocked;
let per_release = remaining / (schedule.release_count as u64 - 1).max(1);
let subsequent_unlocked = per_release * (releases_occurred - 1);

initial_unlocked + subsequent_unlocked
}

Security Considerations

Escrow Ownership

  • Escrow owner is a PDA controlled by the tokenlock program
  • Ensures only program logic can transfer escrowed tokens
  • Never manually transfer tokens from escrow

Cancelable Timelocks

  • Use for employee vestings (company can cancel on termination)
  • Do not use for investor lockups (should be non-cancelable)
  • Maximum 10 cancelers per timelock

Transfer Restrictions

  • Escrow account must be registered in Access Control and Transfer Restrictions
  • Transfers from escrow to users must have valid transfer rules
  • Cancellations require two valid transfer rules (to recipient and to reclaimer)

Common Patterns

Employee Vesting (4-Year with 1-Year Cliff)

// Schedule: 0% initial, 48 monthly releases, 1-year cliff
await createReleaseSchedule(
uuid,
48, // releases
365 * 24 * 60 * 60, // 1 year cliff
0, // 0% initial
30 * 24 * 60 * 60 // 30 days period
);

// Mint timelock (cancelable by company)
await mintReleaseSchedule(
uuid,
48_000_tokens,
Date.now() / 1000, // start now
scheduleId,
[companyWallet] // cancelable
);

Investor Lockup (2-Year Cliff, 100% Release)

// Schedule: 100% initial, 1 release, 2-year cliff
await createReleaseSchedule(
uuid,
1, // single release
2 * 365 * 24 * 60 * 60, // 2 years
10000, // 100%
0 // period not used
);

// Mint timelock (non-cancelable)
await mintReleaseSchedule(
uuid,
1_000_000_tokens,
Date.now() / 1000,
scheduleId,
[] // non-cancelable
);

Founder Vesting (25% at 1 Year, then Monthly for 3 Years)

// Schedule: 25% initial, 37 releases (1 + 36), 1-year cliff
await createReleaseSchedule(
uuid,
37, // 1 initial + 36 monthly
365 * 24 * 60 * 60, // 1 year cliff
2500, // 25% (2500 BIPS)
30 * 24 * 60 * 60 // 30 days period
);

Program ID

Mainnet/Devnet: AoodM6rkg968933giHnigMEwp9kiGi68ZEx9bPqk71Gt

IDL

The Interface Definition Language (IDL) file contains the complete program interface including all instructions, accounts, types, and errors.

Download: tokenlock.json

Usage with Anchor:

import { Program, AnchorProvider } from "@coral-xyz/anchor";
import idl from "./tokenlock.json";

const programId = new PublicKey("AoodM6rkg968933giHnigMEwp9kiGi68ZEx9bPqk71Gt");
const program = new Program(idl, programId, provider);

Key Instructions in IDL:

  • initialize_tokenlock - Initialize tokenlock program
  • initialize_timelock - Create timelock account for recipient
  • create_release_schedule - Define vesting schedule
  • mint_release_schedule - Mint tokens into timelock
  • transfer - Transfer unlocked tokens
  • transfer_timelock - Transfer timelock to new recipient
  • cancel_timelock - Cancel vesting (if allowed)