A production-ready Anchor program demonstrating hierarchical access control patterns, two-step ownership transfers, and secure permission management on Solana.
This program showcases enterprise-grade access control mechanisms without exposing business logic:
- Two-Step Ownership Transfers: Prevents accidental transfers to wrong addresses
- Hierarchical Permissions: Program admin and vault owner separation of concerns
- Granular Error Handling: Specific errors for precise debugging
- Event Monitoring: Timestamped events for all access control changes
- Lazy Initialization: Efficient PDA and ATA creation patterns
pub struct Config {
pub admin: Pubkey, // Current program administrator
pub new_admin: Pubkey, // Pending admin (two-step transfer)
pub bump: u8,
}- PDA Seeds:
[b"config"] - Purpose: Global program administration
- Access: Admin-only operations
pub struct Vault {
pub owner: Pubkey, // Current vault owner
pub pending_owner: Option<Pubkey>, // Pending owner (two-step transfer)
pub vault_token: Pubkey, // Associated token mint
pub vault_id: String, // Human-readable identifier (max 20 chars)
pub bump: u8,
}- PDA Seeds:
[b"vault", vault_id.as_bytes()] - Purpose: Project-level token vault with ownership controls
- Access: Owner-only withdrawals, anyone can deposit
┌─────────────────────────────────────────┐
│ Program Admin (Global) │
│ - Transfer admin role │
│ - Global configuration │
└─────────────────────────────────────────┘
│
│ Cannot access individual vaults
▼
┌─────────────────────────────────────────┐
│ Vault Owner (Per-Project) │
│ - Withdraw deposits │
│ - Transfer ownership │
│ - Renounce ownership │
└─────────────────────────────────────────┘
│
│ Controls only their vault
▼
┌─────────────────────────────────────────┐
│ Vault Token Account (PDA) │
│ - Stores deposited tokens │
│ - Authority: Vault PDA │
└─────────────────────────────────────────┘
Initialize the program with a program admin.
- Access: Anyone (one-time initialization)
- Effect: Creates Config PDA
- Event:
ProgramDeployed(admin, timestamp)
Create a new vault with the caller as owner.
- Access: Anyone
- Validations:
vault_id.len() <= 20 - Effect: Creates Vault PDA, caller becomes owner
- Event:
VaultInitialized(vault_id, owner, vault_token, timestamp)
Initiate ownership transfer (step 1 of 2).
- Access: Current vault owner only
- Effect: Sets
pending_ownerfield - Event:
OwnershipTransferred(vault_id, previous_owner, new_owner, timestamp)
Accept ownership transfer (step 2 of 2).
- Access: Pending owner only
- Validations: Caller must match
pending_owner - Effect: Updates
owner, clearspending_owner - Error:
InvalidPendingOwnerif caller doesn't match
Permanently renounce ownership (irreversible).
- Access: Current vault owner only
- Effect: Sets
ownertoPubkey::default() - Warning: Irreversible - vault becomes ownerless
- Event:
OwnershipRenounced(vault_id, previous_owner, timestamp)
Initiate program admin transfer (step 1 of 2).
- Access: Current program admin only
- Effect: Sets
new_adminfield - Event:
ProgramAdminTransferred(previous_admin, new_admin, timestamp)
Accept program admin transfer (step 2 of 2).
- Access: Pending admin only
- Validations: Caller must match
new_admin - Effect: Updates
admin, clearsnew_admin - Error:
InvalidPendingAdminif caller doesn't match
Deposit tokens into the vault.
- Access: Anyone
- Effect: Transfers tokens from caller to vault ATA
- Features: Lazy ATA initialization with
init_if_needed - Event:
TokensDeposited(vault_id, depositor, amount, token_mint, timestamp)
Withdraw tokens from the vault.
- Access: Vault owner only
- Validations: Sufficient balance check
- Effect: Transfers tokens from vault ATA to caller
- Error:
InsufficientBalanceif amount exceeds vault balance - Event:
TokensWithdrawn(vault_id, recipient, amount, token_mint, timestamp)
pub enum AccessControlError {
NotOwner, // Operation requires vault ownership
NotProgramAdmin, // Operation requires program admin
InvalidPendingOwner, // Caller is not the pending owner
InvalidPendingAdmin, // Caller is not the pending admin
VaultIdTooLong, // Vault ID exceeds 20 characters
InsufficientBalance, // Vault balance too low for withdrawal
}seeds = [b"config"]
// One per program, stores global adminseeds = [b"vault", vault_id.as_bytes()]
// One per project, isolated by vault_id// Standard Associated Token Account
mint = vault.vault_token
authority = vault (PDA)let vault_id_bytes = vault_id.as_bytes();
let bump = &[vault.bump];
let signer_seeds: &[&[&[u8]]] = &[&[
b"vault",
vault_id_bytes,
bump
]];Prevents accidental or malicious transfers:
Owner A Owner B (pending)
│ │
├──► transfer_ownership() │
│ (sets pending_owner=B) │
│ │
│ ├──► receive_ownership()
│ │ (validates caller=B, updates owner)
│ │
└──────────────────────────────┴───► Ownership transferred safely
All sensitive operations use explicit require! statements:
require!(
ctx.accounts.owner.key() == vault.owner,
AccessControlError::NotOwner
);Vault PDA is the authority for token accounts:
- User keys never hold authority
- Program controls transfers via PDA signing
- Prevents unauthorized token movements
ATAs created only when first needed:
#[account(
init_if_needed, // Creates account only if missing
payer = authority,
associated_token::mint = token_mint,
associated_token::authority = vault,
)]Program Admin ──(can't access)──> Individual Vaults
│
└───> Global Configuration Only
All events include timestamp: i64 for audit trails:
ProgramDeployed { admin, timestamp }
VaultInitialized { vault_id, owner, vault_token, timestamp }
OwnershipTransferred { vault_id, previous_owner, new_owner, timestamp }
OwnershipRenounced { vault_id, previous_owner, timestamp }
ProgramAdminTransferred { previous_admin, new_admin, timestamp }
TokensDeposited { vault_id, depositor, amount, token_mint, timestamp }
TokensWithdrawn { vault_id, recipient, amount, token_mint, timestamp }This pattern is suitable for:
- Each customer gets isolated vault
- Platform admin can't access customer vaults
- Customers fully control their assets
- DAO tools managing multiple organization vaults
- Each DAO has independent ownership
- Safe ownership transfers between signers
- Project-based token escrows
- Time-based or condition-based releases
- Secure ownership handoffs
- Isolated allocation per project
- Withdrawal controls by project owner
- Audit trail via events
- Two-Step Transfers: Prevents loss of control due to typos or malicious actors
- Separation of Concerns: Admin and owner roles are distinct with clear boundaries
- Event-Driven: All access control changes emit events for monitoring
- Granular Errors: Specific errors aid debugging and user experience
- PDA-Based Authority: Secure token control without exposing private keys
- Lazy Initialization: Gas-efficient account creation patterns
anchor buildanchor testMIT