Skip to main content

Transfer Restrictions

Overview

The Transfer Restrictions program implements transfer rules and holder management for Solana RWA tokens using SPL Token-2022's Transfer Hook extension. It enforces compliance requirements such as transfer groups, holder limits, lockup periods, and wallet permissions.

Architecture

  • Transfer Hook Integration: Executes on every token transfer via Token-2022 transfer hooks
  • Group-Based Restrictions: Wallets are assigned to transfer groups with configurable transfer rules
  • Holder Management: Tracks unique holders across groups with consolidation under common holder IDs
  • Time-Based Lockups: Enforces lockup periods between transfer groups
  • Holder Limits: Configurable maximum holders globally and per group

Key Features

  • On-chain enforcement of transfer restrictions via transfer hooks
  • Group-to-group transfer rules with time locks
  • Holder tracking with multiple wallet support per holder
  • Maximum holder caps (global and per-group)
  • Wallet freezing at the transfer restriction level
  • Emergency pause functionality
  • Integration with tokenlock escrow accounts

Core Concepts

Transfer Groups

Transfer groups categorize wallets based on regulatory or business requirements (e.g., Reg D, Reg S, Reg CF, Founders, Exchanges).

Key Properties:

  • Each group has a unique ID (Group 0 is the default group)
  • Groups can have maximum holder limits
  • Transfer rules define allowed transfers between groups

Example Groups:

Group IDNamePurpose
0Default GroupCatch-all for non-categorized
1Issuer Token ReservesCompany treasury
2Regulated ExchangeListed exchange custody accounts
3Reg S Non-US ApprovedForeign investors
5Founders (2 Yr Lockup)Founder allocations

Transfer Rules

Transfer rules define which group-to-group transfers are allowed and when.

Rule Structure:

pub struct TransferRule {
pub transfer_restriction_data: Pubkey, // Parent restriction data
pub transfer_group_id_from: u64, // Source group
pub transfer_group_id_to: u64, // Destination group
pub locked_until: u64, // Unix timestamp (0 = never allowed)
}

Special Value: locked_until = 0 means transfers are not allowed (this is the default state for all group pairs).

Example Rules:

Holders and Wallets

Holder: A unique entity that can own multiple wallet addresses across different groups.

Wallet: An individual Solana address with an associated token account.

Key Features:

  • One holder can have multiple wallets
  • Wallets can be in different transfer groups
  • Holder count is tracked globally and per-group
  • Holders are consolidated under a common holder_id

Example:

Holder A (holder_id: 1)
├─ Wallet 1 (Group 1)
├─ Wallet 2 (Group 1)
├─ Wallet 3 (Group 2)
└─ Wallet 4 (Group 3)

Holder A counts as:

  • 1 unique holder globally
  • 1 unique holder in Group 1
  • 1 unique holder in Group 2
  • 1 unique holder in Group 3

Security Associated Account (SAA)

Every wallet must have a Security Associated Account (SAA) to receive transfers. The SAA links a wallet's token account to:

  • A transfer group
  • A holder account
  • A holder group account

Structure:

