Token-2022
Context Diff
Token Account Length
Token-2022 的 token account length不再是固定的,由开启的 extensions 个数决定。
原有 IX 兼容性
在 SPL-Token 上的原有IX 接口均在 Token-2022 中存在,但是为了适配某些 extension ,对某些接口进行了重写(增加了针对 extension 的内部hook)。
例如原 Transfer ix,如果在 token-2022 的 token account 开启了 TransferHookAccount / TransferFeeAmount ,则 data 中必须传入 expected_decimals,account中也必须传入对应的 mint account,否则转账会直接失败。或者直接使用原 TransferChecked ix。同时新的 TransferCheckedWithFee ix 只是封装了老的,经过重写的 process_transfer 接口。
Update: 注意 token-2022 中的 TokenInstruction::Transfer 指令,
#[allow(deprecated)]
TokenInstruction::Transfer
已被标记为 deprecated,调用它同样需要标记 #[allow(deprecated)],但是此指令传入的 expected_decimals 和 mint 一定是 None,因此处理开启了 TransferHookAccount / TransferFeeAmount 的 token会直接失败,必须使用 TransferChecked ix。
Native Mint
Token-2022 有一个新的 native mint id,是和原 SPL Token 中的 WSol So11111111111111111111111111111111111111112 区分开来的(实际上没人用这个新的):
solana_program::declare_id!("9pan9bMn5HatX4EJdBwg9VgCa7Uz5HL8N1m5D3NdXejP");
这是个由 token 2022 program id 派生的 pda ,seed 是 "native-mint" , bump 255.
New Instructions
InitializeMintCloseAuthority {
...
},
TransferFeeExtension(TransferFeeInstruction),
ConfidentialTransferExtension,
DefaultAccountStateExtension,
Reallocate {
/// New extension types to include in the reallocated account
extension_types: Vec<ExtensionType>,
},
MemoTransferExtension,
CreateNativeMint,
InitializeNonTransferableMint,
InterestBearingMintExtension,
CpiGuardExtension,
InitializePermanentDelegate {
...
},
TransferHookExtension,
ConfidentialTransferFeeExtension,
WithdrawExcessLamports,
MetadataPointerExtension,
GroupPointerExtension,
GroupMemberPointerExtension,
首先关注下除 extensions 外的 new IXs:
CreateNativeMint
只在部署的时候调用一次,再调用会不成功,用于创建 token-2022 的 native mint account。
WithdrawExcessLamports
把误发送到 Token Account / Mint / Multisig 地址上的 lamports 转移到其他地址。Its permissioned。 会校验 token account owner / mint_authority / Multisig owners(需要满足全部签名个数)。
Extension
Mint extensions currently include:
- confidential transfers
- transfer fees
- closing mint
- interest-bearing tokens
- non-transferable tokens
- permanent delegate
- transfer hook
- metadata pointer
- metadata
Account extensions currently include:
- memo required on incoming transfers
- immutable ownership
- default account state
- CPI guard
只关注安全性相关的 extension。
Confidential Transfers
[TODO]
加密传输 和 ZK 相关实现
https://spl.solana.com/confidential-token/deep-dive/overview
Transfer Fees
struct TransferFeeConfig & TransferFee
Mint 中的 transfer fee 结构
pub struct TransferFeeConfig {
/// Optional authority to set the fee
pub transfer_fee_config_authority: OptionalNonZeroPubkey,
/// Withdraw from mint instructions must be signed by this key
pub withdraw_withheld_authority: OptionalNonZeroPubkey,
/// Withheld transfer fee tokens that have been moved to the mint for
/// withdrawal
pub withheld_amount: PodU64,
/// Older transfer fee, used if the current epoch < new_transfer_fee.epoch
pub older_transfer_fee: TransferFee,
/// Newer transfer fee, used if the current epoch >= new_transfer_fee.epoch
pub newer_transfer_fee: TransferFee,
}
pub struct TransferFee {
/// First epoch where the transfer fee takes effect
pub epoch: PodU64, // Epoch,
/// Maximum fee assessed on transfers, expressed as an amount of tokens
pub maximum_fee: PodU64,
/// Amount of transfer collected as fees, expressed as basis points of the
/// transfer amount, ie. increments of 0.01%
pub transfer_fee_basis_points: PodU16,
}
Note:
- 更新设置时,需要指定特定的 生效 epoch,生效之前仍然会按照 older_transfer_fee 中的配置分发fee,这种延迟更新是对用户的安全保证,以避免瞬时的 fee 更新。关于延迟更新的安全保证在
SetTransferFeeix 的process_set_transfer_fee接口中有固定的 2 个 epoch (≈4 days)的硬编码。https://github.com/solana-labs/solana-program-library/blob/9ddfe54cc051759f1c619aecf7ba31d93f28d846/token/program-2022/src/extension/transfer_fee/processor.rs#L101-L102
// set two epochs ahead to avoid rug pulls at the end of an epoch
let newer_fee_start_epoch = epoch.saturating_add(2);
- maximum_fee 是每次transfer 收取fee的最大值,必须设置,为0的话等效于不收费。
- TransferFeeConfig.withheld_amount 是 Mint 中的数据,这个值和实际已经发生的 transfer fee是不同步的。需要调用
HarvestWithheldTokensToMintix 手动同步。同步不是必须的。
struct TransferFeeAmount
Token Account tlv_data 中存储的结构
pub struct TransferFeeAmount {
/// Amount withheld during transfers, to be harvested to the mint
pub withheld_amount: PodU64,
}
只有一个属性 withheld_amount,用于存储作为 transfer 的 destination account 时收取的transfer fee,每次transfer 会累积。
ix TransferCheckedWithFee
复用了重写后的 process_transfer 接口,限制了 expected_fee 和 expected_decimals 必须传入 Some。
ix HarvestWithheldTokensToMint
将多个 Token Accounts 的 TransferFeeAmount.withheld_amount 转移到 Token Mint 的 TransferFeeConfig.withheld_amount 中。
参数接受多个 token account。
ix WithdrawWithheldTokensFromMint
把 Token Mint 的 TransferFeeConfig.withheld_amount 转移到指定的 token account 的 amount 中。
ix WithdrawWithheldTokensFromAccounts
直接把多个 Token Accounts 的 TransferFeeAmount.withheld_amount 转移到指定的 token account 的 amount 中。
参数接受多个 token account。
Permanent Delegate
pub struct PermanentDelegate {
/// Optional permanent delegate for transferring or burning tokens
pub delegate: OptionalNonZeroPubkey,
}
保存在 Mint 中,对于所有token account的token amount都可以管理(transfer / burn,但不能直接mint):
例如 process_transfer 中的校验:
match (source_account.base.delegate, maybe_permanent_delegate) {
(_, Some(ref delegate)) if cmp_pubkeys(authority_info.key, delegate) => {
Self::validate_owner(
program_id,
delegate,
authority_info,
authority_info_data_len,
account_info_iter.as_slice(),
)?
}
InterestBearingMintExtension
计息代币的 scaled amount 仅仅是对底层 amount(实际存储的data)的装饰,所以如果其他 program要使用此功能,必须使用 Token-2022 的 UiAmountToAmount / AmountToUiAmount ix 来手动处理 scaled amount。所有标准接口都不依赖此 scaled amount。
CPI Guard
[TODO] 好像没有啥特别要注意的。。。作为安全措施更多的是限制?
https://spl.solana.com/token-2022/extensions#cpi-guard
DefaultAccountState
在 Mint 中通过 process_initialize_default_account_state 设置默认的 state,其实除了正常状态,只有一个 Frozen 状态可选,检查如下:
- 不能默认是
Uninitialized状态。
fn check_valid_default_state(state: AccountState) -> ProgramResult {
match state {
AccountState::Uninitialized => Err(TokenError::InvalidState.into()),
_ => Ok(()),
}
}
- 默认是 Frozen 则必须设置 freeze_authority,用于 default account解锁,也就是说,如果 freeze_authority 是一个 pda authority,那么用户可以通过链上交互来解锁 new token account。
Reallocate
某些扩展可以由用户手动在已初始化的token account上追加(开启),这时候就需要增加 account space。用于下列扩展的扩容:
ExtensionType::ImmutableOwner
| ExtensionType::TransferFeeAmount
| ExtensionType::ConfidentialTransferAccount
| ExtensionType::MemoTransfer
| ExtensionType::NonTransferableAccount
| ExtensionType::TransferHookAccount
| ExtensionType::CpiGuard
| ExtensionType::ConfidentialTransferFeeAmount
[TODO] 按照文档:https://spl.solana.com/token-2022/extensions#reallocate
CLI 会在开启以上扩展时自动调用 Reallocate,问题是 SDK 会不会也封装了这种行为?那么会不会隐式的产生额外的 rent 而被开发者忽略?
TransferHookExtension
文档需要查阅github而不是spl官网:https://github.com/solana-labs/solana-program-library/blob/9ddfe54cc051759f1c619aecf7ba31d93f28d846/docs/src/transfer-hook-interface
Hook Program 可以实现三个接口,其中 Execute 是必须的,只有这个接口会通过 SPL Token-2022 CPI 调用。
ix Execute
The
Executeinstruction is required by any program who wishes to implement the interface, and this is the instruction in which custom transfer functionality will live.
Discriminator: First 8 bytes of the hash of the string literal"spl-transfer-hook-interface:execute"- Data:
amount: u64- The transfer amount- Accounts:
- 1
[]: Source token account- 2
[]: Mint- 3
[]: Destination token account- 4
[]: Source token account authority- 5
[]: Validation accountnnumber of additional accounts, written into the validation account
非常强大而危险的特性,我们直接看 process_transfer 如何处理的:https://github.com/solana-labs/solana-program-library/blob/9ddfe54cc051759f1c619aecf7ba31d93f28d846/token/program-2022/src/processor.rs#L494-L519
if let Some(program_id) = maybe_transfer_hook_program_id {
if let Some((mint_info, _)) = expected_mint_info {
// set transferring flags
transfer_hook::set_transferring(&mut source_account)?;
transfer_hook::set_transferring(&mut destination_account)?;
// must drop these to avoid the double-borrow during CPI
drop(source_account_data);
drop(destination_account_data);
spl_transfer_hook_interface::onchain::invoke_execute(
&program_id,
source_account_info.clone(),
mint_info.clone(),
destination_account_info.clone(),
authority_info.clone(),
account_info_iter.as_slice(),
amount,
)?;
// unset transferring flag
transfer_hook::unset_transferring(source_account_info)?;
transfer_hook::unset_transferring(destination_account_info)?;
} else {
return Err(TokenError::MintRequiredForTransfer.into());
}
}
- 此过程发生在 process_transfer 的最末尾,所有标准的/其他extensions的状态更新都已完成。
set transferring flags会在 token account 内设置一个类似"重入防御"的 flag 以方便 hook_program 查询现在的调用状态。- 重点是
invoke_execute中到底传递了哪些签名。
spl_transfer_hook_interface::onchain::invoke_execute
首先把标准 transfer接口的accounts 添加到 CPI 的 instruction accounts meta 中:
let data = TransferHookInstruction::Execute { amount }.pack();
let accounts = vec![
AccountMeta::new_readonly(*source_pubkey, false),
AccountMeta::new_readonly(*mint_pubkey, false),
AccountMeta::new_readonly(*destination_pubkey, false),
AccountMeta::new_readonly(*authority_pubkey, false),
AccountMeta::new_readonly(*validate_state_pubkey, false),
];
Note:
- 这些accounts都以 readonly 传入;
- 不管 CPI 传入的 account info vec 带不带 is_signer / is_writable flag,接收的instruction都以 AccountMeta 解码。
因此,这些标准接口的 accounts 都是只读的。
对于复杂的hook程序,这些accounts可能不能满足需要,因此使用该方法 spl_tlv_account_resolution::state::ExtraAccountMetaList::add_to_cpi_instruction,将 account_info_iter 中剩下的 accounts 也按需添加到 CPI 中。
Note:
剩余 accounts 是使用 account_info_iter.as_slice() 传入的,此事该迭代器已经把标准接口的accounts消费了,因此不包含这些accounts,除非主动传入重复的 accounts。
add_to_cpi_instruction的主要逻辑是解码 Mint Account 派生的 validation account 中的配置(ExtraAccountMeta 列表),来从剩余的 account_info_iter 中按需添加 account meta & account info 到 CPI。关于这部分查看github文档:https://github.com/solana-labs/solana-program-library/blob/9ddfe54cc051759f1c619aecf7ba31d93f28d846/docs/src/transfer-hook-interface/configuring-extra-accounts.md
需要注意的是在查找accounts之后,将accounts添加到 CPI instruction 之前,有一步关键的安全措施https://github.com/solana-labs/solana-program-library/blob/9ddfe54cc051759f1c619aecf7ba31d93f28d846/libraries/tlv-account-resolution/src/state.rs#L357
de_escalate_account_meta(&mut meta, &cpi_instruction.accounts);
/// De-escalate an account meta if necessary
fn de_escalate_account_meta(account_meta: &mut AccountMeta, account_metas: &[AccountMeta]) {
...
// If `Some`, then the account was found somewhere in the instruction
if let Some((is_signer, is_writable)) = maybe_highest_privileges {
if !is_signer && is_signer != account_meta.is_signer {
// Existing account is *NOT* a signer already, but the CPI
// wants it to be, so de-escalate to not be a signer
account_meta.is_signer = false;
}
if !is_writable && is_writable != account_meta.is_writable {
// Existing account is *NOT* writable already, but the CPI
// wants it to be, so de-escalate to not be writable
account_meta.is_writable = false;
}
}
这步检查保证了 已经在 cpi_instruction 的 account_metas 中的 is_writable / is_signer 权限不会被提升,这包括了两类accounts:
- 标准 transfer 接口所需要的 accounts:这使得外部 hook 程序不能直接劫持用户签名。
- 在 validation account 中之前加载的 accounts:这要求如果出现重复的 accounts, signer / writable的 accounts必须被首次遍历,否则权限将被覆盖为 false。这避免了权限的歧义。
[TODO] 如何绕过 direct self recursion 限制以破坏某些transfer时默认的 invariants
无法通过 Hook 直接破坏 spl token transfer 前后的不变量,因为solana禁止重入,我们无法从 Hook program再跳回 Token-2022,但是这或许开放了一个复杂的攻击面:当dapp program cpi token-2022 token transfer时,我们可能可以破坏其外部依赖的其他 program 的状态,以破坏默认的 invariants。
Associated Token Account 兼容性
When processes CreateAssociatedTokenAccount instruction,所有的 CPI instruction 都是由 spl_token_2022 构造。但是调用的 program id可以是 spl token 而不是token 2022 。所以 spl token 对于这些 ATA 相关的接口实现了一些兼容性升级。
GetAccountDataSize
ATA 需要先获取init token account需要alloc的内存空间大小:
let account_len = get_account_len(
spl_token_mint_info,
spl_token_program_info,
&[ExtensionType::ImmutableOwner],
)?;
/// Determines the required initial data length for a new token account based on
/// the extensions initialized on the Mint
pub fn get_account_len<'a>(
mint: &AccountInfo<'a>,
spl_token_program: &AccountInfo<'a>,
extension_types: &[ExtensionType],
) -> Result<usize, ProgramError> {
SPL Token GetAccountDataSize ix会直接忽略传入的 data(extension_types),而返回固定的account长度。
InitializeImmutableOwner
Token 2022 扩展: https://spl.solana.com/token-2022/extensions#immutable-owner
Token account owners may reassign ownership to any other address. This is useful in many situations, but it can also create security vulnerabilities.
For example, the addresses for Associated Token Accounts are derived based on the owner and the mint, making it easy to find the "right" token account for an owner. If the account owner has reassigned ownership of their associated token account, then applications may derive the address for that account and use it, not knowing that it does not belong to the owner anymore.
To avoid this issue, Token-2022 includes the ImmutableOwner extension, which makes it impossible to reassign ownership of an account. The Associated Token Account program always uses this extension when creating accounts.
ATA 默认会在 init token account时调用此 InitializeImmutableOwner IX,因此 SPL-Token 中也实现了一个对应兼容性接口。但是由于 SPL-Token 并不支持任何扩展,因此此接口只会校验 token account是否已初始化。
if account.is_initialized() {
return Err(TokenError::AlreadyInUse.into());
}
Reference
- https://spl.solana.com/token-2022
- https://github.com/solana-labs/solana-program-library/blob/9ddfe54cc051759f1c619aecf7ba31d93f28d846/docs/src/transfer-hook-interface/
\