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¶
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.
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:
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 |