Token-2022 guide
Validate Token-2022 ownership and extensions, and initialize exact-size mints with explicit extension plans.
Hopper provides zero-copy token readers, declarative extension constraints, and fail-closed TLV policies. These are distinct from mint creation: reading an extension never initializes it.
MintPlan and InitializeMint2 below ship in Hopper 0.3.1. Use the matching
framework and CLI release; registry packages and compiled consumer checks
are recorded on the release status page.
Pin ownership and authority
Use the Token-2022 program explicitly when the instruction requires its extension semantics. A token-program owner check alone does not authorize the caller to operate on a mint.
use hopper::prelude::*;
use hopper::token_2022::TOKEN_2022_PROGRAM_ID;
#[derive(Accounts)]
pub struct ConfigureMint<'info> {
#[account(
mut,
mint::authority = *ctx.account(1)?.address(),
mint::token_program = TOKEN_2022_PROGRAM_ID,
)]
pub mint: UncheckedAccount<'info>,
pub authority: Signer<'info>,
}
mint::token_program and token::token_program choose the required owner
for the corresponding mint or token constraints. Without an override they
use legacy SPL Token. In 0.3.1, every extension constraint also
requires Token-2022 ownership before reading its bytes, even when a separate
mint/token owner constraint is omitted. The published 0.3.0 release requires
that explicit owner constraint; retain it in code that must support 0.3.0.
Raw TLV reader functions do not establish ownership on their own.
Create a mint with an exact extension plan
This helper creates a signer mint with a close authority and metadata pointer. The pointer identifies an account; it does not create or populate metadata in that account.
use hopper::prelude::*;
use hopper::token_2022::{MintConfig, MintExtension, MintPlan, MintProgram};
pub fn create_mint<'info>(
payer: &Signer<'info>,
mint: &Signer<'info>,
authority: &Address,
) -> ProgramResult {
let extensions = [
MintExtension::MintCloseAuthority(Some(authority)),
MintExtension::MetadataPointer {
authority: Some(authority),
metadata_address: Some(mint.address()),
},
];
let plan = MintPlan::new(
MintProgram::Token2022,
MintConfig {
decimals: 9,
mint_authority: authority,
freeze_authority: None,
},
&extensions,
)?;
// Include System and Token-2022 program accounts in the instruction.
// Both payer and mint must be writable and sign this creation.
plan.create(payer.as_account(), mint.as_account(), &[])
}
For a PDA mint, pass the executing program's Signer seeds to create.
A signature authorizes the creation; it does not prove that an application
selected a canonical PDA. Validate the address policy separately.
The plan supports these six fixed-size mint extensions:
| Variant | Initialization configuration |
|---|---|
TransferFeeConfig |
Configuration and withdrawal authorities, basis points, maximum fee |
MintCloseAuthority |
Optional close authority |
NonTransferable |
Non-transferability marker |
PermanentDelegate |
Delegate address |
TransferHook |
Optional authority and hook program |
MetadataPointer |
Optional authority and metadata account |
plan.space() includes the base state, Token-2022 padding and TLV headers.
plan.check_space(requested) rejects both undersized and oversized allocations.
The token processor requires the size to match the extensions actually
initialized; arbitrary spare space is not accepted by this plan.
MintPlan::new rejects duplicate extensions, fees above 10,000 basis points,
and zero addresses in optional fields where zero encodes absence. A legacy
MintProgram::Legacy plan accepts an empty extension list and uses 82 bytes.
create uses the live Rent sysvar, funds only the shortfall on a prefunded
System-owned empty mint, initializes the listed extensions, and initializes
the base mint last. It uses CreateAccountAllowPrefund, so the target cluster
must support that System instruction. initialize accepts an already
allocated, correctly owned, rent-exempt, entirely zeroed account of exactly
the planned size. Neither operation creates token accounts or mints supply.
Propagate errors from these multi-CPI operations so the enclosing instruction
rolls back earlier CPIs. Catching an error and returning success can retain
partial work. The low-level InitializeMint2 builder is also available when
you manage allocation and extension initialization yourself.
The plan covers the thirteen fixed-size mint extensions: transfer fee, mint
close authority, non-transferable, permanent delegate, transfer hook,
metadata pointer, default account state, interest bearing, scaled UI amount,
pausable, group pointer, group member pointer, and permissioned burn. Each
variant's allocation and bytes are checked against the canonical
spl-token-2022-interface constructors. Variable-length token metadata,
confidential extensions, and automatic extension inference are outside this
API.
Shared token operations and extension builders
The shared instruction set (transfers, mints, burns, approvals, close,
freeze, authority changes, account and multisig initialization, the
data-size and UI-amount queries, excess-lamport withdrawal) lives in
hopper::token. Every builder there targets SPL Token from invoke() and
Token-2022 from invoke_on; invoke_for_owner reads the first account's
owner, picks the program, and refuses anything that is not one of the two:
use hopper::token::{GetAccountDataSize, InitializeAccount3, InitializeImmutableOwner, TokenProgram};
let program = TokenProgram::owning(mint)?;
let size = GetAccountDataSize { mint, extension_types: &[7] }.query(program)?;
// allocate `size` bytes owned by `program.address()`, then:
InitializeImmutableOwner { account }.invoke_on(program, &[], &[])?;
InitializeAccount3 { account, mint, owner }.invoke_for_owner(&[], &[])?;
The Token-2022-only instructions live in
hopper::token_2022::extension_instructions: CreateNativeMint,
InitializeNonTransferableMint, Reallocate, and the listed extension families'
initializers, updates, and toggles (transfer fee, default account state,
memo transfer, interest bearing, CPI guard, permanent delegate, transfer
hook, metadata pointer, group pointer, group member pointer, scaled UI
amount, pausable, permissioned burn, mint close authority). Each has
invoke, invoke_signed, and, where an authority is involved,
invoke_multisig and invoke_signed_multisig:
use hopper::token_2022::extension_instructions::{Pause, Resume, UpdateScaledUiAmountMultiplier};
Pause { mint, authority }.invoke()?;
UpdateScaledUiAmountMultiplier { mint, authority, multiplier: 3.0, effective_timestamp: 0 }.invoke()?;
Resume { mint, authority }.invoke()?;
TokenBatch collects supported builders within its byte/account limits
and sends them as one Batch
CPI; SPL Token (p-token) accepts it, and the token-lab devnet runner records
whether the deployed Token-2022 does.
Metadata and groups on the mint
Token-2022 implements the token-metadata and token-group interfaces, so a
mint can be its own metadata account and its own group. The builders live
in hopper::token_2022::metadata_instructions. They send to Token-2022
from invoke() and to any other program that implements the interface
from invoke_on_program.
use hopper::token_2022::metadata_instructions::{
InitializeTokenMetadata, MetadataField, UpdateMetadataField,
};
// The mint was created with a metadata pointer that names itself.
InitializeTokenMetadata {
metadata: mint,
update_authority: authority,
mint,
mint_authority: authority,
name: "Hopper",
symbol: "HOP",
uri: "https://example.com/hop.json",
}
.invoke()?;
UpdateMetadataField {
metadata: mint,
update_authority: authority,
field: MetadataField::Key("tier"),
value: "gold",
}
.invoke()?;
Two things to know before you ship this:
- Token-2022 grows the mint to hold the metadata and does not pay for it.
Transfer the rent for the new size to the mint first, or the instruction
fails. The token lab's
fund_growthis a few lines and does exactly that. - The instruction is encoded on the stack and capped at 512 bytes. A
longer name, symbol, URI, or value is refused with
InvalidArgumentbefore the CPI, never truncated.
RemoveMetadataKey, UpdateMetadataAuthority, and EmitTokenMetadata
(the serialized metadata comes back as return data) cover the rest of the
metadata interface. InitializeTokenGroup, UpdateTokenGroupMaxSize,
UpdateTokenGroupAuthority, and InitializeTokenGroupMember cover groups.
Confidential transfers
hopper::token_2022::confidential_instructions has a builder for each of
the fifteen confidential-transfer instructions. Hopper does not encrypt,
decrypt, or prove anything on chain: the ciphertexts, the ElGamal keys, and
the proofs are made off chain with the account's keys, and your program
carries them to Token-2022 as bytes.
Each proof an instruction needs is a ProofLocation: another instruction
in the same transaction (an offset from the Token-2022 instruction, read
through the Instructions sysvar) or a context-state account that the ZK
ElGamal proof program verified earlier.
use hopper::token_2022::confidential_instructions::{ConfidentialWithdraw, ProofLocation};
ConfidentialWithdraw {
account,
mint,
authority,
amount,
decimals,
new_decryptable_available_balance: &new_balance,
equality_proof: ProofLocation::ContextStateAccount(equality_context),
range_proof: ProofLocation::ContextStateAccount(range_context),
instructions_sysvar: None,
}
.invoke()?;
The account order is the part people get wrong by hand, so the builder
owns it: the instruction's accounts, the Instructions sysvar once if any
proof is by offset, the context-state accounts in proof order, the
authority, then the multisig signers. A proof by offset with no sysvar
account is refused with NotEnoughAccountKeys before the CPI. Every
builder is compared with the spl-token-2022-interface constructor for
all 32 combinations of proof locations, with a single authority and with
a multisig.
Run against the program mainnet runs
examples/hopper-confidential-lab puts each builder behind an instruction
and drives the whole flow through them under Mollusk, against the
Token-2022 that mainnet-beta runs (program@v11.0.0, dumped on 2026-09-29
and pinned by hash) and the ZK ElGamal proof program, with proofs made by
solana-zk-sdk and spl-token-confidential-transfer-proof-generation:
| Step through Hopper's builder | Proofs | CU, lab instruction |
|---|---|---|
Create a confidential mint (InitializeMint, then InitializeMint2) |
6,592 | |
| Configure, proof in a context-state account | pubkey validity | 11,343 |
| Configure, proof by instruction offset | pubkey validity | 14,294 |
| Configure from an ElGamal registry (Token-2022 grows the account) | 14,144 | |
| Approve, update the mint | 2,870, 2,438 | |
| Deposit | 11,304 | |
| Apply the pending balance | 9,330 | |
| Withdraw | equality, range u64 | 7,418 |
| Transfer | equality, validity, range u128 | 16,746 |
| Transfer on a fee mint | five | 46,703 |
| A credit toggle (any of the four) | 2,360 | |
| Empty, proof by instruction offset | zero ciphertext | 9,280 |
The proof program's own cost is separate: 2,600 CU for a pubkey validity
proof, 6,400 for equality, 16,400 for three-handle validity, 111,000 for a
u64 range proof, 200,000 for u128, and 368,000 for u256. The tests decrypt
what landed: the recipient reads the transferred amount, the auditor reads
it from the ciphertexts the builder carried, and the withdraw authority
reads the withheld fee. A replayed withdraw proof is refused (Balance mismatch), and so is a registry that belongs to someone else.
Two things the lab found that are easy to trip on:
- On a mint with a confidential transfer fee, size new accounts for
ConfidentialTransferFeeAmountyourself.GetAccountDataSizemakes room for the transfer-fee amount and the confidential state, andConfigureAccountthen fails withInvalidAccountData. - The Token-2022 that Mollusk 0.15 bundles is v7.0.0, whose ciphertext
operations are compiled out: it answers
DepositwithInvalidInstructionData. Test confidential flows against a current dump.
The ZK ElGamal proof program verifies proofs on mainnet-beta, testnet, and
devnet (checked by simulation on 2026-09-29), so the same flow runs on a
public cluster. examples/hopper-confidential-lab/runner does it: every
step as a real transaction through the lab program, proofs made off chain,
verified into context-state accounts, by instruction offset, and, for the
u128 and u256 range proofs that do not fit in a transaction next to the
compute-budget instruction they need, from an SPL Record account.
It ran on devnet on 2026-09-29: 60 finalized transactions against
Token-2022 program@v11.1.0, every step checked by decrypting what landed
(evidence).
On chain the transfer cost 17,134 CU and the transfer with a fee 46,974,
the same order as under Mollusk. Two things a client has to handle:
- Proof verification is a builtin instruction with a small default compute
allowance; put a
SetComputeUnitLimitin every transaction that verifies a proof. - A u128 or u256 range proof plus that instruction is over the 1,232-byte transaction limit. Write the proof to an SPL Record account and verify it from the account; the context-state account works the same afterwards.
Extension constraints
Mint-side constraints include close authority, permanent delegate, transfer hook authority/program, metadata pointer authority/address, default account state, interest-bearing rate authority, transfer-fee authorities, and presence checks for non-transferability, confidential transfer, and scaled UI amounts. For example, add these to a Token-2022 mint field:
use hopper::prelude::*;
use hopper::token_2022::TOKEN_2022_PROGRAM_ID;
#[derive(Accounts)]
#[instruction(close_authority: Address)]
pub struct InspectMint<'info> {
#[account(
mint::token_program = TOKEN_2022_PROGRAM_ID,
extensions::mint_close_authority::authority = close_authority,
extensions::non_transferable,
)]
pub mint: UncheckedAccount<'info>,
}
Token-account constraints include extensions::immutable_owner,
extensions::cpi_guard, and extensions::confidential_transfer::account.
Presence does not mean that the program supports an extension's behavior.
default_account_state::state compares a raw byte: 1 is Initialized and 2 is
Frozen. A present extension with a matching field is not an allowlist of every
other extension on the account.
Apply a policy before interpreting TLV bytes
For custody or settlement, start with an explicit allowlist of extension semantics the application supports:
use hopper::token_2022::validate_extension_allowlist;
// `tlv` is the TLV region of an owner-checked account.
// An empty allowlist rejects every extension.
validate_extension_allowlist(tlv, &[])?;
The allowlist rejects unknown IDs, duplicate entries, truncation, and entries
outside the list. The required/forbidden ExtensionPolicy helper validates
TLV structure too, but does not reject every unlisted known extension.
Use the strict mint screening helpers when checking a complete finalized
mint envelope; a raw TLV policy alone does not prove owner, mint identity,
initialization, authority, or every extension's payload semantics.
The current constants cover official discriminators through PermissionedBurn
(28). A raw presence reader can inspect an unknown ID; that does not grant
permission to accept its behavior. Never treat find_extension(...) == None
as a complete safety decision on unvalidated input. Check payload lengths
before slicing or interpreting bytes.
Resolve transfer-hook extra accounts
extra_account_metas_pda, ExtraAccountMetaList, and HookAccountBuf<N>
resolve the hook's declared extra accounts without heap allocation. Use
.address() on the resolver's account input for its address. The list supports literal
addresses, this-program PDAs, external-program PDAs, and literal,
instruction-data, or account-key seeds. Account-data seeds return
HookError::UnsupportedSeed and require explicit handling.
The resolver computes account addresses and privileges. The caller must validate the metadata-list PDA and its owner, supply the resolved accounts, and choose a capacity that accommodates the list. It does not fetch accounts or run the transfer hook.
Working programs and executable checks
examples/hopper-token-2022-vaultbinds an existing mint and authority to a reward vault, creates the vault ATA, mints rewards, and sweeps tokens. It does not impose non-transferability or a mint-close-authority constraint.examples/hopper-token-2022-transfer-hookdemonstrates hook integration.bench/mint-plan/programexercises mint creation and initialization against canonical token processors, including PDA and prefunded mint creation.crates/hopper-spl/hopper-token-2022/tests/mint_plan.rscompares allocation sizes and emitted bytes with the canonical SPL interface;crates/hopper-runtime/src/token_differential_tests.rsdoes the same for every builder's bytes and account metas.examples/hopper-token-labruns one instruction per builder family against SPL Token and Token-2022 on devnet throughscripts/test-token-lab-devnet.py, with byte-level checks of every touched account.
Mint initialization does not replace the token readers and authority checks needed by later instructions. Validate each operation's own contract.
Hook-list and batch validation in Hopper 0.5
The hook resolver selects the Execute TLV entry by discriminator, checks its declared value length, and confines the entry count to that value. Check the list account's owner and derived address separately. Literal, instruction-data, and account-key seeds are supported; account-data seeds and pubkey-data entries require explicit application resolution. A failed resolution leaves the caller's output buffer unchanged.
TokenBatch permits the same account in different inner instructions but
rejects repeated writable roles inside one, including self-transfers. Each
payload must fit 255 bytes and the batch's configured capacity. Failed pushes
leave the existing batch unchanged. Individual metadata/group APIs can be
used when an instruction intentionally needs writable aliases.
