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:
| Type | Releases | Cliff | Initial % | Period | Description |
|---|---|---|---|---|---|
| Employee 4-Year Vest | 48 | 1 yr | 0% | 1 mo | Monthly vesting after 1-year cliff |
| Investor 2-Year Lockup | 1 | 2 yr | 100% | N/A | Full release after 2 years |
| Founder 4-Year + Cliff | 49 | 1 yr | 25% | 1 mo | 25% 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:
- Time Elapsed:
current_time - commencement_timestamp - Release Schedule: Determines which releases have occurred
- Initial Release:
total_amount * initial_release_portion_in_bips / 10000 - 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 periodmin_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 ≥ 1delay_until_first_release ≤ max_release_delayinitial_release_portion_in_bips ≤ 10000(100%)- If
release_count == 1:initial_release_portion_in_bipsmust be 10000 (100%) - If
release_count > 1:period_between_releases > 0initial_release_portion_in_bips < 10000(cannot vest 100% initially)
- Maximum 100 schedules per tokenlock account
Errors:
SchedulesCountReachedMax: Too many schedulesFirstReleaseDelayBiggerThanMaxDelay: Cliff exceeds maxReleaseCountLessThanOne: Invalid release countReleasePeriodZero: Period is 0 for multi-release scheduleCantVestAllForMultipleReleases: 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_amountamount ≥ release_count(at least 1 token per release)schedule_idmust reference an existing schedulecommencement_timestampmust be withinmax_release_delayof current time- Maximum 10
cancelable_byaddresses - Mints tokens and locks them in escrow
- Does not require security associated account (bypass transfer restrictions)
Errors:
AmountLessThanMinMintingAmount: Amount too smallInvalidScheduleId: Schedule doesn't existPerReleaseTokenLessThanOne: Not enough tokens per releaseCommencementTimeoutOfRange: Commencement time invalidMax10CancelableAddresses: 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 tokensAmountMustBeBiggerThanZero: 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 existTimelockHasntValue: Timelock is empty
Canceling Timelocks
Authorized addresses can cancel timelocks, returning locked tokens to a designated reclaimer.
Cancel Flow:
- Unlocked tokens → Transferred to original recipient
- 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_bylist - 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 authorizedInvalidTimelockId: Timelock doesn't existTimelockHasntValue: 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 accountmint_address(InterfaceAccount<Mint\>): Token-2022 minttransfer_restrictions_data(Account<TransferRestrictionData\>): Transfer restrictions dataauthority_wallet_role(Account<WalletRole\>): Authority's role accountaccess_control(Account<AccessControl\>): Access control accountauthority(Signer, mut): Must have Contract Admin roletoken_program(Program): SPL Token-2022 Program
Requirements:
max_release_delay ≥ 1min_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 delayMinTimelockAmountLessThanOne: Invalid min amountUnauthorized: 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 schedulerelease_count(u32): Number of releases (must be ≥ 1)delay_until_first_release_in_seconds(u64): Cliff period before first releaseinitial_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 accountauthority(Signer, mut): Must have any admin roleauthority_wallet_role(Account<WalletRole>): Authority's role accountaccess_control(Account<AccessControl>): Access control account
Requirements:
- Caller must have Contract, Reserve, Transfer, or Wallet Admin role
release_count ≥ 1delay_until_first_release_in_seconds ≤ max_release_delayinitial_release_portion_in_bips ≤ 10000- If
release_count == 1:initial_release_portion_in_bips == 10000 - If
release_count > 1:period_between_releases_in_seconds > 0initial_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 roleFirstReleaseDelayBiggerThanMaxDelay: Cliff too longReleaseCountLessThanOne: Invalid release countInitReleasePortionBiggerThan100Percent: Initial portion > 100%ReleasePeriodZero: Period is 0 for multi-releaseInitReleasePortionMustBe100Percent: Single release not 100%CantVestAllForMultipleReleases: 100% initial with multiple releasesSchedulesCountReachedMax: 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 accounttimelock_account(Account<TimelockData>, init): New timelock account (must be Keypair)target_account(AccountInfo): Recipient walletpayer(Signer, mut): Pays for account creationauthority(Signer): Must have any admin rolesystem_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 timelockamount(u64): Total tokens to lockcommencement_timestamp(u64): Unix timestamp when vesting startsschedule_id(u16): Index of release schedule to usecancelable_by(Vec<Pubkey>): Addresses that can cancel (max 10, empty for non-cancelable)
Accounts:
tokenlock_account(AccountInfo): Tokenlock data accounttimelock_account(Account<TimelockData>, mut, realloc): Recipient's timelock accountescrow_account(InterfaceAccount<TokenAccount>, mut): Escrow token accountescrow_account_owner(AccountInfo): Escrow owner PDApayer(Signer, mut): Pays for account expansionauthority(Signer): Must have Reserve Admin roleauthority_wallet_role(Account<WalletRole>): Authority's role accountaccess_control(Account<AccessControl>): Access control accountmint_address(InterfaceAccount<Mint>, mut): Token-2022 mintto(AccountInfo): Target recipient wallet (must match timelock account target)token_program(Program): SPL Token-2022 Programaccess_control_program(Program): Access Control Programsystem_program(Program): Solana System Program
Requirements:
- Caller must have Reserve Admin role
amount ≥ min_timelock_amountamount ≥ release_count(at least 1 token per release)schedule_idmust reference existing schedulecommencement_timestampwithinnow ± max_release_delay- Maximum 10
cancelable_byaddresses - No duplicate addresses in
cancelable_by - Timelock account is automatically expanded to fit new timelock
Effects:
- Mints
amounttokens 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 roleAmountLessThanMinMintingAmount: Amount too smallInvalidScheduleId: Schedule doesn't existPerReleaseTokenLessThanOne: Not enough tokens per releaseCommencementTimeoutOfRange: Invalid commencement timeMax10CancelableAddresses: Too many cancelersDuplicatedCancelable: Duplicate address in cancelable_byInsufficientDataSpace: 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 accounttimelock_account(Account<TimelockData>, mut): Source timelock accountescrow_account(InterfaceAccount<TokenAccount>, mut): Escrow token accountpda_account(AccountInfo): Escrow owner PDAauthority(Signer): Must be target account ownerto(InterfaceAccount<TokenAccount>, mut): Destination token accountmint_address(InterfaceAccount<Mint>): Token-2022 minttoken_program(Program): SPL Token-2022 Programtransfer_restrictions_program(Program): Transfer Restrictions Programauthority_account(AccountInfo): Authority account (for transfer restrictions)security_associated_account_from(AccountInfo): Source SAAsecurity_associated_account_to(AccountInfo): Destination SAAtransfer_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 0AmountBiggerThanUnlocked: 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 accountsource_timelock_account(Account<TimelockData>, mut): Source timelock accounttarget_timelock_account(Account<TimelockData>, mut, realloc): Target timelock accountescrow_account(InterfaceAccount<TokenAccount>, mut): Escrow token accountpda_account(AccountInfo): Escrow owner PDAauthority(Signer): Must be source target account ownerto(InterfaceAccount<TokenAccount>, mut): Required for transfer restrictions checkmint_address(InterfaceAccount<Mint>): Token-2022 minttoken_program(Program): SPL Token-2022 Programtransfer_restrictions_program(Program): Transfer Restrictions Programauthority_account(AccountInfo): Authority accountsecurity_associated_account_from(AccountInfo): Source SAAsecurity_associated_account_to(AccountInfo): Destination SAAtransfer_rule(AccountInfo): Transfer rule accountpayer(Signer, mut): Pays for target account expansionsystem_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 existTimelockHasntValue: 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 accounttimelock_account(Account<TimelockData>, mut): Timelock accountescrow_account(InterfaceAccount<TokenAccount>, mut): Escrow token accountpda_account(AccountInfo): Escrow owner PDAtarget(AccountInfo): Original recipient wallettarget_assoc(InterfaceAccount<TokenAccount>, mut): Recipient token accountauthority(Signer): Must be in timelock's cancelable_byreclaimer(InterfaceAccount<TokenAccount>, mut): Reclaimer token account (receives locked portion)mint_address(InterfaceAccount<Mint>): Token-2022 minttoken_program(Program): SPL Token-2022 Programtransfer_restrictions_program(Program): Transfer Restrictions Programsecurity_associated_account_from(AccountInfo): Escrow SAAsecurity_associated_account_to(AccountInfo): Reclaimer SAAtransfer_rule(AccountInfo): Transfer rule (escrow → reclaimer)
Remaining Accounts:
Must include transfer hook extra account metas for both transfers:
- Unlocked tokens: escrow → recipient
- Locked tokens: escrow → reclaimer
Requirements:
- Caller must be in timelock's
cancelable_bylist - 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 authorizedInvalidTimelockId: Timelock doesn't existTimelockHasntValue: 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
| Error | Code | Description |
|---|---|---|
InvalidTokenlockAccount | 6000 | Invalid tokenlock account data |
MaxReleaseDelayLessThanOne | 6001 | Max release delay must be ≥ 1 |
MinTimelockAmountLessThanOne | 6002 | Min timelock amount must be ≥ 1 |
AmountLessThanMinMintingAmount | 6003 | Amount < min timelock amount |
InsufficientTokenLockDataSpace | 6004 | Tokenlock account full (max 100 schedules) |
InsufficientDataSpace | 6005 | Timelock account full |
InvalidScheduleId | 6006 | Schedule ID doesn't exist |
PerReleaseTokenLessThanOne | 6007 | Not enough tokens per release |
CommencementTimeoutOfRange | 6008 | Commencement time out of range |
InitialReleaseTimeoutOfRange | 6009 | Initial release timeout out of range |
Max10CancelableAddresses | 6010 | Maximum 10 cancelable addresses |
InvalidTimelockId | 6011 | Timelock ID doesn't exist |
TimelockHasntValue | 6012 | Timelock has no value left |
HasntCancelTimelockPermission | 6013 | Not authorized to cancel |
AmountBiggerThanUnlocked | 6014 | Amount exceeds unlocked balance |
AmountMustBeBiggerThanZero | 6015 | Amount must be > 0 |
BadTransfer | 6016 | Transfer failed |
FirstReleaseDelayLessThanZero | 6017 | First release delay < 0 |
ReleasePeriodLessThanZero | 6018 | Release period < 0 |
FirstReleaseDelayBiggerThanMaxDelay | 6019 | Cliff exceeds max delay |
ReleaseCountLessThanOne | 6020 | Release count < 1 |
InitReleasePortionBiggerThan100Percent | 6021 | Initial portion > 100% |
ReleasePeriodZero | 6022 | Period is 0 for multi-release |
InitReleasePortionMustBe100Percent | 6023 | Single release must be 100% |
BalanceIsInsufficient | 6024 | Insufficient balance |
MisMatchedToken | 6025 | Token mint mismatch |
MisMatchedEscrow | 6026 | Escrow account mismatch |
HashAlreadyExists | 6027 | Schedule UUID collision |
DuplicatedCancelable | 6028 | Duplicate address in cancelable_by |
SchedulesCountReachedMax | 6029 | Max 100 schedules reached |
CancelablesCountReachedMax | 6030 | Max cancelable addresses reached |
IncorrectTokenlockAccount | 6031 | Wrong tokenlock account |
IncorrectEscrowAccount | 6032 | Wrong escrow account |
Unauthorized | 6033 | Caller lacks required role |
InvalidAccessControlAccount | 6034 | Invalid access control account |
InvalidTransferRestrictionData | 6035 | Invalid transfer restriction data |
InvalidAccountOwner | 6036 | Invalid account owner |
CantVestAllForMultipleReleases | 6037 | Cannot 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_accountin 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 programinitialize_timelock- Create timelock account for recipientcreate_release_schedule- Define vesting schedulemint_release_schedule- Mint tokens into timelocktransfer- Transfer unlocked tokenstransfer_timelock- Transfer timelock to new recipientcancel_timelock- Cancel vesting (if allowed)