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 ID | Name | Purpose |
|---|---|---|
| 0 | Default Group | Catch-all for non-categorized |
| 1 | Issuer Token Reserves | Company treasury |
| 2 | Regulated Exchange | Listed exchange custody accounts |
| 3 | Reg S Non-US Approved | Foreign investors |
| 5 | Founders (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:
- Pause Check: Is the system paused?
- Escrow Bypass: Is this transfer involving the lockup escrow account?
- Group Validation: Do source and destination SAAs exist?
- Transfer Rule: Does a rule exist for (source_group → dest_group)?
- Time Lock: Has
locked_untiltimestamp passed? - 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:
- A holder account
- A holder group account (for each group the holder participates in)
- 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:
- Revoke all Security Associated Accounts for a holder
- Revoke all Holder Group Accounts for a holder
- 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 == 0current_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 PDAzero_transfer_restriction_group(Account<TransferRestrictionGroup\>, init): Default group (ID: 0)mint(InterfaceAccount<Mint\>): Token-2022 mintaccess_control_account(Account<AccessControl\>): Access control accountauthority_wallet_role(Account<WalletRole\>): Authority's role accountpayer(Signer, mut): Pays for account creationauthority(Signer): Must have Transfer Admin or Wallet Admin rolesystem_program(Program): Solana System Programtoken_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 PDAtransfer_restriction_data(Account<TransferRestrictionData>): Main restriction dataaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin rolepayer(Signer, mut): Pays for account creationsystem_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 IDtransfer_group_id_to(u64): Destination group IDlock_until(u64): Unix timestamp when transfers are allowed (0 = never)
Accounts:
transfer_rule(Account<TransferRule>, init): New transfer rule PDAtransfer_restriction_data(Account<TransferRestrictionData>): Main restriction datatransfer_restriction_group_from(Account<TransferRestrictionGroup>): Source grouptransfer_restriction_group_to(Account<TransferRestrictionGroup>): Destination groupaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin rolepayer(Signer, mut): Pays for account creationsystem_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 allowedlock_until = 1: Transfers allowed immediatelylock_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 IDtransfer_group_id_to(u64): Destination group IDlocked_until(u64): New Unix timestamp
Accounts:
transfer_rule(Account<TransferRule>, mut): Existing transfer ruletransfer_restriction_data(Account<TransferRestrictionData>): Main restriction dataaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(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 PDAtransfer_restriction_data(Account<TransferRestrictionData>, mut): Main restriction dataaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin rolepayer(Signer, mut): Pays for account creationsystem_program(Program): Solana System Program
Requirements:
- Holder ID must be unique
- Authority must have Transfer Admin or Wallet Admin role
- Increments
current_holders_countin 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 PDAtransfer_restriction_data(Account<TransferRestrictionData>): Main restriction datagroup(Account<TransferRestrictionGroup>, mut): Transfer groupholder(Account<TransferRestrictionHolder>, mut): Holder accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin rolepayer(Signer, mut): Pays for account creationsystem_program(Program): Solana System Program
Requirements:
- Holder group must not already exist for this (holder, group) pair
- Increments
current_holder_group_countin holder - Increments
current_holders_countin 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 walletholder_id(u64): Holder ID for this wallet
Accounts:
security_associated_account(Account<SecurityAssociatedAccount>, init): New SAA PDAgroup(Account<TransferRestrictionGroup>, mut): Transfer groupholder(Account<TransferRestrictionHolder>, mut): Holder accountholder_group(Account<HolderGroup>, mut): Holder group accountsecurity_token(InterfaceAccount<Mint>): Token-2022 minttransfer_restriction_data(Account<TransferRestrictionData>): Main restriction datauser_wallet(AccountInfo): Wallet ownerassociated_token_account(InterfaceAccount<TokenAccount>): User's token accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin rolepayer(Signer, mut): Pays for account creationsystem_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_countin holder and holder group
PDA Seeds: ["saa", associated_token_account.key()]
Errors:
MaxHoldersReached: Global holder limit would be exceededMaxHoldersReachedInsideTheGroup: 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 SAAtransfer_restriction_data(Account<TransferRestrictionData>): Main restriction datacurrent_group(Account<TransferRestrictionGroup>, mut): Current groupcurrent_holder_group(Account<HolderGroup>, mut): Current holder groupnew_group(Account<TransferRestrictionGroup>, mut): New groupnew_holder_group(Account<HolderGroup>): New holder group (must exist)user_wallet(AccountInfo): Wallet ownerassociated_token_account(InterfaceAccount<TokenAccount>): User's token accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin rolepayer(Signer, mut): Pays for account creation if neededsystem_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 0NewGroupIsTheSameAsTheCurrentGroup: Same group specifiedMaxHoldersReachedInsideTheGroup: 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 dataaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(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 IDholder_group_max(u64): New maximum holder count for group (0 = unlimited)
Accounts:
transfer_restriction_group(Account<TransferRestrictionGroup>, mut): Transfer grouptransfer_restriction_data(Account<TransferRestrictionData>): Main restriction dataaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(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 0NewHolderGroupMaxMustExceedCurrentHolderGroupCount: 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 dataaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(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 assignfrozen(bool): Whether to freeze the wallet
Accounts:
security_associated_account(Account<SecurityAssociatedAccount>, mut): SAA to updatetransfer_restriction_group_new(Account<TransferRestrictionGroup>, mut): New grouptransfer_restriction_group_current(Account<TransferRestrictionGroup>, mut): Current grouptransfer_restriction_holder(Account<TransferRestrictionHolder>): Holder accountholder_group_new(Account<HolderGroup>, mut): New holder groupholder_group_current(Account<HolderGroup>, mut): Current holder grouptransfer_restriction_data(Account<TransferRestrictionData>): Main restriction datauser_associated_token_account(InterfaceAccount<TokenAccount>): User's token accountsecurity_mint(InterfaceAccount<Mint>): Token-2022 mintaccess_control_account(Account<AccessControl>): Access control accountaccess_control_program(Program): Access Control Programtoken_program(Program): SPL Token-2022 Programpayer(Signer, mut): Pays for account creation if neededauthority(Signer): Must have Transfer Admin or Wallet Admin rolesystem_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 roleBalanceIsTooLow: 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 dataescrow_account(InterfaceAccount<TokenAccount>): Escrow token accountsecurity_token(InterfaceAccount<Mint>): Token-2022 mintaccess_control_account(Account<AccessControl>): Access control accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(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 revokeholder_group(Account<HolderGroup>, mut): Associated holder groupgroup(Account<TransferRestrictionGroup>, mut): Transfer groupholder(Account<TransferRestrictionHolder>, mut): Holder accounttransfer_restriction_data(Account<TransferRestrictionData>): Main restriction datauser_wallet(AccountInfo): Wallet ownerassociated_token_account(InterfaceAccount<TokenAccount>): User's token accountauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin roledestination(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 revokeholder(Account<TransferRestrictionHolder>, mut): Holder accountgroup(Account<TransferRestrictionGroup>, mut): Transfer grouptransfer_restriction_data(Account<TransferRestrictionData>): Main restriction dataauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin roledestination(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 revoketransfer_restriction_data(Account<TransferRestrictionData>, mut): Main restriction dataauthority_wallet_role(Account<WalletRole>): Authority's role accountauthority(Signer): Must have Transfer Admin or Wallet Admin roledestination(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 groupsCurrentWalletsCountMustBeZero: 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 accountmint(InterfaceAccount<Mint>): Token-2022 mintdestination_account(InterfaceAccount<TokenAccount>): Destination token accountowner(UncheckedAccount): Transfer authority- Additional accounts required by transfer hook interface
Validation Logic:
- Check if paused → reject if true
- Check if escrow account involved → allow if true
- Load source and destination SAAs
- Load transfer rule for (source_group → dest_group)
- Check if
current_time ≥ locked_until - Check if adding new holder would exceed limits
Errors:
AllTransfersPaused: System is pausedTransferRuleNotAllowedUntilLater: Lockup period not metMaxHoldersReached: Global limit exceededMaxHoldersReachedInsideTheGroup: Group limit exceededTransferGroupNotApproved: 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 PDAmint(InterfaceAccount<Mint>): Token-2022 mintauthority(Signer): Mint authoritysystem_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
| Error | Code | Description |
|---|---|---|
Unauthorized | 6000 | Caller lacks required role |
MaxHoldersReached | 6001 | Global holder limit reached |
TransferRuleNotAllowedUntilLater | 6002 | Lockup period not met |
InvalidRole | 6003 | Invalid role value |
AllTransfersPaused | 6004 | System is paused |
InvalidPDA | 6005 | Invalid program derived address |
BalanceIsTooLow | 6006 | Token balance too low (must be 0 for some ops) |
CurrentWalletsCountMustBeZero | 6007 | Holder still has wallets |
MismatchedEscrowAccount | 6008 | Escrow account doesn't match |
InvalidHolderIndex | 6009 | Invalid holder index |
MaxHoldersReachedInsideTheGroup | 6010 | Group holder limit reached |
TransferGroupNotApproved | 6011 | No transfer rule exists for group pair |
IncorrectTokenlockAccount | 6012 | Wrong tokenlock account |
TransferRuleAccountDataIsEmtpy | 6013 | Transfer rule account not initialized |
SecurityAssociatedAccountDataIsEmtpy | 6014 | SAA account not initialized |
TransferRestrictionsAccountDataIsEmtpy | 6015 | Transfer restrictions data not initialized |
NoWalletsInGroup | 6016 | Holder has no wallets in group |
NewGroupIsTheSameAsTheCurrentGroup | 6017 | Cannot update to same group |
NewHolderMaxMustExceedCurrentHolderCount | 6018 | New max below current count |
NewHolderGroupMaxMustExceedCurrentHolderGroupCount | 6019 | New group max below current count |
ZeroGroupHolderGroupMaxCannotBeNonZero | 6020 | Cannot limit Group 0 |
NonPositiveHolderGroupCount | 6021 | Holder group count must be positive |
CurrentHolderGroupCountMustBeZero | 6022 | Holder still in groups |
ValueUnchanged | 6023 | New value same as current |
CurrentGroupRequiredForExistingWallet | 6024 | Must provide current group |
HolderGroupAlreadyInitialized | 6025 | Holder 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_roleaccount validation
With Tokenlock Program
- Set
lockup_escrow_accountto 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
addExtraAccountMetasForExecutehelper 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 programinitialize_transfer_restriction_group- Create transfer groupinitialize_transfer_rule- Create transfer ruleinitialize_transfer_restriction_holder- Create holderinitialize_holder_group- Link holder to groupinitialize_security_associated_account- Create wallet SAAinitialize_default_security_accounts- Quick setup for Group 0update_wallet_group- Move wallet to different groupset_address_permission- Set group and frozen status atomicallyset_allow_transfer_rule- Update transfer ruleset_holder_max- Set global holder limitset_holder_group_max- Set per-group holder limitpause- Emergency pauseset_lockup_escrow_account- Configure escrow bypassrevoke_security_associated_account- Remove walletrevoke_holder_group- Remove holder from grouprevoke_holder- Remove holderexecute_transaction- Transfer hook (automatic)enforce_transfer_restrictions- Manual restriction checkinitialize_extra_account_meta_list- Setup transfer hook