Part-I 中文版
Token2022 Security Best Practices: A Guide for Solana Contract Developers
随着 Solana 的不断发展,用户和开发者对更复杂、适应性更强的代币功能需求日益增长,推动着生态系统的创新和进步。目前,Solana 上的代币计划主要依赖于一组简单的功能,以满足可替代和不可替代代币的基本需求。这种简单性虽然提供了易于使用的基础设施,但也在一定程度上限制了创新的空间。
为了应对这一挑战,Solana 推出了 Token2022,其提供的增强功能是专为扩展生态系统中的代币能力而设计。Token2022 不仅增加了代币的灵活性,还为开发者提供了更广泛的工具,以支持复杂的应用场景。这包括更复杂的智能合约支持、增强的安全性措施以及更高的可扩展性。
Token2022的功能和结构都是Token的严格超集,其中包含有将近20个新的extension,为了支持这些新的extension,solana 的开发人员需要认真阅读solana官方的开发文档和样例代码。但是现有的开发文档和开发样例都十分匮乏,开发和适配Token2022的过程中需要关注和留心哪些类型的安全问题,这些都还没有太多公开的文档来介绍。
我们希望通过一系列的Token2022 Security Best Practices文章,为 solana contract集成Token2022 的安全开发shed the light,让solana project开发者能在项目的开发过程中就考虑到这些问题,尽量规避一些潜在的安全风险,最大程度地保证自己的项目能够安全地支持Token2022 extensions。
在这系列的第一篇文章中,我们分别探讨以下问题,并请您额外关注每个话题中的 Developer Attention 章节:
- Account Lifetime中各个阶段的安全风险
- Token 与 Token2022的差异
- Anchor中如何安全地使用Token2022
- 存在安全风险的 Extension (Part 1)
How to secure accounts during its whole lifetime?
Token2022 的Extension都是围绕着Solana中的Token Account和Mint Account来开展的,在这些Account Lifetime的各个阶段,都存在一些安全风险,需要开发者在设计和开发的过程中关注。
这里我们针对Token Account和Mint Account来分别探讨。
1. Token Account
Token2022 中的Token Account与 SPL Token中的Token Account有一些区别。这些区别主要体现在新的Token Account中会带上更多的Token2022 extension相关的数据。
作为Token的strict superset,Token2022 中保持了Token Account的structure layout,并将额外的Extension Data数组拼接在 Token Account 后,大致的结构示意图如下:

