← Writing

Writing

KYC-gated Token-2022 transfers on Solana

  • solana
  • token-2022
  • transfer-hook
  • kyc
  • anchor
  • rust

Regulated and permissioned tokens usually need one hard rule: only KYC-approved parties can move the asset. An allowlist in an application database is useful for UX, but it cannot stop someone who builds their own transaction. Enforcement has to live on-chain, on the transfer path itself.

Solana’s classic SPL Token program has no extension point for that policy. Token-2022 does: the Transfer Hook extension stores a program id on the mint, and Token-2022 CPI-calls that program on every Transfer / TransferChecked. If the hook returns an error, the whole transaction fails.

This article walks through a concrete design: a Token-2022 mint whose hook validates KYC records for both the source and destination token accounts. The accompanying open-source example (token2022-kyc-transfer-hook) is an Anchor program plus TypeScript scripts that implement the pattern end to end.

Transfer outcomes

Whitelisted ATA ──transfer──► Whitelisted ATA     ✅ succeeds
Whitelisted ATA ──transfer──► Unknown ATA         ❌ DestinationNotApproved / missing PDA
Unknown ATA     ──transfer──► Whitelisted ATA     ❌ SourceNotApproved / missing PDA

There is no soft fail and no reliance on the client “remembering” to check KYC.

Architecture

Three PDAs carry the state:

Account Seeds Purpose
Config ["config"] Singleton - compliance admin pubkey
ExtraAccountMetaList ["extra-account-metas", mint] Tells Token-2022 how to derive KYC PDAs at transfer time
KycRecord ["kyc-record", mint, token_account] One whitelist row per ATA (not per wallet alone)
                    ┌─────────────────────┐
   transfer ───────►│   Token-2022 mint   │
                    │  TransferHook ext   │
                    └──────────┬──────────┘
                               │ CPI Execute
                               ▼
                    ┌─────────────────────┐
                    │     kyc_guard       │
                    │   transfer_hook()   │
                    └──────────┬──────────┘
               ┌───────────────┴───────────────┐
               ▼                               ▼
     KycRecord(src ATA)               KycRecord(dst ATA)
     status == Approved               status == Approved
     expiry ok                        expiry ok

Whitelist by mint + token account. That matches the accounts Token-2022 passes into the hook (source_token, mint, destination_token). Wallet-only keys still need ATA pubkeys at execute time; the ATA is the natural unit of custody.

The KYC record

#[account]
pub struct KycRecord {
    pub mint: Pubkey,
    pub token_account: Pubkey,
    /// 0 = Unapproved, 1 = Approved, 2 = Revoked
    pub status: u8,
    /// Unix timestamp, or -1 for never expires
    pub expiry_ts: i64,
    pub last_update_ts: i64,
}

Account space: 8 (discriminator) + 32 + 32 + 1 + 8 + 8.

#[repr(u8)]
pub enum KycStatus {
    Unapproved = 0,
    Approved = 1,
    Revoked = 2,
}

Step 1 - Initialize config

Deploy the hook program, align program ids (anchor keys sync after clone), then create the config PDA once:

pub fn initialize_config(ctx: Context<InitializeConfig>, admin: Pubkey) -> Result<()> {
    ctx.accounts.config.admin = admin;
    Ok(())
}
#[derive(Accounts)]
pub struct InitializeConfig<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,

    #[account(
        init,
        payer = payer,
        space = 8 + 32,
        seeds = [b"config"],
        bump
    )]
    pub config: Account<'info, Config>,

    pub system_program: Program<'info, System>,
}

From TypeScript:

const [configPda] = PublicKey.findProgramAddressSync(
  [Buffer.from("config")],
  program.programId
);

await program.methods
  .initializeConfig(provider.wallet.publicKey)
  .accounts({
    payer: provider.wallet.publicKey,
    config: configPda,
    systemProgram: SystemProgram.programId,
  })
  .rpc();

Only this admin can later call update_kyc_record, close_kyc_record, or update_config.

Step 2 - Create a Token-2022 mint with Transfer Hook

Extensions must be enabled before InitializeMint:

  1. SystemProgram.createAccount (Token-2022 owns the mint)
  2. InitializeMetadataPointer (optional)
  3. InitializeTransferHook - hook program id = the KYC program
  4. InitializeMint
  5. On-mint metadata init (if using MetadataPointer)
  6. initialize_extra_account_meta_list on the hook program (next section)
const mintSpace = getMintLen([
  ExtensionType.MetadataPointer,
  ExtensionType.TransferHook,
]);

