Skip to content

Solana

TokenTable Unlocker is a self-custodial program for unlocking tokens on a schedule. Each unlocker is controlled by its project owner. The program admin can still change an unlocker's fee collector and fee token.

Unlocker splits a token distribution into presets and actuals. A preset is an unlocking schedule for a group of recipients. An actual defines the claim variables, such as the number of tokens a recipient is allocated.

Feature Description
Unlocking schedules Linear unlocks, cliff unlocks and pauses
Partial deposits The full amount does not have to be deposited upfront
Unruggable configuration An unlocker can be configured so that actuals cannot be cancelled and tokens cannot be withdrawn
Delegate claiming A delegate can send unlocked tokens directly to recipients' wallets
Dashboard Create, view and track unlocker information from a personal dashboard

System overview

Account structure

Accounts of the Unlocker program Accounts of the Unlocker program
Accounts of the Unlocker program

Storage pattern

The program has one config account. It stores the admin's address, the default fee collector's address, and the address of a pending new admin for two-step ownership transfers.

pub struct Config {
  pub admin: Pubkey,
  pub new_admin: Pubkey,
  pub default_fee_collector: Pubkey,
}

The program can manage many token unlocks. Each initialized unlocker is stored as follows:

pub struct Unlocker {
  pub owner: Pubkey,
  pub project_token: Pubkey,
  pub fee_collector: Pubkey,
  pub fee_token: Pubkey,
  pub is_createable: bool,
  pub is_cancelable: bool,
  pub is_transferable: bool,
  pub is_withdrawable: bool,
  #[max_len(20)]
  pub project_id: String,
}

initialize sets owner to the signer and is_createable to true; is_cancelable, is_transferable and is_withdrawable are passed in. Each flag can later be turned off (never back on) with disable_create, disable_cancel, disable_transfer_actual and disable_withdraw.

Each preset is stored in a PresetAccount:

pub struct PresetAccount {
  pub linear_start_timestamps_relative: Vec<u64>,
  pub linear_end_timestamp_relative: u64,
  pub linear_bips: Vec<u64>,
  pub num_of_unlocks_for_each_linear: Vec<u64>,
  pub stream: bool,
  pub next_actual_id: u64,
  pub preset_id: u64,
  pub project_id: String,
}

Each actual is stored in an ActualAccount. Actual IDs are assigned from the preset's next_actual_id:

pub struct ActualAccount {
  pub recipient: Pubkey,
  pub actual_id: u64,
  pub preset_id: u64,
  pub start_timestamp_absolute: u64,
  pub amount_claimed: u64,
  pub total_amount: u64,
  pub project_id: String,
}

A claiming delegate is enabled or disabled per unlocker:

pub struct ClaimingDelegate {
  pub status: bool,
}

When an actual is cancelled, the actual account is closed. If the owner chooses not to wipe the recipient's unlocked but unclaimed balance, that amount is kept in a separate account until the recipient claims it:

pub struct PendingAmountClaimableForCancelledActualsAccount {
  pub pending_amount_claimable_for_cancelled_actuals: u64,
  pub recipient: Pubkey,
}

PDA seeds

Account Seeds Count
config "config" One per program
unlocker "unlocker", project_id One per project ID
vault "vault", unlocker address One per unlocker; token authority is the unlocker PDA
claiming_delegate "claiming_delegate", unlocker address, delegate One per delegate per unlocker
preset "preset", unlocker address, preset_id (u64 LE) Many per unlocker
actual "actual", unlocker address, preset_id (u64 LE), actual_id (u64 LE) Many per preset
pending_amount_claimable_for_cancelled_actuals "pending_claimable", unlocker address, preset_id (u64 LE), actual_id (u64 LE) At most one per cancelled actual

Claim mechanism

Recipients can claim their own tokens, or a claiming delegate whose claiming_delegate account has status set to true can claim for them. Either way, the tokens go to the recipient's associated token account.

pub fn claim(
  ctx: Context<Claim>,
  project_id: String,
  actual_id: u64,
  batch_id: u64
 ) -> Result<()>

If the owner cancelled an actual without wiping its unlocked tokens, the recipient or a delegate claims those tokens with:

pub fn claim_cancelled_actual_tokens(
  ctx: Context<ClaimCancelledActualTokens>,
  project_id: String,
  preset_id: u64,
  actual_id: u64,
  batch_id: u64
) -> Result<()>

Fee system

On every claim, the unlocker makes a CPI to the fee collector's collect_fee instruction with the claimed amount, and reports the result as fees_charged in TokensClaimed. The account that signs the claim (the recipient or the delegate) pays the fee. It is not deducted from the claimed tokens.

The fee collector is the external FeeCollector program, which calculates fees and manages the vault accounts. Each unlocker stores its own fee_collector, copied from default_fee_collector at initialization. The fee for a project is read from the fee collector's Fee account (seeds "fee", unlocker program ID, project_id). If that account is set to default, the fee is claimed_amount * default_fees_bips / 10000. Otherwise the fee collector uses a fixed fee if one is set, and custom bips if not.

Fees are paid in native SOL when fee_token is the default public key, and in an SPL token otherwise. The program admin sets the fee token per unlocker with set_fee_token.

Fee tokens are held in Program Derived Accounts (PDAs). SOL fees go to the fee collector's storage PDA, and SPL fees go to the fee collector's vault PDA (seeds "vault", fee token mint).

Security

Access control

Role Permissions
Program Admin Set default fee collector, set an unlocker's fee collector and fee token, transfer program admin
Unlocker Owner Create presets and actuals, cancel actuals, set claiming delegates, disable features, withdraw deposited tokens, transfer or renounce unlocker ownership
Delegate Claim on behalf of recipients
Users Claim their allocated tokens, transfer their actuals (if the unlocker allows it)

deposit has no owner check: any account can deposit the unlocker's token into the vault.

Safeguards

The owner can permanently disable creating, cancelling, transferring and withdrawing, each with its own one-way switch. Claimable amounts are calculated from the preset schedule and the actual's start time.

Events and errors

Events

Event Description
Deployed Program config created by deploy
Initialized Successful unlocker initialization
ActualCreated Successful actual creation
PresetCreated Successful preset creation
ClaimingDelegateSet Delegate account permissions changed
TokensClaimed Tokens claimed successfully (by claim or claim_cancelled_actual_tokens)
ActualCancelled Successfully cancelled an actual
TokensWithdrawn Successfully withdrew tokens
CreateDisabled Successfully disabled creating actuals
CancelDisabled Successfully disabled cancel functionality
TransferActualDisabled Successfully disabled actual transfer functionality
WithdrawDisabled Successfully disabled token withdrawal functionality
HookDisabled Defined, but never emitted by the current code

Errors

Error Description
ActualDoesNotExist Actual does not exist
PresetDoesNotExist Actual does not reference a valid preset
DataMismatch Call parameters do not match supplied accounts in transaction
InvalidPresetFormat Preset data contains inconsistencies or an invalid ID
InvalidSkipAmount An actual's amount_claimed must be less than the actual's total_amount
NotOwner Not unlocker owner
NotProgramAdmin Not program admin
NotTransferable Unlocker does not allow the transfer of actuals
NotWithdrawable Unlocker does not allow the withdrawal of deposited tokens
NotPermissioned Missing permissions for user or unlocker feature disabled
InvalidFeeCollector Provided fee collector account is incorrect