pub struct SecurityAssociatedAccount {
pub group: u64, // Transfer group ID
pub holder: Option<Pubkey`, // Associated holder account
}

PDA Seeds: ["saa", associated_token_account.key()]

How Transfer Restrictions Work

Transfer Hook Execution Flow

Every token transfer triggers the transfer hook, which enforces restrictions:

Restriction Checks

During transfer hook execution, the following checks are performed:

  1. Pause Check: Is the system paused?
  2. Escrow Bypass: Is this transfer involving the lockup escrow account?
  3. Group Validation: Do source and destination SAAs exist?
  4. Transfer Rule: Does a rule exist for (source_group → dest_group)?
  5. Time Lock: Has locked_until timestamp passed?
  6. Holder Limits: Would this transfer exceed holder limits?

Usage

Initialization

Initialize the Transfer Restrictions program for a token mint.

TypeScript Example:

const [transferRestrictionDataPDA] =
anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("trd"), mint.toBuffer()],
transferRestrictionsProgramId
);

const [zeroGroupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(0).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.initializeTransferRestrictionsData(
new anchor.BN(10000) // max_holders
)
.accountsStrict({
transferRestrictionData: transferRestrictionDataPDA,
zeroTransferRestrictionGroup: zeroGroupPDA,
mint: mint,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
payer: payer.publicKey,
authority: transferAdmin.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
tokenProgram: TOKEN_2022_PROGRAM_ID,
})
.signers([transferAdmin, payer])
.rpc();

Note: Group 0 (default group) is automatically created during initialization.

Creating Transfer Groups

Transfer Admins can create new transfer groups.

TypeScript Example:

const groupId = 1; // Issuer Reserves

const [groupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(groupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.initializeTransferRestrictionGroup(new anchor.BN(groupId))
.accountsStrict({
transferRestrictionGroup: groupPDA,
transferRestrictionData: transferRestrictionDataPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([transferAdmin, payer])
.rpc();

Creating Transfer Rules

Define allowed transfers between groups.

TypeScript Example:

const fromGroupId = 1; // Issuer
const toGroupId = 2; // Exchange
const lockedUntil = Math.floor(Date.now() / 1000); // Allow immediately (use future timestamp for lockup)

const [fromGroupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(fromGroupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

const [toGroupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(toGroupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

const [transferRulePDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("tr"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(fromGroupId).toArrayLike(Buffer, "le", 8),
new anchor.BN(toGroupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.initializeTransferRule(
new anchor.BN(fromGroupId),
new anchor.BN(toGroupId),
new anchor.BN(lockedUntil)
)
.accountsStrict({
transferRule: transferRulePDA,
transferRestrictionData: transferRestrictionDataPDA,
transferRestrictionGroupFrom: fromGroupPDA,
transferRestrictionGroupTo: toGroupPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([transferAdmin, payer])
.rpc();

Important: Transfer rules are directional. A rule from Group A → Group B does not allow transfers from Group B → Group A.

Initializing Holders and Wallets

Before a wallet can receive tokens, it must be provisioned with:

  1. A holder account
  2. A holder group account (for each group the holder participates in)
  3. A security associated account (for each wallet)

Step 1: Create Holder Account

const holderId = 1;

const [holderPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trh"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(holderId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.initializeTransferRestrictionHolder(new anchor.BN(holderId))
.accountsStrict({
transferRestrictionHolder: holderPDA,
transferRestrictionData: transferRestrictionDataPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: walletAdminWalletRolePDA,
authority: walletAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([walletAdmin, payer])
.rpc();

Step 2: Create Holder Group Account

const groupId = 1;

const [holderGroupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trhg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(holderId).toArrayLike(Buffer, "le", 8),
new anchor.BN(groupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.initializeHolderGroup()
.accountsStrict({
holderGroup: holderGroupPDA,
transferRestrictionData: transferRestrictionDataPDA,
group: groupPDA,
holder: holderPDA,
authorityWalletRole: walletAdminWalletRolePDA,
authority: walletAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([walletAdmin, payer])
.rpc();

Step 3: Create Security Associated Account

import { getAssociatedTokenAddressSync } from "@solana/spl-token";

const userTokenAccount = getAssociatedTokenAddressSync(
mint,
userWallet.publicKey,
false,
TOKEN_2022_PROGRAM_ID
);

const [saaPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("saa"), userTokenAccount.toBuffer()],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.initializeSecurityAssociatedAccount(
new anchor.BN(groupId),
new anchor.BN(holderId)
)
.accountsStrict({
securityAssociatedAccount: saaPDA,
group: groupPDA,
holder: holderPDA,
holderGroup: holderGroupPDA,
securityToken: mint,
transferRestrictionData: transferRestrictionDataPDA,
userWallet: userWallet.publicKey,
associatedTokenAccount: userTokenAccount,
authorityWalletRole: walletAdminWalletRolePDA,
authority: walletAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([walletAdmin, payer])
.rpc();

Convenience Function: Use initialize_default_security_accounts to create all three accounts in one transaction for a new wallet in Group 0.

Updating Transfer Rules

Modify existing transfer rules with set_allow_transfer_rule.

TypeScript Example:

const newLockedUntil = Math.floor(Date.now() / 1000) + 86400 * 180; // 180 days from now

await transferRestrictionsProgram.methods
.setAllowTransferRule(
new anchor.BN(fromGroupId),
new anchor.BN(toGroupId),
new anchor.BN(newLockedUntil)
)
.accountsStrict({
transferRule: transferRulePDA,
transferRestrictionData: transferRestrictionDataPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
})
.signers([transferAdmin])
.rpc();

Changing Wallet Groups

Move a wallet to a different transfer group using update_wallet_group.

TypeScript Example:

const newGroupId = 2;

const [newGroupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(newGroupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

const [newHolderGroupPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("trhg"),
transferRestrictionDataPDA.toBuffer(),
new anchor.BN(holderId).toArrayLike(Buffer, "le", 8),
new anchor.BN(newGroupId).toArrayLike(Buffer, "le", 8),
],
transferRestrictionsProgramId
);

await transferRestrictionsProgram.methods
.updateWalletGroup(new anchor.BN(newGroupId))
.accountsStrict({
securityAssociatedAccount: saaPDA,
transferRestrictionData: transferRestrictionDataPDA,
currentGroup: currentGroupPDA,
currentHolderGroup: currentHolderGroupPDA,
newGroup: newGroupPDA,
newHolderGroup: newHolderGroupPDA,
userWallet: userWallet.publicKey,
associatedTokenAccount: userTokenAccount,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
payer: payer.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([transferAdmin, payer])
.rpc();

Note: The wallet must have 0 token balance to change groups.

Setting Holder Limits

Global Holder Max:

await transferRestrictionsProgram.methods
.setHolderMax(new anchor.BN(5000)) // Max 5000 unique holders
.accountsStrict({
transferRestrictionData: transferRestrictionDataPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
})
.signers([transferAdmin])
.rpc();

Per-Group Holder Max:

const groupId = 3; // Reg S Group
const maxHolders = 500;

await transferRestrictionsProgram.methods
.setHolderGroupMax(new anchor.BN(groupId), new anchor.BN(maxHolders))
.accountsStrict({
transferRestrictionGroup: groupPDA,
transferRestrictionData: transferRestrictionDataPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
})
.signers([transferAdmin])
.rpc();

Note: Group 0 cannot have a holder max (unlimited holders allowed in default group).

Pausing Transfers

Transfer Admins can pause all transfers in emergency situations.

TypeScript Example:

// Pause all transfers
await transferRestrictionsProgram.methods
.pause(true)
.accountsStrict({
transferRestrictionData: transferRestrictionDataPDA,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
})
.signers([transferAdmin])
.rpc();

// Unpause
await transferRestrictionsProgram.methods
.pause(false)
.accountsStrict({
/* same accounts */
})
.signers([transferAdmin])
.rpc();

Setting Lockup Escrow Account

Configure the escrow account for tokenlock integration to bypass transfer restrictions.

TypeScript Example:

await transferRestrictionsProgram.methods
.setLockupEscrowAccount()
.accountsStrict({
transferRestrictionData: transferRestrictionDataPDA,
escrowAccount: escrowTokenAccount,
securityToken: mint,
accessControlAccount: accessControlPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
})
.signers([transferAdmin])
.rpc();

Revoking Accounts

To reclaim rent SOL, accounts can be revoked in specific order.

Order of Revocation:

  1. Revoke all Security Associated Accounts for a holder
  2. Revoke all Holder Group Accounts for a holder
  3. Revoke the Holder Account

Revoke Security Associated Account:

await transferRestrictionsProgram.methods
.revokeSecurityAssociatedAccount()
.accountsStrict({
securityAssociatedAccount: saaPDA,
holderGroup: holderGroupPDA,
group: groupPDA,
holder: holderPDA,
transferRestrictionData: transferRestrictionDataPDA,
userWallet: userWallet.publicKey,
associatedTokenAccount: userTokenAccount,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
destination: destination.publicKey, // Receives reclaimed SOL
})
.signers([transferAdmin])
.rpc();

Requirements:

  • Token account must have 0 balance
  • Updates holder group and group holder counts

Revoke Holder Group:

await transferRestrictionsProgram.methods
.revokeHolderGroup()
.accountsStrict({
holderGroup: holderGroupPDA,
holder: holderPDA,
group: groupPDA,
transferRestrictionData: transferRestrictionDataPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
destination: destination.publicKey,
})
.signers([transferAdmin])
.rpc();

Requirements:

  • Holder must have no wallets in this group (current_wallets_count == 0)

Revoke Holder:

await transferRestrictionsProgram.methods
.revokeHolder()
.accountsStrict({
transferRestrictionHolder: holderPDA,
transferRestrictionData: transferRestrictionDataPDA,
authorityWalletRole: transferAdminWalletRolePDA,
authority: transferAdmin.publicKey,
destination: destination.publicKey,
})
.signers([transferAdmin])
.rpc();

Requirements:

  • current_holder_group_count == 0
  • current_wallets_count == 0

API Reference

Instructions

initialize_transfer_restrictions_data(ctx: Context\<InitializeTransferRestrictionData\>, max_holders: u64) -> Result<()>

Initializes the Transfer Restrictions program for a token mint.

Parameters:

  • max_holders (u64): Maximum number of unique holders allowed globally

Accounts:

  • transfer_restriction_data (Account<TransferRestrictionData\>, init): Main restriction data PDA
  • zero_transfer_restriction_group (Account<TransferRestrictionGroup\>, init): Default group (ID: 0)
  • mint (InterfaceAccount<Mint\>): Token-2022 mint
  • access_control_account (Account<AccessControl\>): Access control account
  • authority_wallet_role (Account<WalletRole\>): Authority's role account
  • payer (Signer, mut): Pays for account creation
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • system_program (Program): Solana System Program
  • token_program (Program): SPL Token-2022 Program

Requirements:

  • Can only be called once per mint
  • Authority must have Transfer Admin (8) or Wallet Admin (4) role
  • Automatically creates Group 0 (default group)

PDA Seeds:

  • Transfer Restriction Data: ["trd", mint.key()]
  • Group 0: ["trg", transfer_restriction_data.key(), 0u64.to_le_bytes()]

initialize_transfer_restriction_group(ctx: Context\<InitializeTransferRestrictionGroup\>, id: u64) -> Result<()>

Creates a new transfer group.

Parameters:

  • id (u64): Unique group identifier

Accounts:

  • transfer_restriction_group (Account<TransferRestrictionGroup>, init): New group PDA
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • payer (Signer, mut): Pays for account creation
  • system_program (Program): Solana System Program

Requirements:

  • Group ID must be unique
  • Authority must have Transfer Admin or Wallet Admin role

PDA Seeds: ["trg", transfer_restriction_data.key(), id.to_le_bytes()]


initialize_transfer_rule(ctx: Context\<InitializeTransferRule\>, transfer_group_id_from: u64, transfer_group_id_to: u64, lock_until: u64) -> Result<()>

Creates a transfer rule allowing transfers from one group to another.

Parameters:

  • transfer_group_id_from (u64): Source group ID
  • transfer_group_id_to (u64): Destination group ID
  • lock_until (u64): Unix timestamp when transfers are allowed (0 = never)

Accounts:

  • transfer_rule (Account<TransferRule>, init): New transfer rule PDA
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • transfer_restriction_group_from (Account<TransferRestrictionGroup>): Source group
  • transfer_restriction_group_to (Account<TransferRestrictionGroup>): Destination group
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin role
  • payer (Signer, mut): Pays for account creation
  • system_program (Program): Solana System Program

Requirements:

  • Both groups must exist
  • Authority must have Transfer Admin role
  • Rule must be unique for the (from, to) pair

PDA Seeds: ["tr", transfer_restriction_data.key(), from_group_id.to_le_bytes(), to_group_id.to_le_bytes()]

Special Values:

  • lock_until = 0: Transfers never allowed
  • lock_until = 1: Transfers allowed immediately
  • lock_until > 1: Transfers allowed after Unix timestamp

set_allow_transfer_rule(ctx: Context\<SetAllowTransferRule\>, transfer_group_id_from: u64, transfer_group_id_to: u64, locked_until: u64) -> Result<()>

Updates an existing transfer rule.

Parameters:

  • transfer_group_id_from (u64): Source group ID
  • transfer_group_id_to (u64): Destination group ID
  • locked_until (u64): New Unix timestamp

Accounts:

  • transfer_rule (Account<TransferRule>, mut): Existing transfer rule
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin role

Requirements:

  • Transfer rule must already exist
  • Authority must have Transfer Admin role

initialize_transfer_restriction_holder(ctx: Context\<InitializeTransferRestrictionHolder\>, id: u64) -> Result<()>

Creates a new holder account.

Parameters:

  • id (u64): Unique holder identifier

Accounts:

  • transfer_restriction_holder (Account<TransferRestrictionHolder>, init): New holder PDA
  • transfer_restriction_data (Account<TransferRestrictionData>, mut): Main restriction data
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • payer (Signer, mut): Pays for account creation
  • system_program (Program): Solana System Program

Requirements:

  • Holder ID must be unique
  • Authority must have Transfer Admin or Wallet Admin role
  • Increments current_holders_count in transfer restriction data

PDA Seeds: ["trh", transfer_restriction_data.key(), id.to_le_bytes()]


initialize_holder_group(ctx: Context\<InitializeHolderGroup\>) -> Result<()>

Links a holder to a transfer group.

Accounts:

  • holder_group (Account<HolderGroup>, init): New holder group PDA
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • group (Account<TransferRestrictionGroup>, mut): Transfer group
  • holder (Account<TransferRestrictionHolder>, mut): Holder account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • payer (Signer, mut): Pays for account creation
  • system_program (Program): Solana System Program

Requirements:

  • Holder group must not already exist for this (holder, group) pair
  • Increments current_holder_group_count in holder
  • Increments current_holders_count in group

PDA Seeds: ["trhg", transfer_restriction_data.key(), holder_id.to_le_bytes(), group_id.to_le_bytes()]


initialize_security_associated_account(ctx: Context\<InitializeSecurityAssociatedAccount\>, group_id: u64, holder_id: u64) -> Result<()>

Creates a security associated account linking a wallet to a group and holder.

Parameters:

  • group_id (u64): Transfer group ID for this wallet
  • holder_id (u64): Holder ID for this wallet

Accounts:

  • security_associated_account (Account<SecurityAssociatedAccount>, init): New SAA PDA
  • group (Account<TransferRestrictionGroup>, mut): Transfer group
  • holder (Account<TransferRestrictionHolder>, mut): Holder account
  • holder_group (Account<HolderGroup>, mut): Holder group account
  • security_token (InterfaceAccount<Mint>): Token-2022 mint
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • user_wallet (AccountInfo): Wallet owner
  • associated_token_account (InterfaceAccount<TokenAccount>): User's token account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • payer (Signer, mut): Pays for account creation
  • system_program (Program): Solana System Program

Requirements:

  • Holder and holder group must already exist
  • Associated token account must exist
  • Checks holder limits (global and per-group)
  • Increments current_wallets_count in holder and holder group

PDA Seeds: ["saa", associated_token_account.key()]

Errors:

  • MaxHoldersReached: Global holder limit would be exceeded
  • MaxHoldersReachedInsideTheGroup: Group holder limit would be exceeded

initialize_default_security_accounts(ctx: Context\<InitializeDefaultSecurityAccounts\>, holder_id: u64) -> Result<()>

Convenience function to create holder, holder group (Group 0), and SAA in one transaction.

Parameters:

  • holder_id (u64): Holder ID to create

Accounts:

Similar to combining initialize_transfer_restriction_holder, initialize_holder_group, and initialize_security_associated_account for Group 0.

Requirements:

  • Wallet must not already have an SAA
  • Group 0 must exist
  • Checks holder limits

update_wallet_group(ctx: Context\<UpdateWalletGroup\>, new_group_id: u64) -> Result<()>

Moves a wallet to a different transfer group.

Parameters:

  • new_group_id (u64): Target group ID

Accounts:

  • security_associated_account (Account<SecurityAssociatedAccount>, mut): Wallet's SAA
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • current_group (Account<TransferRestrictionGroup>, mut): Current group
  • current_holder_group (Account<HolderGroup>, mut): Current holder group
  • new_group (Account<TransferRestrictionGroup>, mut): New group
  • new_holder_group (Account<HolderGroup>): New holder group (must exist)
  • user_wallet (AccountInfo): Wallet owner
  • associated_token_account (InterfaceAccount<TokenAccount>): User's token account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • payer (Signer, mut): Pays for account creation if needed
  • system_program (Program): Solana System Program

Requirements:

  • Token account must have 0 balance
  • New holder group must already exist
  • New group ID must be different from current group
  • Checks holder limits for new group

Effects:

  • Decrements wallet count in old group and holder group
  • Increments wallet count in new group and holder group (if not already counted as holder)
  • Updates SAA group field

Errors:

  • BalanceIsTooLow: Token balance must be 0
  • NewGroupIsTheSameAsTheCurrentGroup: Same group specified
  • MaxHoldersReachedInsideTheGroup: New group limit would be exceeded

set_holder_max(ctx: Context\<SetHolderMax\>, holder_max: u64) -> Result<()>

Sets the global maximum number of unique holders.

Parameters:

  • holder_max (u64): New maximum holder count

Accounts:

  • transfer_restriction_data (Account<TransferRestrictionData>, mut): Main restriction data
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin role

Requirements:

  • Authority must have Transfer Admin role
  • New max must be ≥ current holder count

Errors:

  • NewHolderMaxMustExceedCurrentHolderCount: New max is too low

set_holder_group_max(ctx: Context\<SetHolderGroupMax\>, group_id: u64, holder_group_max: u64) -> Result<()>

Sets the maximum number of unique holders for a specific group.

Parameters:

  • group_id (u64): Group ID
  • holder_group_max (u64): New maximum holder count for group (0 = unlimited)

Accounts:

  • transfer_restriction_group (Account<TransferRestrictionGroup>, mut): Transfer group
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin role

Requirements:

  • Authority must have Transfer Admin role
  • Cannot set holder max for Group 0 (must remain unlimited)
  • New max must be ≥ current group holder count

Errors:

  • ZeroGroupHolderGroupMaxCannotBeNonZero: Cannot limit Group 0
  • NewHolderGroupMaxMustExceedCurrentHolderGroupCount: New max is too low

pause(ctx: Context\<Pause\>, paused: bool) -> Result<()>

Pauses or unpauses all transfers.

Parameters:

  • paused (bool): True to pause, false to unpause

Accounts:

  • transfer_restriction_data (Account<TransferRestrictionData>, mut): Main restriction data
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin role

Requirements:

  • Authority must have Transfer Admin role

Effects:

When paused, all transfers (except force transfers by Reserve Admin) will fail.


set_address_permission(ctx: Context\<SetAddressPermission\>, group_id: u64, frozen: bool) -> Result<()>

Sets the transfer group and frozen status for a wallet address.

Parameters:

  • group_id (u64): Transfer group ID to assign
  • frozen (bool): Whether to freeze the wallet

Accounts:

  • security_associated_account (Account<SecurityAssociatedAccount>, mut): SAA to update
  • transfer_restriction_group_new (Account<TransferRestrictionGroup>, mut): New group
  • transfer_restriction_group_current (Account<TransferRestrictionGroup>, mut): Current group
  • transfer_restriction_holder (Account<TransferRestrictionHolder>): Holder account
  • holder_group_new (Account<HolderGroup>, mut): New holder group
  • holder_group_current (Account<HolderGroup>, mut): Current holder group
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • user_associated_token_account (InterfaceAccount<TokenAccount>): User's token account
  • security_mint (InterfaceAccount<Mint>): Token-2022 mint
  • access_control_account (Account<AccessControl>): Access control account
  • access_control_program (Program): Access Control Program
  • token_program (Program): SPL Token-2022 Program
  • payer (Signer, mut): Pays for account creation if needed
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • system_program (Program): Solana System Program

Requirements:

  • Caller must have Transfer Admin or Wallet Admin role
  • Token balance must be 0 to change groups
  • New holder group must exist

Effects:

  • Updates group and frozen status atomically
  • Updates holder counts if group changed

Errors:

  • Unauthorized: Caller lacks required role
  • BalanceIsTooLow: Token balance must be 0

set_lockup_escrow_account(ctx: Context\<SetLockupEscrowAccount\>) -> Result<()>

Sets the lockup escrow account for tokenlock integration.

Accounts:

  • transfer_restriction_data (Account<TransferRestrictionData>, mut): Main restriction data
  • escrow_account (InterfaceAccount<TokenAccount>): Escrow token account
  • security_token (InterfaceAccount<Mint>): Token-2022 mint
  • access_control_account (Account<AccessControl>): Access control account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin role

Requirements:

  • Authority must have Transfer Admin role
  • Can only be set once

Effects:

Transfers involving the escrow account bypass transfer restrictions.


revoke_security_associated_account(ctx: Context\<RevokeSecurityAssociatedAccount\>) -> Result<()>

Revokes a security associated account and reclaims rent SOL.

Accounts:

  • security_associated_account (Account<SecurityAssociatedAccount>, mut): SAA to revoke
  • holder_group (Account<HolderGroup>, mut): Associated holder group
  • group (Account<TransferRestrictionGroup>, mut): Transfer group
  • holder (Account<TransferRestrictionHolder>, mut): Holder account
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • user_wallet (AccountInfo): Wallet owner
  • associated_token_account (InterfaceAccount<TokenAccount>): User's token account
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • destination (AccountInfo, mut): Receives reclaimed SOL

Requirements:

  • Token account must have 0 balance
  • Decrements wallet counts in holder and holder group

revoke_holder_group(ctx: Context\<RevokeHolderGroup\>) -> Result<()>

Revokes a holder group account and reclaims rent SOL.

Accounts:

  • holder_group (Account<HolderGroup>, mut): Holder group to revoke
  • holder (Account<TransferRestrictionHolder>, mut): Holder account
  • group (Account<TransferRestrictionGroup>, mut): Transfer group
  • transfer_restriction_data (Account<TransferRestrictionData>): Main restriction data
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • destination (AccountInfo, mut): Receives reclaimed SOL

Requirements:

  • Holder must have no wallets in this group (current_wallets_count == 0)
  • Decrements holder count in group and holder group count in holder

Errors:

  • NoWalletsInGroup: Holder still has wallets in this group

revoke_holder(ctx: Context\<RevokeHolder\>) -> Result<()>

Revokes a holder account and reclaims rent SOL.

Accounts:

  • transfer_restriction_holder (Account<TransferRestrictionHolder>, mut): Holder to revoke
  • transfer_restriction_data (Account<TransferRestrictionData>, mut): Main restriction data
  • authority_wallet_role (Account<WalletRole>): Authority's role account
  • authority (Signer): Must have Transfer Admin or Wallet Admin role
  • destination (AccountInfo, mut): Receives reclaimed SOL

Requirements:

  • Holder must have no holder groups (current_holder_group_count == 0)
  • Holder must have no wallets (current_wallets_count == 0)
  • Decrements global holder count

Errors:

  • CurrentHolderGroupCountMustBeZero: Holder still in groups
  • CurrentWalletsCountMustBeZero: Holder still has wallets

execute_transaction(ctx: Context\<ExecuteTransferHook\>, amount: u64) -> Result<()>

Transfer hook execution (called automatically by Token-2022 on every transfer).

Parameters:

  • amount (u64): Transfer amount (not used in validation)

Accounts:

  • source_account (InterfaceAccount<TokenAccount>): Source token account
  • mint (InterfaceAccount<Mint>): Token-2022 mint
  • destination_account (InterfaceAccount<TokenAccount>): Destination token account
  • owner (UncheckedAccount): Transfer authority
  • Additional accounts required by transfer hook interface

Validation Logic:

  1. Check if paused → reject if true
  2. Check if escrow account involved → allow if true
  3. Load source and destination SAAs
  4. Load transfer rule for (source_group → dest_group)
  5. Check if current_time ≥ locked_until
  6. Check if adding new holder would exceed limits

Errors:

  • AllTransfersPaused: System is paused
  • TransferRuleNotAllowedUntilLater: Lockup period not met
  • MaxHoldersReached: Global limit exceeded
  • MaxHoldersReachedInsideTheGroup: Group limit exceeded
  • TransferGroupNotApproved: No rule exists for group pair

initialize_extra_account_meta_list(ctx: Context\<InitializeExtraAccountMetaList\>) -> Result<()>

Initializes the extra account meta list for the transfer hook.

Accounts:

  • extra_account_meta_list (AccountInfo, init): Meta list PDA
  • mint (InterfaceAccount<Mint>): Token-2022 mint
  • authority (Signer): Mint authority
  • system_program (Program): Solana System Program

Requirements:

  • Must be called during token setup
  • Defines which accounts the transfer hook needs

PDA Seeds: ["extra-account-metas", mint.key()]


Account Structures

TransferRestrictionData

#[account]
pub struct TransferRestrictionData {
pub security_token_mint: Pubkey, // Associated token mint
pub access_control_account: Pubkey, // Access control account
pub current_holders_count: u64, // Current unique holder count
pub holder_ids: u64, // Next available holder ID
pub max_holders: u64, // Maximum unique holders
pub paused: bool, // Emergency pause flag
pub lockup_escrow_account: Option<Pubkey`, // Optional escrow account
}