const tx = new Transaction().add(
  SystemProgram.createAccount({
    fromPubkey: payer.publicKey,
    newAccountPubkey: mint.publicKey,
    space: mintSpace,
    lamports,
    programId: TOKEN_2022_PROGRAM_ID,
  }),
  createInitializeMetadataPointerInstruction(
    mint.publicKey,
    payer.publicKey,
    mint.publicKey,
    TOKEN_2022_PROGRAM_ID
  ),
  createInitializeTransferHookInstruction(
    mint.publicKey,
    payer.publicKey,
    program.programId,
    TOKEN_2022_PROGRAM_ID
  ),
  createInitializeMintInstruction(
    mint.publicKey,
    decimals,
    payer.publicKey,
    null,
    TOKEN_2022_PROGRAM_ID
  )
);

Without InitializeTransferHook, Token-2022 never CPI-calls the KYC program.

Step 3 - Register ExtraAccountMetaList

Token-2022 only passes a fixed account set into the hook by default. KYC PDAs are extra. An ExtraAccountMetaList account describes how to derive them from the execute accounts:

  • index 0 → source token account
  • index 1 → mint
  • index 2 → destination token account
let account_metas = vec![
    ExtraAccountMeta::new_with_seeds(
        &[
            Seed::Literal { bytes: b"kyc-record".to_vec() },
            Seed::AccountKey { index: 1 }, // mint
            Seed::AccountKey { index: 0 }, // source token
        ],
        false,
        false,
    )?,
    ExtraAccountMeta::new_with_seeds(
        &[
            Seed::Literal { bytes: b"kyc-record".to_vec() },
            Seed::AccountKey { index: 1 },
            Seed::AccountKey { index: 2 }, // destination token
        ],
        false,
        false,
    )?,
];

Creating the list account (seeds ["extra-account-metas", mint]) must be signed by the mint authority:

let auth = Option::from(ctx.accounts.mint.mint_authority);
let auth_pubkey = auth.ok_or(KycError::Unauthorized)?;
require_keys_eq!(auth_pubkey, ctx.accounts.payer.key(), KycError::Unauthorized);

Call this once per mint, after the Transfer Hook extension is set and before the first transfer. A missing ExtraAccountMetaList produces confusing missing-account failures.

Step 4 - Execute path

Token-2022 does not call a named Anchor instruction directly. It sends a Transfer Hook Execute instruction. Anchor needs a fallback that unpacks it and forwards to 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()),
    }
}

Accounts context:

#[derive(Accounts)]
pub struct TransferHook<'info> {
    #[account(token::mint = mint, token::authority = owner)]
    pub source_token: InterfaceAccount<'info, TokenAccount>,
    pub mint: InterfaceAccount<'info, Mint>,
    #[account(token::mint = mint)]
    pub destination_token: InterfaceAccount<'info, TokenAccount>,
    /// CHECK: source token account owner
    pub owner: UncheckedAccount<'info>,
    /// CHECK: ExtraAccountMetaList PDA
    #[account(seeds = [b"extra-account-metas", mint.key().as_ref()], bump)]
    pub extra_account_meta_list: UncheckedAccount<'info>,

    #[account(
        seeds = [b"kyc-record", mint.key().as_ref(), source_token.key().as_ref()],
        bump
    )]
    pub src_kyc_record: Account<'info, KycRecord>,

    #[account(
        seeds = [b"kyc-record", mint.key().as_ref(), destination_token.key().as_ref()],
        bump
    )]
    pub dst_kyc_record: Account<'info, KycRecord>,
}

Policy - both legs approved and not expired:

pub fn transfer_hook(ctx: Context<TransferHook>, _amount: u64) -> Result<()> {
    let current_ts = Clock::get()?.unix_timestamp;

    let src = &ctx.accounts.src_kyc_record;
    require!(
        src.status == KycStatus::Approved as u8,
        KycError::SourceNotApproved
    );
    require!(
        src.expiry_ts == -1 || src.expiry_ts >= current_ts,
        KycError::SourceExpired
    );

    let dst = &ctx.accounts.dst_kyc_record;
    require!(
        dst.status == KycStatus::Approved as u8,
        KycError::DestinationNotApproved
    );
    require!(
        dst.expiry_ts == -1 || dst.expiry_ts >= current_ts,
        KycError::DestinationExpired
    );

    Ok(())
}
#[error_code]
pub enum KycError {
    #[msg("Source token account is not KYC-approved")]
    SourceNotApproved,
    #[msg("Destination token account is not KYC-approved")]
    DestinationNotApproved,
    #[msg("Source KYC has expired")]
    SourceExpired,
    #[msg("Destination KYC has expired")]
    DestinationExpired,
    #[msg("Unauthorized")]
    Unauthorized,
    #[msg("Invalid expiry time")]
    InvalidExpiry,
    #[msg("Invalid status")]
    InvalidStatus,
}

Mint and burn

MintTo and Burn do not invoke the Transfer Hook. That is Token-2022 behavior.

Consequences:

  • Tokens can be minted into a non-whitelisted ATA.
  • That holder still cannot transfer until both source and destination records are Approved.
  • Gating mint itself needs a separate control (mint authority, gated CPI wrapper, and so on).