Token Account后的可以Append 的Extension包括:
- Immutable Owner
- CPI Guard
- Required Memo on Transfer
- Non-Transferable Tokens
- Transfer Fees
- Transfer Hook
- Confidential Transfer
- Confidential Transfer Fee
\
1.1. Create Token Account
Token2022中会根据Mint和Token Account本身支持的Extension种类和数量不同,创建不同大小的Token Account,他们占用的space可能会比SPL Token中的Token Account更大,并且这个大小会随着其中包含的extension种类和数量的增加而增大。
!!! Developer Attention !!!
在开发过程中,如果需要动态为User 创建一个Token2022的Token Account,不能对Token Account硬编码一个固定的space大小,或者固定space大小需要的最小rent exempt amount。
Vulnerable Case
下面这个函数会尝试Create一个支持 Token2022 的 Token Account。
export const getCostToOpenAta = async (mint, price) => {
const cost = 0.00203928 * price.data.SOL.price * 10 ** mint.decimals;
return cost;
};
create Token Account时使用了硬编码的SOL amount 0.00203928,这个值正好对应SPL Token Account 占用的165 Bytes 所需的rent。 这里的问题就是,如果需要这个Token Account支持Token2022 extension,这个数量是不够cover加上了Token2022 account extension space之后的rent值,那么之后的create with Token2022 extension的 token account也就会失败。
如果这个space大小可以由用户任意指定,并且由keeper来负责Create Token Account,那么这里可能存在Keeper会多付rent的情况,导致Keeper发生资金损失。
推荐项目开发中尽量避免让keeper来为用户建立Token Account。
Recommendation
@solana/spl-token 中提供了api接口帮助计算带了extension之后的account length和最小rent。
- getExtensionTypes:获取mint支持的所有extension
- getMinimumBalanceForRentExemptAccountWithExtensions:获取带上extension后 token account所需要的最小rent。
下面是个针对上述问题的改进代码
export const getCostToOpenAta = async (mint, price) => {
const extensions = getExtensionTypes(mint.tlvData);
const rent = await getMinimumBalanceForRentExemptAccountWithExtensions(
connection,
[...extensions]
);
const cost = rent * price.data.SOL.price * 10 ** mint.decimals;
return cost;
}
1.2. Reallocate
有的Account Extension可以在Token Account Create之后再添加上来的,比如Memo Transfer。因此Token Account的大小可能是会动态发生变化。
为了支持Token Account创建之后还能添加新的Account Extension,Token2022 增加了一个Reallocate IX来为一个已存在Token Account增加大小,以容纳更多的extension bytes。
在Reallocate IX中,如果发现当前的Token Account大小已经满足需求,则会直接返回,不会做进一步的realloc操作。
!!! Developer Attention !!!
当需要reallocate一个更大的Token Account时,是会产生额外的rent,这部分rent 该由哪方来承担,是需要在开发的过程中考虑进来的,如果忽略了这一点,可能会造成协议/项目方自己承担这部分不在预期范围内的额外开销。
具体来说,对于可能用到的以下API,需要关注API中的这些参数该如何传递值。
payerparameter in createReallocateInstructionpayerparameter in reallocate
1.3. Close Token Account
一个Token Account是可以被close的。 在SPL Token中,对于一个非WSOL的Token Account,只要满足 account.amount == 0 ,即可由 account.authority 将这个Token Account close掉。
但是在Token2022中,close 一个 token account需要满足更多的条件:
- account.amount == 0
- 如果Token Account支持CPI Guard extension,还需要确认lamport destination必须是 Token Account的owner
- 如果Token Account支持 Confidential Transfer extension,需要满足 pending_balance == 0 && available_balance == 0
- 如果Token Account支持 Confidential Transfer Fee extension,需要满足 withheld_amount == 0
- 如果Token Account支持 Transfer Fee extension,需要满足 withheld_amount == 0
!!! Developer Attention !!!
如果合约中需要动态判断Token2022 Token Account是否满足close条件,至少需要满足上述列出来的所有条件才可以执行close,否则Token Account无法在合约中直接close。
Vulnerable Case
下面这个合约的代码片段,会在token account A的amount为0时,将这个token account close掉。
if ctx.accounts.A.amount == 0 {
close_account(CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
CloseAccount {
account: ctx.accounts.A.to_account_info(),
destination: ctx.accounts.B.to_account_info(),
authority: ctx.accounts.B.to_account_info(),
},
signer_seeds,
))?;
}
A是一个Token2022的Token Account,假如A中同时还支持 Transfer Fee等extension,并且A的TransferFeeAmount.withheld_amount 大于0,此时是无法close A这个token Account的,此时会在close_acount中报出错误 AccountHasWithheldTransferFees:"An account can only be closed if its withheld fee balance is zero, harvest fees to the mint and try again"。合约会因为这个错误而无法成功执行这个IX,因为在检查A是否满足close token account的要求时,并未确认 TransferFeeAmount.withheld_amount == 0。TransferFeeAmount中提供了 closable 方法负责做这个检查。 同理,前面提到的其他extension 如果缺少了相应的检查,也会产生类似的问题,在这些extension中也提供了 closable 方法。
2. Mint Account
与Token Account类似,Token2022 中保持了Mint Account的structure layout,并将额外的Extension Data数组拼接在 Mint Account 后,大致的结构示意图如下:

Mint Account 后的可以 Append 的 Extension 包括:
- Non-Transferable Tokens
- Transfer Fees
- Transfer Hook
- Confidential Transfer
- Confidential Transfer Fee
- Mint Close Authority
- Default Account State
- Interest-Bearing Tokens
- Permanent Delegate
- Metadata Pointer
- Metadata
- Group Pointer
- Group
- Group Member Pointer
- Group Member
Create Mint
与Token Account可以在创建后动态添加新的Extension不同,在Token2022中,所有的extension都要在 初始化Mint Account之前先创建好。因为大部分Mint Extension都会与这个Mint的设计哲学以及基础行为紧密相关。
这个代码片段介绍了如何创建一个带Extension的Mint:
// List extensions to be added to the new mint
const extensions = [ExtensionType.TransferFeeConfig];
// Calculate length of mint
const mintLen = getMintLen(extensions);
// Transfer Fee Config Parameters
const decimals = 9;
const feeBasisPoints = 50;
const maxFee = BigInt(5_000);
// Calculate minimum rent for create the new mint account
const mintLamports = await connection.getMinimumBalanceForRentExemption(mintLen);
// Mint Creation IXs
// 1. Create a raw mint account with calculated mintLen and mintLamports
// 2. Create TransferFee Config and append this extension to the mint account
// 3. Initialize Mint
const mintTransaction = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: mint,
space: mintLen,
lamports: mintLamports,
programId: TOKEN_2022_PROGRAM_ID,
}),
createInitializeTransferFeeConfigInstruction(
mint,
transferFeeConfigAuthority.publicKey,
withdrawWithheldAuthority.publicKey,
feeBasisPoints,
maxFee,
TOKEN_2022_PROGRAM_ID
),
createInitializeMintInstruction(mint, decimals, mintAuthority.publicKey, null, TOKEN_2022_PROGRAM_ID)
);
await sendAndConfirmTransaction(connection, mintTransaction, [payer, mintKeypair], undefined);
这些Mint extension之间还存在一些绑定的约束关系,会在initialize_mint中一起检查。
- 如果开启了confidential transfer fee,则一定要开启transfer fee和confidential transfer
- 如果开启了transfer fee和confidential transfer,则一定要开启confidential transfer fee
!!! Developer Attention !!!
Mint account 不像 Token Account,所有支持的extension必须在initialize之前初始化好。当Mint account已经created后,无法再为其添加新的extension。
Close Mint
在SPL Token program中,是无法关闭Mint account的。
但是,在Token2022中,可以通过 MintCloseAuthority extension来关闭一个Mint。
!!! Developer Attention !!!
MintCloseAuthority 要求 Mint.supply == 0
如果一个Mint是处于一个可以被关闭的状态,通常不会造成任何损害,因为此时所有token account都是空的。
但是,若项目中存储了有关mint的任何信息,那么当mint关闭并在同一个address重新创建时,可能会出现不一致。比如该account可能用于完全不同的目的。
如果项目中存储了mint信息,请找到一种方法来重新设计解决方案,使其始终直接使用来自mint的信息。
3. 总结
Token2022的一些Extension只在Mint Account后添加数据,一些Extension只在Token Account添加数据,还有些Extension会同时在Mint Account 和Token Account后同时拼接数据。
在这一节的最后,我们再做了一个简单的总结,将这些Extension以及相应的Account关系对应起来。
| Extension | Extension Data Type | Account Type | |
|---|---|---|---|
| Immutable Owner | ImmutableOwner | Token Account | |
| CPI Guard | CpiGuard | Token Account | |
| Required Memo on Transfer | MemoTransfer | Token Account | |
| Non-Transferable Tokens | NonTransferableAccount | Token Account | |
| Non-Transferable Tokens | NonTransferable | Mint | |
| Transfer Fees | TransferFeeAmount | Token Account | |
| Transfer Fees | TransferFeeConfig | Mint | |
| Transfer Hook | TransferHookAccount | Token Account | |
| Transfer Hook | TransferHook | Mint | |
| Confidential Transfer | ConfidentialTransferAccount | Token Account | |
| Confidential Transfer | ConfidentialTransferMint | Mint | |
| Confidential Transfer Fee | ConfidentialTransferFeeAmount | Token Account | |
| Confidential Transfer Fee | ConfidentialTransferFeeConfig | Mint | |
| Mint Close Authority | MintCloseAuthority | Mint | |
| Default Account State | DefaultAccountState | Mint | |
| Interest-Bearing Tokens | InterestBearingConfig | Mint | |
| Permanent Delegate | PermanentDelegate | Mint | |
| Metadata Pointer | MetadataPointer | Mint | |
| Metadata | TokenMetadata | Mint | |
| Group Pointer | GroupPointer | Mint | |
| Group | TokenGroup | Mint | |
| Group Member Pointer | GroupMemberPointer | Mint | |
| Group Member | TokenGroupMember | Mint |
\
What's the difference between Token and Token2022?
Token2022 作为Token的strict superset,完整地支持了所有的25个Token IX,有完全相同 instruction layout,二者的format 完全一样。
transfer VS transfer_checked
Token2022中,transfer是一个已经deprecated的function
/// Creates a `Transfer` instruction.
#[deprecated(
since = "4.0.0",
note = "please use `transfer_checked` or `transfer_checked_with_fee` instead"
)]
pub fn transfer(
代码注释中给了非常明确的标注,建议使用 transfer_checked 或 transfer_checked_with_fee。
先看看推荐的这两个function的定义:
/// Creates a `TransferChecked` instruction.
#[allow(clippy::too_many_arguments)]
pub fn transfer_checked(
token_program_id: &Pubkey,
source_pubkey: &Pubkey,
mint_pubkey: &Pubkey,
destination_pubkey: &Pubkey,
authority_pubkey: &Pubkey,
signer_pubkeys: &[&Pubkey],
amount: u64,
decimals: u8,
) -> Result<Instruction, ProgramError> {
...
}
/// Create a `TransferCheckedWithFee` instruction
#[allow(clippy::too_many_arguments)]
pub fn transfer_checked_with_fee(
token_program_id: &Pubkey,
source: &Pubkey,
mint: &Pubkey,
destination: &Pubkey,
authority: &Pubkey,
signers: &[&Pubkey],
amount: u64,
decimals: u8,
fee: u64,
) -> Result<Instruction, ProgramError> {
...
}
分别于transfer diff后可以看到:


可以看到
- `transfer_checked` 比 `transfer`多了2个参数:`mint_pubkey` 和 `decimals`
- `transfer_checked_with_fee` 比 `transfer`多了3个参数:`mint` , `decimals` 和
fee
通过SPL transfer IX 的实现,可以发现,如果坚持继续使用 transfer ,可能会在 transfer 的过程中因为Token Account支持 Transfer Hook 或 Transfer Fees 这两个extension而导致Transfer无法完成。
/// Processes a [Transfer](enum.TokenInstruction.html) instruction.
pub fn process_transfer(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
expected_decimals: Option<u8>,
expected_fee: Option<u64>,
) -> ProgramResult {
...
let expected_mint_info = if let Some(expected_decimals) = expected_decimals {
Some((next_account_info(account_info_iter)?, expected_decimals))
} else {
None
};
...
let (fee, maybe_permanent_delegate, maybe_transfer_hook_program_id) =
if let Some((mint_info, expected_decimals)) = expected_mint_info {
...
} else {
// Transfer hook extension exists on the account, but no mint
// was provided to figure out required accounts, abort
if source_account
.get_extension::<TransferHookAccount>()
.is_ok()
{
return Err(TokenError::MintRequiredForTransfer.into());
}
// Transfer fee amount extension exists on the account, but no mint
// was provided to calculate the fee, abort
if source_account
.get_extension_mut::<TransferFeeAmount>()
.is_ok()
{
return Err(TokenError::MintRequiredForTransfer.into());
} else {
(0, None, None)
}
};
...
从代码中可以看到,当调用transfer function时,传入的 expected_decimals 为 None,此时会走入上面代码片段中的else branch。此时如果Token Account 中支持 Transfer Hook 和 Transfer Fees,此时就会直接在transfer中返回 MintRequiredForTransfer Error 而退出。
因此,推荐在 Token2022 中,不要再使用transfer,而是使用 transfer_checked 和 transfer_checked_with_fee 。
\
2个版本的wsol
SPL Token中,WSOL的account id为 So11111111111111111111111111111111111111112 Token2022 中,重新定义了一个新的WSOL,account id为 9pan9bMn5HatX4EJdBwg9VgCa7Uz5HL8N1m5D3NdXejP
如果合约中涉及到需要特殊处理WSOL相关的case,此时则需要注意,是否需要分别处理SPL Token和Token2022中的WSOL。
截止到2024年8月,Token2022中的WSOL目前还没有什么交易量,通常WSOL还是指 SPL Token中的
So11111111111111111111111111111111111111112对于某些defi,sol/wsol 资产具有特殊意义,为了避免多入口歧义,建议将 token2022 的 wsol 地址加入黑名单.
Program ID
当前很多实现中,在涉及到Token Program ID时,都是硬编码了SPL Token Program。但是Token2022的Program ID与SPL不同,因此在这些地方需要注意,避免硬编码。
- SPL Token Program ID:
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA - Token2022 Program ID:
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
以 solana/@spl_token 中的 createAssociatedTokenAccountInstruction 为例。
createAssociatedTokenAccountInstruction(payer, associatedToken, owner, mint, programId?, associatedTokenProgramId?): TransactionInstruction
对于其中的参数programId ,文档如下:
programId: PublicKey = TOKEN_PROGRAM_ID
SPL Token program account
可以看到,这两个参数如果使用默认值,就直接使用了 TOKEN_PROGRAM_ID。
\
How to integrate Token2022 in Anchor's project?
Anchor是现在主流的Solana合约开发框架,其中也添加了对Token2022的支持,包括各种类型以及相关IX的封装,开发者可以直接使用anchor封装的这些类型和方法。
类型
这里总结了各种类型的定义,以及支持的Token Program类型。
| Anchor 类型 | use路径 | Token Program |
|---|---|---|
| TokenAccount | anchor_spl::token_interface::TokenAccount | SPL_TOKEN, SPL_TOKEN_2022 |
| anchor_spl::token::TokenAccount | SPL_TOKEN | |
| Mint | anchor_spl::token_interface::Mint | SPL_TOKEN, SPL_TOKEN_2022 |
| anchor_spl::token::Mint | SPL_TOKEN | |
| TokenInterface | anchor_spl::token_interface::TokenInterface | SPL_TOKEN, SPL_TOKEN_2022 |
| Token2022 | anchor_spl::token_2022::Token2022 | SPL_TOKEN_2022 |
| Token | anchor_spl::token::Token | SPL_TOKEN |
可以看到:
- 路径token_interface下的类型,Token Program和Token2022 Program都支持
- 路径token下的类型,只支持Token Program
- 路径token_2022下的类型,只支持Token2022 Program
开发的过程中需要明确合约是否需要支持Token2022
- 支持Token2022,建议使用路径
anchor_spl::token_interface下的Types - 不支持 Token2022,建议使用路径
anchor_spl::token::Token下的Types
如果不打算支持Token2022,但是却在合约中使用了路径 anchor_spl::token_interface 下的Type,可能会因为这里的二义性而引入一些非预期的问题。
Methods
Anchor中同样封装了 Token和Token2022 的method,底层分别调用了spl_token和spl_token_2022相关的SPL method。SPL Token 和Token 2022有很多同名函数,开发的过程中需要明确是否支持Token2022
- 支持Token2022,建议使用路径
anchor_spl::token_2022下的 Methods - 不支持 Token2022,建议使用路径
anchor_spl::token下的 Methods
[TODO] 承上启下,下篇更精彩
附录
\
\