PDA Seeds: ["trd", mint.key()]


TransferRestrictionGroup

#[account]
pub struct TransferRestrictionGroup {
pub id: u64, // Group identifier
pub current_holders_count: u64, // Unique holders in group
pub max_holders: u64, // Max holders (0 = unlimited)
pub transfer_restriction_data: Pubkey, // Parent restriction data
}

PDA Seeds: ["trg", transfer_restriction_data.key(), id.to_le_bytes()]


TransferRule

#[account]
pub struct TransferRule {
pub transfer_restriction_data: Pubkey, // Parent restriction data
pub transfer_group_id_from: u64, // Source group
pub transfer_group_id_to: u64, // Destination group
pub locked_until: u64, // Unix timestamp (0 = never)
}

PDA Seeds: ["tr", transfer_restriction_data.key(), from_id.to_le_bytes(), to_id.to_le_bytes()]


TransferRestrictionHolder

#[account]
pub struct TransferRestrictionHolder {
pub id: u64, // Holder identifier
pub current_wallets_count: u64, // Number of wallets
pub current_holder_group_count: u64, // Number of groups
pub transfer_restriction_data: Pubkey, // Parent restriction data
}

PDA Seeds: ["trh", transfer_restriction_data.key(), id.to_le_bytes()]


HolderGroup