Step 5 - Approve an ATA

Admin-only. Creates the PDA with init_if_needed when missing:

pub fn update_kyc_record(
    ctx: Context<UpdateKycRecord>,
    status: u8,
    expiry_ts: i64,
) -> Result<()> {
    require_keys_eq!(
        ctx.accounts.authority.key(),
        ctx.accounts.config.admin,
        KycError::Unauthorized
    );
    require!(expiry_ts >= -1, KycError::InvalidExpiry);
    require!(status <= 2, KycError::InvalidStatus);

    let record = &mut ctx.accounts.kyc_record;
    record.mint = ctx.accounts.mint.key();
    record.token_account = ctx.accounts.token_account.key();
    record.status = status;
    record.expiry_ts = expiry_ts;
    record.last_update_ts = Clock::get()?.unix_timestamp;
    Ok(())
}
const ata = getAssociatedTokenAddressSync(
  mint,
  owner,
  false,
  TOKEN_2022_PROGRAM_ID
);

await program.methods
  .updateKycRecord(1, new BN(-1)) // Approved, never expires
  .accounts({
    authority: payer.publicKey,
    config: configPda,
    mint,
    tokenAccount: ata,
    kycRecord: kycPda,
    systemProgram: SystemProgram.programId,
  })
  .rpc();

Time-limited KYC uses a unix expiry instead of -1. Revoke with status = 2, or close_kyc_record to remove the PDA.

Step 6 - Transfer with a hook-aware client

A plain createTransferInstruction often does not resolve ExtraAccountMetaList. Prefer the Token-2022 helper:

const transferIx = await createTransferCheckedWithTransferHookInstruction(
  connection,
  senderAta,
  mint,
  recipientAta,
  payer.publicKey,
  amount,
  decimals,
  [],
  "confirmed",
  TOKEN_2022_PROGRAM_ID
);

await sendAndConfirmTransaction(
  connection,
  new Transaction().add(transferIx),
  [payer]
);

That helper reads the mint’s Transfer Hook extension, loads ExtraAccountMetaList, derives both KYC PDAs, and appends them to the instruction.

Scenario Result
Approved → Approved Success
Approved → no PDA / Unapproved / Revoked Fail
Expired → Approved Fail (SourceExpired)
Approved → Expired Fail (DestinationExpired)
Mint to non-listed ATA Success (hook not invoked)
Transfer from that ATA before approve Fail

End-to-end checklist

yarn install
anchor build
anchor keys sync
anchor build
anchor test

# set ANCHOR_PROVIDER_URL + ANCHOR_WALLET
npx ts-node scripts/01_init_config.ts
npx ts-node scripts/02_create_mint.ts
# export MINT=...

MINT=$MINT OWNER=<wallet_a> npx ts-node scripts/03_approve_ata.ts
MINT=$MINT OWNER=<wallet_b> npx ts-node scripts/03_approve_ata.ts

# mint to wallet_a, then:
MINT=$MINT RECIPIENT=<wallet_b> AMOUNT=1000000000 \
  npx ts-node scripts/04_transfer.ts

Production notes

  • Chain is authoritative. Dashboards may mirror whitelist rows for search; always re-check on-chain status before showing “can transfer”.
  • Admin key security. Config.admin can approve any ATA for the mint - treat it like a compliance hot wallet or multisig.
  • One ExtraAccountMetaList per mint. Many mints can share one hook program id; each mint needs its own meta-list and KYC records.
  • Program id alignment. declare_id!, Anchor.toml, deploy keypair, and client IDL must match.
  • Anchor init-if-needed is required for KYC updates:
[dependencies]
anchor-lang = { version = "0.31.1", features = ["init-if-needed"] }
anchor-spl = "0.31.1"
spl-tlv-account-resolution = "0.11.1"
spl-transfer-hook-interface = "2.1.0"

When this pattern fits

Good fit for security-token / restricted-transfer products, platform tokens that must stay inside a vetted set, and any Token-2022 asset where transfer eligibility must fail closed on-chain.

Less ideal when only soft UI warnings are required, when mint/burn must be gated the same way (needs extra authority design), or when the product wants wallet-level KYC without modeling ATAs - the execute path still reasons about token accounts.

Summary

Token-2022 Transfer Hooks attach real compliance policy to every transfer without forking the token program. The durable recipe is:

  1. Hook program with per-ATA KycRecord PDAs
  2. ExtraAccountMetaList registered once per mint
  3. Admin update_kyc_record for approve / revoke / expiry
  4. Clients that use Transfer Hook-aware transfer helpers

The hook remains the enforcement layer; any operator UI is only how admins manage records.

Contact

Let’s build something together

Need a Token-2022 Transfer Hook, on-chain KYC whitelist, or an operator flow to approve ATAs and transfer safely - I’d like to hear about your project.