diff --git a/examples/oft-solana/Anchor.toml b/examples/oft-solana/Anchor.toml index dfd19e5a98..18734556d4 100644 --- a/examples/oft-solana/Anchor.toml +++ b/examples/oft-solana/Anchor.toml @@ -8,6 +8,7 @@ skip-lint = false [programs.localnet] oft = "G2BYTnfGCMQAErMZkTBCFSapKevzf6QCjizjXi8hFEtJ" +transfer_hook = "Hook111111111111111111111111111111111111111" [registry] url = "https://api.apr.dev" diff --git a/examples/oft-solana/Cargo.lock b/examples/oft-solana/Cargo.lock index 84f26dc083..5879e14040 100644 --- a/examples/oft-solana/Cargo.lock +++ b/examples/oft-solana/Cargo.lock @@ -2743,6 +2743,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "transfer-hook" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "spl-tlv-account-resolution", + "spl-transfer-hook-interface", +] + [[package]] name = "typenum" version = "1.18.0" diff --git a/examples/oft-solana/README.md b/examples/oft-solana/README.md index b773a558cd..4178e3826f 100644 --- a/examples/oft-solana/README.md +++ b/examples/oft-solana/README.md @@ -352,6 +352,37 @@ Before deploying, ensure the following: - (recommended) you have profiled the gas usage of `lzReceive` on your destination chains +## Transfer Hook Example + +This example also includes a **Token-2022 Transfer Hook** program that demonstrates how to add custom transfer validation logic to your OFT. + +### What is a Transfer Hook? + +Token-2022's [Transfer Hook extension](https://spl.solana.com/token-2022/extensions#transfer-hook) allows you to execute custom validation logic on every token transfer. Use cases include: + +- **Compliance**: Enforce allowlist/blocklist for regulated tokens +- **Transfer restrictions**: Time-locks, vesting schedules, daily limits +- **Royalties**: Ensure royalty payments on NFT transfers + +### Getting Started with Transfer Hook + +The Transfer Hook program is located in [`programs/transfer-hook/`](./programs/transfer-hook/). See the [Transfer Hook README](./programs/transfer-hook/README.md) for: + +- Architecture overview and how it integrates with Token-2022 +- Step-by-step usage instructions +- Example code for extending the hook with your own logic +- Integration patterns with OFT + +### Building and Testing the Transfer Hook + +```bash +# Build +anchor build -p transfer-hook + +# Test +pnpm run test:anchor +``` + ## Appendix ### Running tests diff --git a/examples/oft-solana/programs/transfer-hook/Cargo.toml b/examples/oft-solana/programs/transfer-hook/Cargo.toml new file mode 100644 index 0000000000..08dba2bdb6 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "transfer-hook" +version = "0.1.0" +description = "Token-2022 Transfer Hook example for compliance validation" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "transfer_hook" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + +[dependencies] +anchor-lang = { version = "0.31.1", features = ["init-if-needed"] } +anchor-spl = "0.31.1" +spl-transfer-hook-interface = "0.9" +spl-tlv-account-resolution = "0.9" + +[lints.rust] +# Suppress warnings from Anchor's internal cfg checks +unexpected_cfgs = { level = "allow", check-cfg = [ + "cfg(feature, values(\"custom-heap\", \"custom-panic\", \"anchor-debug\"))" +] } diff --git a/examples/oft-solana/programs/transfer-hook/README.md b/examples/oft-solana/programs/transfer-hook/README.md new file mode 100644 index 0000000000..ae48636985 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/README.md @@ -0,0 +1,243 @@ +# Token-2022 Transfer Hook Example + +This program demonstrates how to implement a **Transfer Hook** for Token-2022 tokens on Solana. Transfer Hooks allow you to execute custom validation logic on every token transfer. + +## What is a Transfer Hook? + +Token-2022 (SPL Token Extensions) introduced the [Transfer Hook extension](https://spl.solana.com/token-2022/extensions#transfer-hook), which allows token creators to specify a program that gets invoked on every transfer. This enables powerful use cases: + +| Use Case | Description | +|----------|-------------| +| **Compliance** | Enforce allowlist/blocklist for regulated tokens (securities, stablecoins) | +| **Royalties** | Ensure royalty payments are included in NFT transfers | +| **Transfer Restrictions** | Time-locks, vesting schedules, daily limits | +| **Analytics** | Track transfer volumes, collect fees, log events | +| **Cross-chain** | Custom logic for bridged/wrapped tokens | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User: transfer_checked() │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Token-2022 Program │ +│ 1. Validate balances, decimals, signatures │ +│ 2. Check for Transfer Hook extension │ +│ 3. CPI to Transfer Hook program ───────────────────────────┐ │ +└─────────────────────────────────────────────────────────────│───┘ + │ + ┌──────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Transfer Hook Program (this) │ +│ 1. Receive transfer context (source, dest, amount, authority) │ +│ 2. Execute custom validation logic │ +│ 3. Return Ok(()) to allow, or Err to reject │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Transfer Complete │ + │ (or Reverted) │ + └─────────────────────┘ +``` + +## Key Components + +### 1. ExtraAccountMetaList PDA + +Before transfers can work, you must initialize an `ExtraAccountMetaList` PDA. This account declares which additional accounts your hook needs beyond the standard transfer accounts. + +``` +Seeds: ["extra-account-metas", mint.key()] +``` + +### 2. The `fallback` Instruction + +Token-2022 uses the SPL instruction format, not Anchor's. The `fallback` handler bridges this gap: + +```rust +pub fn fallback<'info>( + program_id: &Pubkey, + accounts: &'info [AccountInfo<'info>], + data: &[u8], +) -> Result<()> { + let instruction = TransferHookInstruction::unpack(data)?; + match instruction { + TransferHookInstruction::Execute { amount } => { + // Route to our transfer_hook handler + } + _ => Err(ProgramError::InvalidInstructionData.into()), + } +} +``` + +### 3. The `transfer_hook` Instruction + +This is where your custom logic lives: + +```rust +pub fn transfer_hook(ctx: Context, amount: u64) -> Result<()> { + // Your validation logic here + // Return Ok(()) to allow, Err to reject + require!(amount > 100, HookError::AmountTooSmall); + Ok(()) +} +``` + +## Usage + +### 1. Build the Program + +```bash +anchor build -p transfer-hook +``` + +### 2. Create a Mint with Transfer Hook Extension + +```typescript +import { + createInitializeMintInstruction, + createInitializeTransferHookInstruction, + getMintLen, + ExtensionType, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; + +const extensions = [ExtensionType.TransferHook]; +const mintLen = getMintLen(extensions); + +const transaction = new Transaction().add( + // Create the mint account + SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: mint, + space: mintLen, + lamports: await connection.getMinimumBalanceForRentExemption(mintLen), + programId: TOKEN_2022_PROGRAM_ID, + }), + // Initialize the Transfer Hook extension + createInitializeTransferHookInstruction( + mint, + authority, + TRANSFER_HOOK_PROGRAM_ID, // Your hook program + TOKEN_2022_PROGRAM_ID + ), + // Initialize the mint + createInitializeMintInstruction( + mint, + decimals, + mintAuthority, + freezeAuthority, + TOKEN_2022_PROGRAM_ID + ) +); +``` + +### 3. Initialize the ExtraAccountMetaList + +```typescript +await program.methods + .initializeExtraAccountMetaList() + .accounts({ + payer: payer.publicKey, + mint: mint, + }) + .rpc(); +``` + +### 4. Transfers Now Go Through Your Hook + +```typescript +import { createTransferCheckedWithTransferHookInstruction } from "@solana/spl-token"; + +const transferIx = await createTransferCheckedWithTransferHookInstruction( + connection, + source, + mint, + destination, + authority, + amount, + decimals, + [], + "confirmed", + TOKEN_2022_PROGRAM_ID +); +``` + +## Extending This Example + +### Adding Config State + +For a compliance hook, you might add a config PDA: + +```rust +#[account] +pub struct TokenConfig { + pub authority: Pubkey, // Admin who can update config + pub paused: bool, // Global pause switch + pub allowlist_mode: u8, // 0=Open, 1=Blacklist, 2=Whitelist +} +``` + +### Adding Allowlist PDAs + +```rust +#[account] +pub struct AllowlistEntry { + pub bump: u8, // Marker PDA - existence determines status +} +``` + +### Updating ExtraAccountMetaList + +When you add custom accounts, update `Initialize::apply()`: + +```rust +let extra_account_metas: Vec = vec![ + // Add your TokenConfig PDA + ExtraAccountMeta::new_with_seeds( + &[Seed::Literal { bytes: b"config".to_vec() }], + false, // is_signer + false, // is_writable + )?, +]; +``` + +## Integration with OFT + +This Transfer Hook can be used with LayerZero OFT tokens. When configured: + +1. **Inbound transfers** (from OFT program) can be whitelisted to skip checks +2. **Outbound/P2P transfers** go through full validation +3. **Compliance** is enforced at the token level, not the OFT level + +See the main [oft-solana README](../../README.md) for OFT integration details. + +## Testing + +The tests use Jest and require a local validator. From the workspace root: + +```bash +# Run all Anchor tests (starts local validator automatically) +pnpm run test:anchor + +# Or run just the transfer-hook tests directly +npx jest test/anchor/transfer-hook.test.ts +``` + +Note: The tests require the program to be built first (`anchor build -p transfer-hook`). + +## References + +- [Solana Transfer Hook Guide](https://solana.com/developers/guides/token-extensions/transfer-hook) +- [Token-2022 Documentation](https://spl.solana.com/token-2022) +- [ExtraAccountMetaList Docs](https://docs.rs/spl-tlv-account-resolution) +- [SPL Transfer Hook Interface](https://docs.rs/spl-transfer-hook-interface) + +## License + +Apache-2.0 diff --git a/examples/oft-solana/programs/transfer-hook/Xargo.toml b/examples/oft-solana/programs/transfer-hook/Xargo.toml new file mode 100644 index 0000000000..475fb71ed1 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/examples/oft-solana/programs/transfer-hook/src/errors.rs b/examples/oft-solana/programs/transfer-hook/src/errors.rs new file mode 100644 index 0000000000..6c102e74a6 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/src/errors.rs @@ -0,0 +1,38 @@ +//! Transfer Hook error codes +//! +//! Define custom errors that can be returned from the transfer hook. +//! These will cause the Token-2022 transfer to revert. + +use anchor_lang::prelude::error_code; + +#[error_code] +pub enum HookError { + /// Transfer amount must meet the minimum threshold. + /// This is a placeholder error for the example; replace with your logic. + #[msg("Transfer amount must be at least 100")] + AmountTooSmall, + + // ========================================================================= + // Example errors for a compliance-focused Transfer Hook: + // ========================================================================= + + // /// Global pause is active - all transfers blocked + // #[msg("Transfers are paused")] + // Paused, + + // /// Source address is on the blocklist + // #[msg("Source address is blocked")] + // SourceBlocked, + + // /// Destination address is on the blocklist + // #[msg("Destination address is blocked")] + // DestinationBlocked, + + // /// Delegate (spender) is on the blocklist + // #[msg("Delegate is blocked")] + // DelegateBlocked, + + // /// Address not on the allowlist (for whitelist mode) + // #[msg("Address not on allowlist")] + // NotOnAllowlist, +} diff --git a/examples/oft-solana/programs/transfer-hook/src/instructions/initialize.rs b/examples/oft-solana/programs/transfer-hook/src/instructions/initialize.rs new file mode 100644 index 0000000000..e0181fc919 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/src/instructions/initialize.rs @@ -0,0 +1,107 @@ +//! Initialize ExtraAccountMetaList instruction +//! +//! This instruction creates the ExtraAccountMetaList PDA, which tells Token-2022 +//! which additional accounts to include when invoking the transfer hook. + +use anchor_lang::{ + prelude::*, + system_program::{create_account, CreateAccount}, +}; +use anchor_spl::token_interface::Mint; +use spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList}; +use spl_transfer_hook_interface::instruction::ExecuteInstruction; + +use crate::EXTRA_ACCOUNT_METAS_SEED; + +/// Accounts for initializing the ExtraAccountMetaList PDA. +/// +/// This must be called once per mint after creating a Token-2022 mint with +/// the Transfer Hook extension pointing to this program. +#[derive(Accounts)] +pub struct Initialize<'info> { + /// The account paying for PDA creation (rent). + #[account(mut)] + pub payer: Signer<'info>, + + /// The Token-2022 mint with Transfer Hook extension. + /// + /// This must already exist and have its Transfer Hook extension + /// configured to point to this program. + #[account(mint::token_program = anchor_spl::token_2022::ID)] + pub mint: InterfaceAccount<'info, Mint>, + + /// The ExtraAccountMetaList PDA to be created. + /// + /// Seeds: `["extra-account-metas", mint.key()]` + /// + /// This account stores a list of `ExtraAccountMeta` entries that tell + /// Token-2022 which additional accounts to include when invoking the hook. + #[account( + mut, + seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()], + bump, + )] + /// CHECK: Created in this instruction + pub extra_account_meta_list: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +impl Initialize<'_> { + /// Create and initialize the ExtraAccountMetaList PDA. + /// + /// The ExtraAccountMetaList declares additional accounts that Token-2022 + /// should include when invoking the transfer hook. These can be: + /// - Static accounts (fixed pubkeys) + /// - PDAs derived from known seeds + /// - Dynamic accounts resolved from other account data + /// + /// For this example, we don't need any extra accounts beyond the standard + /// transfer hook accounts (source, mint, destination, authority). + pub fn apply(ctx: &mut Context) -> Result<()> { + // Define the extra accounts your hook needs. + // This example doesn't need any, but you could add: + // + // - Config PDA: ExtraAccountMeta::new_with_seeds(...) + // - Allowlist entries: ExtraAccountMeta::new_external_pda_with_seeds(...) + // + // See spl_tlv_account_resolution docs for all options. + let extra_account_metas: Vec = vec![]; + + // Calculate space and rent for the account + let account_size = ExtraAccountMetaList::size_of(extra_account_metas.len())?; + let lamports = Rent::get()?.minimum_balance(account_size); + + let mint_key = ctx.accounts.mint.key(); + let signer_seeds: &[&[&[u8]]] = &[&[ + EXTRA_ACCOUNT_METAS_SEED, + mint_key.as_ref(), + &[ctx.bumps.extra_account_meta_list], + ]]; + + // Create the ExtraAccountMetaList account as a PDA + create_account( + CpiContext::new_with_signer( + ctx.accounts.system_program.to_account_info(), + CreateAccount { + from: ctx.accounts.payer.to_account_info(), + to: ctx.accounts.extra_account_meta_list.to_account_info(), + }, + signer_seeds, + ), + lamports, + account_size as u64, + ctx.program_id, + )?; + + // Initialize the account data with our extra account list + let mut data = ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?; + ExtraAccountMetaList::init::(&mut data, &extra_account_metas)?; + + msg!( + "Transfer Hook: ExtraAccountMetaList initialized for mint {}", + mint_key + ); + Ok(()) + } +} diff --git a/examples/oft-solana/programs/transfer-hook/src/instructions/mod.rs b/examples/oft-solana/programs/transfer-hook/src/instructions/mod.rs new file mode 100644 index 0000000000..1644376a18 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/src/instructions/mod.rs @@ -0,0 +1,7 @@ +//! Instruction handlers for the Transfer Hook program + +pub mod initialize; +pub mod transfer_hook; + +pub use initialize::*; +pub use transfer_hook::*; diff --git a/examples/oft-solana/programs/transfer-hook/src/instructions/transfer_hook.rs b/examples/oft-solana/programs/transfer-hook/src/instructions/transfer_hook.rs new file mode 100644 index 0000000000..699209a71f --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/src/instructions/transfer_hook.rs @@ -0,0 +1,138 @@ +//! Transfer Hook execute instruction +//! +//! This is where your custom transfer validation logic lives. +//! Token-2022 invokes this on every `transfer_checked` call. + +use anchor_lang::prelude::*; + +use crate::errors::HookError; +use crate::EXTRA_ACCOUNT_METAS_SEED; + +/// Accounts for the Transfer Hook execute instruction. +/// +/// These are the standard accounts passed by Token-2022 when invoking +/// a transfer hook. The order and types are defined by the SPL interface. +/// +/// Note: All accounts are `UncheckedAccount` because Token-2022 has already +/// validated them. We use seeds constraints for the PDAs we own. +#[derive(Accounts)] +pub struct TransferHookExecute<'info> { + /// The source token account (tokens are being transferred FROM here). + /// CHECK: Validated by Token-2022 Program + pub source: UncheckedAccount<'info>, + + /// The token mint. + /// CHECK: Validated by Token-2022 Program + pub mint: UncheckedAccount<'info>, + + /// The destination token account (tokens are being transferred TO here). + /// CHECK: Validated by Token-2022 Program + pub destination: UncheckedAccount<'info>, + + /// The authority signing the transfer. + /// + /// This is either: + /// - The source token account owner (direct transfer) + /// - A delegate with approval (delegated transfer) + /// CHECK: Validated by Token-2022 Program + pub authority: UncheckedAccount<'info>, + + /// This program's ExtraAccountMetaList PDA. + /// + /// Token-2022 verifies this PDA exists and matches expectations. + /// CHECK: Validated by seeds + #[account( + seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()], + bump + )] + pub extra_account_meta_list: UncheckedAccount<'info>, + + // ========================================================================= + // Add your custom accounts here! + // ========================================================================= + // + // For a compliance hook, you might add: + // + // /// Token configuration (pause state, allowlist mode) + // pub token_config: Account<'info, TokenConfig>, + // + // /// Source owner's allowlist entry (optional - may not exist) + // pub source_allowlist: Option>, + // + // /// Destination owner's allowlist entry (optional) + // pub dest_allowlist: Option>, + // + // Remember to add these to ExtraAccountMetaList in initialize.rs! +} + +impl TransferHookExecute<'_> { + /// Validate the transfer and return Ok(()) to allow or Err to reject. + /// + /// This is the core of your transfer hook - add your custom logic here. + /// + /// # Arguments + /// - `ctx`: The instruction context with all accounts + /// - `amount`: Raw transfer amount (not decimal-adjusted) + /// + /// # Returns + /// - `Ok(())`: Transfer is allowed + /// - `Err(...)`: Transfer is rejected, entire transaction reverts + /// + /// # Example Use Cases + /// + /// **Compliance/Allowlist:** + /// ```ignore + /// // Check if source owner is blocked + /// if is_blocked(source_owner) { + /// return Err(HookError::SourceBlocked.into()); + /// } + /// ``` + /// + /// **Royalty Enforcement:** + /// ```ignore + /// // Verify royalty payment exists in the transaction + /// if !royalty_paid() { + /// return Err(HookError::RoyaltyNotPaid.into()); + /// } + /// ``` + /// + /// **Transfer Limits:** + /// ```ignore + /// // Check daily transfer limit + /// if daily_transfers + amount > DAILY_LIMIT { + /// return Err(HookError::DailyLimitExceeded.into()); + /// } + /// ``` + pub fn apply(_ctx: &Context, amount: u64) -> Result<()> { + msg!("Transfer Hook: validating transfer of {} tokens", amount); + + // ===================================================================== + // YOUR VALIDATION LOGIC HERE + // ===================================================================== + // + // This example uses a simple threshold check. + // Replace with your actual business logic. + // + // Common patterns: + // + // 1. Allowlist/Blocklist check: + // let source_owner = get_token_account_owner(&ctx.accounts.source); + // require!(!is_blocked(source_owner), HookError::SourceBlocked); + // + // 2. Pause check: + // require!(!config.paused, HookError::Paused); + // + // 3. OFT/Bridge bypass (skip checks for trusted programs): + // if ctx.accounts.source.key() == trusted_program { + // return Ok(()); + // } + // + // ===================================================================== + + // Example: Only allow transfers of 100 or more raw tokens + require!(amount >= 100, HookError::AmountTooSmall); + + msg!("Transfer Hook: transfer approved"); + Ok(()) + } +} diff --git a/examples/oft-solana/programs/transfer-hook/src/lib.rs b/examples/oft-solana/programs/transfer-hook/src/lib.rs new file mode 100644 index 0000000000..ba661f02f9 --- /dev/null +++ b/examples/oft-solana/programs/transfer-hook/src/lib.rs @@ -0,0 +1,136 @@ +//! # Token-2022 Transfer Hook Example +//! +//! This program demonstrates how to implement a Transfer Hook for Token-2022 tokens. +//! Transfer Hooks allow custom validation logic to be executed on every token transfer, +//! enabling use cases like: +//! +//! - **Compliance**: Allowlist/blocklist enforcement for regulated tokens +//! - **Royalties**: Enforcing royalty payments on NFT transfers +//! - **Transfer restrictions**: Time-locks, vesting schedules, etc. +//! +//! ## Architecture +//! +//! The Transfer Hook integrates with Token-2022's transfer flow: +//! +//! ```text +//! User calls transfer_checked() +//! │ +//! ▼ +//! Token-2022 validates (balance, decimals) +//! │ +//! ▼ +//! Token-2022 invokes Transfer Hook Program ◄── This program +//! │ +//! ▼ +//! Hook validates (compliance, allowlist, etc.) +//! │ +//! ▼ +//! Transfer completes (or reverts) +//! ``` +//! +//! ## Key Components +//! +//! - **ExtraAccountMetaList**: PDA that declares additional accounts the hook needs +//! - **fallback instruction**: Bridges SPL instruction format to Anchor handlers +//! - **transfer_hook instruction**: Contains the actual validation logic +//! +//! ## Example Usage +//! +//! This example implements a simple threshold check (transfers must be > 100 tokens). +//! In production, you would replace this with your compliance logic. +//! +//! ## References +//! +//! - [Solana Transfer Hook Guide](https://solana.com/developers/guides/token-extensions/transfer-hook) +//! - [Token-2022 Docs](https://spl.solana.com/token-2022) + +#![allow(deprecated)] // Anchor 0.31 internal uses deprecated AccountInfo::realloc + +use anchor_lang::prelude::*; +use spl_transfer_hook_interface::instruction::TransferHookInstruction; + +pub mod errors; +pub mod instructions; + +use instructions::*; + +declare_id!("Hook111111111111111111111111111111111111111"); + +/// Seed for the ExtraAccountMetaList PDA +pub const EXTRA_ACCOUNT_METAS_SEED: &[u8] = b"extra-account-metas"; + +/// Transfer Hook Program +/// +/// Implements the Token-2022 Transfer Hook interface for custom transfer validation. +/// This example demonstrates the boilerplate required for any Transfer Hook, +/// with a placeholder validation rule (amount > 100). +#[program] +pub mod transfer_hook { + use super::*; + + /// Initialize the ExtraAccountMetaList PDA for this hook. + /// + /// Must be called once per mint after creating a Token-2022 mint with the + /// Transfer Hook extension pointing to this program. This PDA declares + /// which additional accounts the hook needs during transfer execution. + /// + /// # Accounts + /// - `payer`: Pays for account creation (rent) + /// - `mint`: The Token-2022 mint with Transfer Hook extension + /// - `extra_account_meta_list`: PDA to be created + /// - `system_program`: For account creation + pub fn initialize_extra_account_meta_list(mut ctx: Context) -> Result<()> { + Initialize::apply(&mut ctx) + } + + /// Transfer Hook execute - validates transfers against custom rules. + /// + /// Called by Token-2022 on every `transfer_checked` or `transfer_checked_with_fee`. + /// Return `Ok(())` to allow the transfer, or an error to reject it. + /// + /// # Arguments + /// - `amount`: The transfer amount in raw token units (not decimal-adjusted) + /// + /// # Accounts + /// - `source`: Source token account + /// - `mint`: Token mint + /// - `destination`: Destination token account + /// - `authority`: The authority (signer) for the transfer + /// - `extra_account_meta_list`: This program's ExtraAccountMetaList PDA + pub fn transfer_hook(ctx: Context, amount: u64) -> Result<()> { + TransferHookExecute::apply(&ctx, amount) + } + + /// Fallback handler for SPL Transfer Hook interface. + /// + /// Token-2022 uses the SPL instruction format (8-byte discriminator from + /// `spl_transfer_hook_interface`), not Anchor's format. This fallback + /// intercepts unknown instructions, unpacks the SPL discriminator, and + /// routes to our Anchor `transfer_hook` handler. + /// + /// This is required boilerplate for any Anchor-based Transfer Hook. + /// + /// # How it works + /// 1. Token-2022 invokes this program with SPL discriminator + /// 2. Anchor doesn't recognize it, calls `fallback` + /// 3. We unpack using `TransferHookInstruction::unpack` + /// 4. Route to `transfer_hook` via Anchor's internal dispatch + /// + /// # Reference + /// [Solana Transfer Hook Guide](https://solana.com/developers/guides/token-extensions/transfer-hook) + pub fn fallback<'info>( + program_id: &Pubkey, + accounts: &'info [AccountInfo<'info>], + data: &[u8], + ) -> Result<()> { + let instruction = TransferHookInstruction::unpack(data)?; + + match instruction { + TransferHookInstruction::Execute { amount } => { + let amount_bytes = amount.to_le_bytes(); + __private::__global::transfer_hook(program_id, accounts, &amount_bytes) + } + _ => Err(ProgramError::InvalidInstructionData.into()), + } + } +} diff --git a/examples/oft-solana/test/anchor/transfer-hook.test.ts b/examples/oft-solana/test/anchor/transfer-hook.test.ts new file mode 100644 index 0000000000..8b34ba6e45 --- /dev/null +++ b/examples/oft-solana/test/anchor/transfer-hook.test.ts @@ -0,0 +1,423 @@ +import * as anchor from '@coral-xyz/anchor' +import { Program } from '@coral-xyz/anchor' +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + ExtensionType, + TOKEN_2022_PROGRAM_ID, + createAssociatedTokenAccountInstruction, + createInitializeMintInstruction, + createInitializeTransferHookInstruction, + createMintToInstruction, + createTransferCheckedWithTransferHookInstruction, + getAssociatedTokenAddressSync, + getMintLen, +} from '@solana/spl-token' +import { Keypair, PublicKey, SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js' + +import { TransferHook } from '../../target/types/transfer_hook' + +describe('transfer-hook', () => { + // Configure the client to use the local cluster + const provider = anchor.AnchorProvider.env() + anchor.setProvider(provider) + + const program = anchor.workspace.TransferHook as Program + + // Test accounts + const payer = (provider.wallet as anchor.Wallet).payer + const mintKeypair = Keypair.generate() + const mint = mintKeypair.publicKey + + // Token accounts + let sourceTokenAccount: PublicKey + let destinationTokenAccount: PublicKey + const destinationOwner = Keypair.generate() + + // Additional test accounts for edge cases + let secondDestinationAccount: PublicKey + const secondDestinationOwner = Keypair.generate() + + // PDAs + let extraAccountMetaListPDA: PublicKey + + const decimals = 9 + const initialMintAmount = 100_000 // 100,000 raw tokens + + beforeAll(async () => { + // Derive the ExtraAccountMetaList PDA + ;[extraAccountMetaListPDA] = PublicKey.findProgramAddressSync( + [Buffer.from('extra-account-metas'), mint.toBuffer()], + program.programId + ) + + // Calculate associated token accounts + sourceTokenAccount = getAssociatedTokenAddressSync( + mint, + payer.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + + destinationTokenAccount = getAssociatedTokenAddressSync( + mint, + destinationOwner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + + secondDestinationAccount = getAssociatedTokenAddressSync( + mint, + secondDestinationOwner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + }) + + describe('Mint Setup', () => { + it('creates a Token-2022 mint with Transfer Hook extension', async () => { + // Calculate space for mint with Transfer Hook extension + const extensions = [ExtensionType.TransferHook] + const mintLen = getMintLen(extensions) + + // Get rent for the mint account + const lamports = await provider.connection.getMinimumBalanceForRentExemption(mintLen) + + // Create mint account with Transfer Hook extension + const transaction = new Transaction().add( + // Create account + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + space: mintLen, + lamports, + programId: TOKEN_2022_PROGRAM_ID, + }), + // Initialize Transfer Hook extension - MUST come before mint init + createInitializeTransferHookInstruction( + mint, + payer.publicKey, // authority + program.programId, // transfer hook program + TOKEN_2022_PROGRAM_ID + ), + // Initialize mint + createInitializeMintInstruction( + mint, + decimals, + payer.publicKey, // mint authority + null, // freeze authority + TOKEN_2022_PROGRAM_ID + ) + ) + + await sendAndConfirmTransaction(provider.connection, transaction, [payer, mintKeypair]) + + // Verify mint was created + const mintInfo = await provider.connection.getAccountInfo(mint) + expect(mintInfo).not.toBeNull() + expect(mintInfo!.owner.toBase58()).toBe(TOKEN_2022_PROGRAM_ID.toBase58()) + }) + }) + + describe('ExtraAccountMetaList Initialization', () => { + it('initializes the ExtraAccountMetaList PDA', async () => { + await program.methods + .initializeExtraAccountMetaList() + .accounts({ + payer: payer.publicKey, + mint: mint, + extraAccountMetaList: extraAccountMetaListPDA, + systemProgram: SystemProgram.programId, + }) + .signers([payer]) + .rpc() + + // Verify the account was created + const account = await provider.connection.getAccountInfo(extraAccountMetaListPDA) + expect(account).not.toBeNull() + expect(account!.owner.toBase58()).toBe(program.programId.toBase58()) + }) + + it('fails to initialize twice (account already exists)', async () => { + try { + await program.methods + .initializeExtraAccountMetaList() + .accounts({ + payer: payer.publicKey, + mint: mint, + extraAccountMetaList: extraAccountMetaListPDA, + systemProgram: SystemProgram.programId, + }) + .signers([payer]) + .rpc() + fail('Should have thrown an error') + } catch (error: any) { + // Expected: account already exists + expect(error.toString()).toContain('already in use') + } + }) + }) + + describe('Token Account Setup', () => { + it('creates source and destination token accounts', async () => { + // Create source token account (for payer) + const createSourceAta = createAssociatedTokenAccountInstruction( + payer.publicKey, + sourceTokenAccount, + payer.publicKey, + mint, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + + // Create destination token account + const createDestAta = createAssociatedTokenAccountInstruction( + payer.publicKey, + destinationTokenAccount, + destinationOwner.publicKey, + mint, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + + const transaction = new Transaction().add(createSourceAta, createDestAta) + + await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + + // Verify accounts were created + const sourceInfo = await provider.connection.getAccountInfo(sourceTokenAccount) + const destInfo = await provider.connection.getAccountInfo(destinationTokenAccount) + expect(sourceInfo).not.toBeNull() + expect(destInfo).not.toBeNull() + }) + + it('mints tokens to source account', async () => { + const mintToSource = createMintToInstruction( + mint, + sourceTokenAccount, + payer.publicKey, + initialMintAmount, + [], + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(mintToSource) + + await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + }) + }) + + describe('Transfer Hook Validation - Happy Path', () => { + it('allows transfer of 100 tokens (at threshold)', async () => { + const transferAmount = BigInt(100) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + + const txSig = await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + + expect(txSig).toBeDefined() + }) + + it('allows transfer of 500 tokens', async () => { + const transferAmount = BigInt(500) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + + const txSig = await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + + expect(txSig).toBeDefined() + }) + + it('allows transfer of large amount (10,000 tokens)', async () => { + const transferAmount = BigInt(10_000) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + + const txSig = await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + + expect(txSig).toBeDefined() + }) + }) + + describe('Transfer Hook Validation - Error Cases', () => { + it('rejects transfer of 99 tokens (just below threshold)', async () => { + const transferAmount = BigInt(99) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + + try { + await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + fail('Transfer should have been rejected') + } catch (error: any) { + // Expected: transfer hook rejects amounts < 100 + expect(error.toString()).toContain('custom program error') + } + }) + + it('rejects transfer of 50 tokens (below threshold)', async () => { + const transferAmount = BigInt(50) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + + try { + await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + fail('Transfer should have been rejected') + } catch (error: any) { + expect(error.toString()).toContain('custom program error') + } + }) + + it('rejects transfer of 1 token (minimum)', async () => { + const transferAmount = BigInt(1) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + + try { + await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + fail('Transfer should have been rejected') + } catch (error: any) { + expect(error.toString()).toContain('custom program error') + } + }) + }) + + describe('Transfer Hook - Edge Cases', () => { + it('handles multiple sequential valid transfers', async () => { + // Do 3 transfers in sequence + for (let i = 0; i < 3; i++) { + const transferAmount = BigInt(150 + i * 50) + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + destinationTokenAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + } + }) + + it('allows transfer to new account after creating it', async () => { + // Create second destination account + const createSecondAta = createAssociatedTokenAccountInstruction( + payer.publicKey, + secondDestinationAccount, + secondDestinationOwner.publicKey, + mint, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ) + + const createTx = new Transaction().add(createSecondAta) + await sendAndConfirmTransaction(provider.connection, createTx, [payer]) + + // Now transfer to it + const transferAmount = BigInt(200) + const transferIx = await createTransferCheckedWithTransferHookInstruction( + provider.connection, + sourceTokenAccount, + mint, + secondDestinationAccount, + payer.publicKey, + transferAmount, + decimals, + [], + 'confirmed', + TOKEN_2022_PROGRAM_ID + ) + + const transaction = new Transaction().add(transferIx) + const txSig = await sendAndConfirmTransaction(provider.connection, transaction, [payer]) + + expect(txSig).toBeDefined() + }) + }) +})