#[account]
pub struct HolderGroup {
pub holder: Pubkey, // Holder account
pub group: u64, // Group ID
pub current_wallets_count: u64, // Wallets in this group
}

PDA Seeds: ["trhg", transfer_restriction_data.key(), holder_id.to_le_bytes(), group_id.to_le_bytes()]


SecurityAssociatedAccount

#[account]
pub struct SecurityAssociatedAccount {
pub group: u64, // Transfer group ID
pub holder: Option<Pubkey`, // Associated holder
}

PDA Seeds: ["saa", associated_token_account.key()]


Errors

TransferRestrictionsError

ErrorCodeDescription
Unauthorized6000Caller lacks required role
MaxHoldersReached6001Global holder limit reached
TransferRuleNotAllowedUntilLater6002Lockup period not met
InvalidRole6003Invalid role value
AllTransfersPaused6004System is paused
InvalidPDA6005Invalid program derived address
BalanceIsTooLow6006Token balance too low (must be 0 for some ops)
CurrentWalletsCountMustBeZero6007Holder still has wallets
MismatchedEscrowAccount6008Escrow account doesn't match
InvalidHolderIndex6009Invalid holder index
MaxHoldersReachedInsideTheGroup6010Group holder limit reached
TransferGroupNotApproved6011No transfer rule exists for group pair
IncorrectTokenlockAccount6012Wrong tokenlock account
TransferRuleAccountDataIsEmtpy6013Transfer rule account not initialized
SecurityAssociatedAccountDataIsEmtpy6014SAA account not initialized
TransferRestrictionsAccountDataIsEmtpy6015Transfer restrictions data not initialized
NoWalletsInGroup6016Holder has no wallets in group
NewGroupIsTheSameAsTheCurrentGroup6017Cannot update to same group
NewHolderMaxMustExceedCurrentHolderCount6018New max below current count
NewHolderGroupMaxMustExceedCurrentHolderGroupCount6019New group max below current count
ZeroGroupHolderGroupMaxCannotBeNonZero6020Cannot limit Group 0
NonPositiveHolderGroupCount6021Holder group count must be positive
CurrentHolderGroupCountMustBeZero6022Holder still in groups
ValueUnchanged6023New value same as current
CurrentGroupRequiredForExistingWallet6024Must provide current group
HolderGroupAlreadyInitialized6025Holder group already exists

Common Patterns

Initial Setup Flow

Wallet Provisioning Flow

Transfer Validation Flow


Integration Notes

With Access Control Program

  • Transfer Admin and Wallet Admin roles from Access Control determine permissions
  • Many instructions require authority_wallet_role account validation

With Tokenlock Program

  • Set lockup_escrow_account to bypass transfer restrictions for vesting
  • Escrow transfers skip all validation checks

With Token-2022

  • Transfer hook executes on every transfer
  • Extra account metas must be added to transfer instructions
  • Use addExtraAccountMetasForExecute helper in client code

Security Considerations

Transfer Hook Bypass

The lockup escrow account bypasses all transfer restrictions. Ensure:

  • Only set escrow for legitimate tokenlock program
  • Never set escrow to user-controlled accounts

Holder Limits

  • Group 0 (default) has unlimited holders by design
  • Holder limits are soft caps (can be increased later)
  • Carefully plan holder limits for Reg CF compliance

Emergency Pause

  • Pause affects all transfers (including internal protocol transfers)
  • Only use for critical security incidents
  • Have clear procedures for unpausing

Program ID

Mainnet/Devnet: 6yEnqdEjX3zBBDkzhwTRGJwv1jRaN4QE4gywmgdcfPBZ

IDL

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

Download: transfer_restrictions.json

Usage with Anchor:

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

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

Key Instructions in IDL:

  • initialize_transfer_restrictions_data - Initialize program
  • initialize_transfer_restriction_group - Create transfer group
  • initialize_transfer_rule - Create transfer rule
  • initialize_transfer_restriction_holder - Create holder
  • initialize_holder_group - Link holder to group
  • initialize_security_associated_account - Create wallet SAA
  • initialize_default_security_accounts - Quick setup for Group 0
  • update_wallet_group - Move wallet to different group
  • set_address_permission - Set group and frozen status atomically
  • set_allow_transfer_rule - Update transfer rule
  • set_holder_max - Set global holder limit
  • set_holder_group_max - Set per-group holder limit
  • pause - Emergency pause
  • set_lockup_escrow_account - Configure escrow bypass
  • revoke_security_associated_account - Remove wallet
  • revoke_holder_group - Remove holder from group
  • revoke_holder - Remove holder
  • execute_transaction - Transfer hook (automatic)
  • enforce_transfer_restrictions - Manual restriction check
  • initialize_extra_account_meta_list - Setup transfer hook