ERC-3643은 규제 준수(KYC, AML 등) 및 허가된 토큰 발행을 위해 특별히 설계된 이더리움 토큰 표준입니다. ERC-20과 마찬가지로 대체 가능한 토큰의 핵심 기능을 정의하지만, 필수적인 규제 준수 요구사항을 온체인에서 강제할 수 있도록 보장합니다.
Solana's Token Extensions (also referred to as Token-2022) are a set of enhancements to Solana's native SPL Token program, introducing built-in support for advanced token features without requiring new standalone contracts. The motivation for Token Extensions was to provide a more flexible, extensible token standard that caters to complex and regulated use cases, all while avoiding the fragmentation seen in ecosystems with many bespoke token contracts.
Key Features (You can check other features here)
Below is a Solidity-style pseudocode that illustrates how key features of Solana's Token Extensions, such as KYC enforcement, account freezing, transfer hooks, and permanent delegate functionality could be conceptually represented in an Ethereum-like contract.
Additionally, this code builds on SPL-20 (the standard Solana token program, SPL-Token) and layers the Token Extension functionality on top of it using Solidity syntax. If you'd like to review the baseline Solana token program (SPL-Token), please refer to this link.
pragma solidity ^0.8.28;
interface ISpl20 {
function transfer(address to, address mintAddress, uint256 amount) external;
function getTokenAccount(address owner, address token) external view returns (uint256 balance, bool isFrozen);
function mintTokens(address to, address mintAddress, uint256 amount) external;
}
contract SPL3643 {
ISpl20 public immutable spl20;
address public immutable mintAddress;
mapping(address => bool) public isKYCApproved;
mapping(address => bool) public frozen;
address public complianceAuthority;
address public transferHookProgram;
event KYCApproved(address indexed user, bool status);
event AccountFrozen(address indexed user, bool status);
event TransferHookSet(address indexed hookProgram);
event ForcedTransfer(address indexed from, address indexed to, uint256 value);
event Transfer(address indexed from, address indexed to, uint256 value);
constructor(address _spl20, address _mint, address authority) {
spl20 = ISpl20(_spl20);
mintAddress = _mint;
complianceAuthority = authority;
}
modifier onlyComplianceAuth() {
require(msg.sender == complianceAuthority, "not compliance auth");
_;
}
function approveKYC(address user, bool approved) external onlyComplianceAuth {
isKYCApproved[user] = approved;
emit KYCApproved(user, approved);
}
function freezeAccount(address user, bool freeze) external onlyComplianceAuth {
frozen[user] = freeze;
emit AccountFrozen(user, freeze);
}
function setTransferHook(address hookProgram) external onlyComplianceAuth {
transferHookProgram = hookProgram;
emit TransferHookSet(hookProgram);
}
function setComplianceAuthority(address newAuthority) external onlyComplianceAuth {
complianceAuthority = newAuthority;
}
function transfer(address to, uint256 amount) external {
require(!frozen[msg.sender] && !frozen[to], "account frozen");
require(isKYCApproved[msg.sender] && isKYCApproved[to], "KYC required");
if (transferHookProgram != address(0)) {
bool ok = ITransferHook(transferHookProgram).onTransfer(msg.sender, to, amount);
require(ok, "blocked by hook");
}
spl20.transfer(to, mintAddress, amount);
emit Transfer(msg.sender, to, amount);
}
function forceTransfer(address from, address to, uint256 amount) external onlyComplianceAuth {
// temporarily unfreeze to bypass Spl20.transfer require(msg.sender == owner)
frozen[from] = false;
spl20.transfer(to, mintAddress, amount);
frozen[from] = true;
emit ForcedTransfer(from, to, amount);
}
}
interface ITransferHook {
function onTransfer(address from, address to, uint256 amount) external returns (bool);
}
The isKYCApproved mapping ensures that only KYC-verified users can send or receive tokens. Before a transfer proceeds, both the sender and recipient must be flagged as approved. This mimics Solana's capability to restrict transfers to verified identities via identity-gated token accounts or transfer hooks configured at the mint level.
The frozen mapping lets the compliance authority lock any account from sending or receiving tokens. In Solana, token accounts can be frozen either by a freeze authority, a native feature that provides immediate enforcement without contract logic.
The transferHookProgram variable represents an external contract that implements custom logic. It is called during each token transfer, allowing dynamic enforcement like:
솔라나의 Transfer Hook 확장 기능은 프로토콜 수준에서 이 기능을 수행하여, 개발자에게 커스텀 컨트랙트 배포 없이도 전송에 대한 강력한 온체인 제어를 제공합니다.
The complianceAuthority has the power to forcibly transfer tokens between any two addresses. This is comparable to Solana's Permanent Delegate feature, which allows authorized entities to bypass user permissions in regulated contexts as required, for example, to comply with legal orders or reclaim tokens during fraud mitigation.
| ERC-3643 Feature | Token-2022 Counterpart / Actor |
|---|---|
| KYC whitelist | Custom transfer-hook program |
| Account freeze | freeze_authority |
| Force / claw-back transfer | permanent_delegate::transfer |
| On-transfer custom logic | Transfer-hook callback (execute) |
| Compliance admin | PDA signer or multisig that owns the mint |
User → Token‑2022 (transfer) ↘
Transfer Hook (KYC check) → OK / ERR
↘
Token‑2022 (actual balance change)
하나의 솔라나 트랜잭션에는 토큰 명령어와 훅 호출이 모두 포함됩니다. 훅이 오류를 반환하면 전체 트랜잭션이 롤백되며, 이는 ERC-3643의 온체인 집행을 그대로 반영합니다.
This program adds a KYC gate to any Token 2022 mint. During setup the one-time init_meta instruction creates the extra_account_meta_list PDA whose fixed seeds bind it to the mint, stores the whitelist PDA inside that list, and immediately inserts the initializer (payer) into whitelist.allowed. That means the mint authority can move the first tokens without an extra whitelist update call. Later, whenever someone calls transfer_checked, the Token 2022 program CPI-invokes the hook's execute function, passing the five fixed accounts (source token, mint, destination token, owner, meta list) plus the forwarded whitelist PDA. The hook checks that the owners of both token accounts appear in whitelist.allowed; if either is missing it raises SrcNotAllowed or DstNotAllowed, rolling back the whole transaction, otherwise it returns Ok(()) and the transfer finalizes. All other administrative powers such as freezing, claw back, and supply changes remain with the mint's native authorities, so roughly ninety lines of code deliver ERC 3643 style compliance without breaking standard SPL tooling or wallet UX.
// programs/kyc_hook/src/lib.rs
use anchor_lang::prelude::*;
use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface};
use spl_tlv_account_resolution::{seeds::Seed, state::*};
use spl_transfer_hook_interface::instruction::TransferHookInstruction;
declare_id!("KycHook1111111111111111111111111111111111111");
#[program]
pub mod kyc_hook {
use super::*;
/// One-time initializer: create extra_account_meta_list PDA,
/// register the whitelist PDA, and automatically whitelist the payer.
#[interface(spl_transfer_hook_interface::initialize_extra_account_meta_list)]
pub fn init_meta(ctx: Context<InitMeta>) -> Result<()> {
/* -- 1. Seed the whitelist with the payer / mint authority -- */
let wl = &mut ctx.accounts.whitelist;
let payer_key = ctx.accounts.payer.key();
if !wl.allowed.contains(&payer_key) {
wl.allowed.push(payer_key);
}
/* -- 2. Build ExtraAccountMeta so Token-2022 forwards whitelist -- */
let metas = vec![ExtraAccountMeta::new_with_pubkey(
&ctx.accounts.whitelist.key(),
/* is_signer */ false,
/* is_writable*/ true,
)?];
/* -- 3. Create extra_account_meta_list PDA -- */
let size = ExtraAccountMetaList::size_of(metas.len())? as u64;
let lamports = Rent::get()?.minimum_balance(size as usize);
let seeds = &[
b"extra-account-metas",
ctx.accounts.mint.key().as_ref(),
&[ctx.bumps.extra_metas],
];
anchor_lang::system_program::create_account(
CpiContext::new_with_signer(
ctx.accounts.system_program.to_account_info(),
anchor_lang::system_program::CreateAccount {
from: ctx.accounts.payer.to_account_info(),
to: ctx.accounts.extra_metas.to_account_info(),
},
&[seeds],
),
lamports,
size,
ctx.program_id,
)?;
/* -- 4. Initialise TLV data inside the PDA -- */
ExtraAccountMetaList::init::<TransferHookInstruction>(
&mut ctx.accounts.extra_metas.try_borrow_mut_data()?,
&metas,
)?;
Ok(())
}
/// Called automatically on every Token-2022 transfer.
#[interface(spl_transfer_hook_interface::execute)]
pub fn execute(ctx: Context<Hook>, _amount: u64) -> Result<()> {
let wl = &ctx.accounts.whitelist;
let src = ctx.accounts.source_token.owner;
let dst = ctx.accounts.dest_token.owner;
require!(wl.allowed.contains(&src), ComplianceError::SrcNotAllowed);
require!(wl.allowed.contains(&dst), ComplianceError::DstNotAllowed);
Ok(())
}
}
/* ---------------- Data & account structs ---------------- */
#[account] // simple demo whitelist
pub struct Whitelist {
pub allowed: Vec<Pubkey>,
}
/* init_meta accounts */
#[derive(Accounts)]
pub struct InitMeta<'info> {
#[account(mut)]
payer: Signer<'info>,
/// CHECK: PDA = ["extra-account-metas", mint]
#[account(mut, seeds = [b"extra-account-metas", mint.key().as_ref()], bump)]
extra_metas: AccountInfo<'info>,
/// CHECK: verified by interface macro
#[account(mut)]
mint: InterfaceAccount<'info, Mint>,
#[account(mut)]
whitelist: Account<'info, Whitelist>,
system_program: Program<'info, System>,
}
/* execute accounts (fixed order!) */
#[derive(Accounts)]
pub struct Hook<'info> {
// 0 source token
#[account(token::mint = mint, token::authority = owner)]
source_token: InterfaceAccount<'info, TokenAccount>,
// 1 mint
mint: InterfaceAccount<'info, Mint>,
// 2 destination token
#[account(token::mint = mint)]
dest_token: InterfaceAccount<'info, TokenAccount>,
// 3 owner (source wallet)
/// CHECK:
owner: UncheckedAccount<'info>,
// 4 extra_account_meta_list
/// CHECK:
#[account(seeds = [b"extra-account-metas", mint.key().as_ref()], bump)]
extra_account_meta_list: UncheckedAccount<'info>,
// 5 whitelist (forwarded via meta list)
whitelist: Account<'info, Whitelist>,
}
#[error_code]
pub enum ComplianceError {
#[msg("source wallet not allowed")]
SrcNotAllowed,
#[msg("destination wallet not allowed")]
DstNotAllowed,
}
먼저 규제 준수 관리자 역할을 할 지갑을 선택하고 해당 pubkey를 COMP_AUTH에 저장한 다음, 훅에 저장할 화이트리스트 PDA를 미리 계산하여 WL_PDA로 저장합니다. 키가 준비되면 anchor build를 사용하여 kyc_hook을 컴파일하고 배포하며, 새로 출력된 program-id를 HOOK_ID로 기록합니다. 다음으로 --transfer-hook $HOOK_ID를 전달하여 이 훅을 가리키는 새로운 Token-2022 민트를 생성하고, 동시에 관리자 지갑을 동결 및 환수 권한으로 지정합니다. 솔라나는 결과 MINT_ADDRESS를 출력합니다. Token-2022는 훅에 전달할 추가 계정을 알아야 하므로, 즉시 anchor run init-meta를 실행하여 훅의 초기화 함수를 호출하고 WL_PDA를 민트의 extra-account-meta 목록에 저장합니다. 이 단계는 한 번만 수행됩니다. 이제 민트가 KYC를 위해 연결되었으므로, 자유롭게 초기 공급량을 자신에게 발행할 수 있습니다. 훅은 발행 작업 시 호출되지 않습니다. 마지막으로 다른 지갑으로 토큰을 전송하면, 그 순간 Token-2022가 훅을 호출하고 훅은 발신자와 수신자를 모두 온체인 화이트리스트와 대조합니다. 둘 다 목록에 있으면 전송이 완료되고, 둘 중 하나라도 누락되면 훅이 오류를 발생시켜 전체 트랜잭션이 롤백되며, 이를 통해 단일 솔라나 트랜잭션에서 ERC-3643 스타일의 규제 준수를 구현할 수 있습니다.
# 0. Keys & PDAs
COMP_AUTH=$(solana address) # compliance admin key
WL_PDA=<derived whitelist PDA> # used in step 2
# 1. Build & deploy the hook
anchor build
solana program deploy target/deploy/kyc_hook.so # save as HOOK_ID
# 2. Create a KYC-enabled mint (Token-2022 CLI)
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb create-token \
--transfer-hook $HOOK_ID \
--enable-permanent-delegate \
--freeze-authority $COMP_AUTH # returns MINT_ADDRESS
# 3. Initialize extra_account_meta_list (one-time)
anchor run init-meta -- --mint $MINT_ADDRESS --whitelist $WL_PDA
# 4. Mint & transfer
spl-token mint $MINT_ADDRESS 100 $(solana address) # minting bypasses hook
spl-token transfer $MINT_ADDRESS 10 <RECIPIENT> # hook executes, KYC enforced