Skip to main content

Part-II 中文版

\

How to Use Token2022 Extensions in a Secure Way

1. Metadata Pointer / Group Pointer / Group Member Pointer

如果合约 / DApp中需要用到 Metadata / Group / Group Member的数据,为了确认数据的真实性,需要确保Mint 和 Pointer之间的互指关系。

Pointer 能够保证数据的真实有效性。

对于一个Mint A,任何人都可以create Metadata / Group/ Group Member account,在这些account中填入任意构造的虚假数据,并将Mint指向一个合法的Mint A。但是只有Mint A的Pointer指向的 Metadata / Group/ Group Member account,才是Mint A认证过的权威数据。

下面以Metadata和Metadata Pointer的指向关系图为例,展示一个符合预期的Metadata 与 Metadata Pointer关系。 这里有1个Mint A和Mint A的Metadata M,他们之间需要满足

  • Mint.Metadata_Pointer.Metadata_Address == M
  • Metadata.Mint == A



为了方便代币元数据的使用,Token-2022 允许铸币创建者将其代币的元数据直接包含在铸币账户中。此时这个关系图可以简化成这样:


\

\

2. Interest-Bearing Tokens

开发者可以使用 AmountToUiAmount 和 UiAmountToAmount 这两个IX来进行 raw amount 与 ui amount之间的转换。转换的公式如下:

amount => ui_amount

ui\_amount = amount * (\frac{e^{\frac{R_{pre} * (T_{last} - T_{init})}{\frac{31556736}{10000}}} * e^{\frac{R_{current} * (T_{current} - T_{last})}{\frac{31556736}{10000}}}}{10^{decimals}} )

这里需要注意的是:

  • 这个extension是基于timestamp 使用一个固定的公式进行利息计算的,如果开发者在这个mint上预期的利息计算公式与extension中所使用的不一致,或者利息的计算不是以timestamp来作为基本单位(比如基于slot),都不应该使用这个extension。
  • 由于solana network的不稳定(比如阶段性的网络拥塞),可能会导致实际执行时的timestamp出现偏差,累计的利息可能与实际预期的值不一致。

自 2022 年 5月 **Bank Timestamp Correction https://lamport.dev/newsletters/solana-tech-roundup-19 ** 更新后, 除了出块暂时停止导致的 timestamp跳跃式更新外,时钟漂移的情况已经大大缓解。

在确认项目的需求与这个extension相符之后,再谨慎使用这个extension,不要过度依赖IX给出的转化结果。

3. Permanent Delegate

被设置为permanent delegate的 authority拥有非常高的权限,可以直接对任意token account进行transfer或burn任意数量的 mint。

以SPL Token2022的process_transfer为例,这里在验证transfer authority时,如果authority是permanent delegate,即可通过认证。

    pub fn process_transfer(
...
) -> ProgramResult {
...
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(),
)?
}
...
}

注意

无论是作为开发者还是用户,对于带有Permanent Delegate extension的mint,都需要额外关注和控制好delegate的行为,以避免非预期的财产损失。

4. Memo Transfer

用户可以选择为自己的Token Account开启Memo,以便于在收到一笔transfer的同时,能够看到附带的memo。Token2022 program在处理开启了Memo 的Token Account时,强制要求在transfer IX前需要附带一条Memo IX。

这是 SPL Transfer与Memo相关的代码片段,可以看到,当Transfer之前的IX不是memo时,会直接导致Transfer返回错误。

    pub fn process_transfer(
...
) -> ProgramResult {
...
if memo_required(&destination_account) {
check_previous_sibling_instruction_is_memo()?;
}
...
}

注意

作为protocol 开发方,如果项目合约中支持Token2022 的Transfer,就需要考虑是否需要为开启的Memo Transfer的Token Account做单独的支持(在Transfer前加一条Memo IX),否则在Memo enabled Token Account作为Transfer的Destination Account时,Token2022的Transfer无法完成,这可能会影响到整个协议或合约的用户体验。

\

5. Transfer Fees

Token2022的Mint中开启了Transfer Fee后,在每次transfer时,都会将这次转账的fee amount记录在receiver的一个独立的Extension Account中,这部分fee是收款人无法取出的,也不会作为余额体现在收款人的Token Account中。

当mint的 transfer_fee_basis_points 大于0时,在这类mint上转账完成后,receiver实际收到的mint amount是少于transfer IX参数中指定的amount,此时如果在调用transfer的合约中还需要进行额外的记账,开发者需要考虑如何处理transfer fee的这部分记账。如果将这部分fee也算成了receiver实际收到的mint,可能会在不同的场景下,导致 协议/合约/用户 的账户中发生额外的损失。

一个简单的错误案例

先来看一个简单的例子,在这个例子中,由于协议没有考虑Transfer过程中产生的fee,从而导致自己受到了损失:

vault 支持 deposit / withdraw 一个fee rate为20%的Token2022 mint,vault的Token Account中现在有1000的余额。

  • User Alice 通过Deposit(100)存入100
    • vault中通过调用Token2022 Transfer 从 Alice 的Token Account中转入Vault 100
      • User Alice 余额减去100
      • 扣除Transfer fee后,vault实际收到的只有80,此时vault的Token Account余额为1080
    • vault 记账 Alice 存入100
  • User Alice 通过Withdraw(100)取出100
    • vault中确认Alice 存入的余额是大于等于100,记账 Alice 取出100
    • vault中通过调用Token2022 Transfer 从 Vault 的Token Account中转给Alice 100
      • 扣除 Transfer fee后,Alice 实际收到80,Alice的Token Accout 余额增加 80
      • Vault 的Token Account 余额扣除100,此时余额为980。

可以看到在 Alice的 deposit / withdraw 这一组操作之后,Vault 凭空损失了一部分 1000 - 980 = 20。这里由于Vault在记账时没有考虑发生在Transfer过程中的Token2022 Transfer Fee,导致自身损失了20。

一种推荐的fee算法

合约中如果要支持带Transfer Fee的Token2022 Mint,需要谨慎地考虑transfer 过程中fee的扣除对合约内部状态的影响。此时可能会涉及到计算pre fee amount和post fee amount,可以从SPL的实现中找到Transfer Fee的计算代码,这里截取了核心的计算部分 ( 其中满足 0<transferfeebasispoints<100000 < transfer_fee_basis_points < 10000):

    fn ceil_div(numerator: u128, denominator: u128) -> Option<u128> {
numerator
.checked_add(denominator)?
.checked_sub(1)?
.checked_div(denominator)
}

pub fn calculate_fee(&self, pre_fee_amount: u64) -> Option<u64> {
...
let numerator = (pre_fee_amount as u128).checked_mul(transfer_fee_basis_points)?;
let raw_fee = Self::ceil_div(numerator, ONE_IN_BASIS_POINTS)?
.try_into() // guaranteed to be okay
.ok()?;
...
}

pub fn calculate_pre_fee_amount(&self, post_fee_amount: u64) -> Option<u64> {
...
let numerator = (post_fee_amount as u128).checked_mul(ONE_IN_BASIS_POINTS)?;
let denominator = ONE_IN_BASIS_POINTS.checked_sub(transfer_fee_basis_points)?;
let raw_pre_fee_amount = Self::ceil_div(numerator, denominator)?;

if raw_pre_fee_amount.checked_sub(post_fee_amount as u128)? >= maximum_fee as u128 {
post_fee_amount.checked_add(maximum_fee)
} else {
// should return `None` if `pre_fee_amount` overflows
u64::try_from(raw_pre_fee_amount).ok()
}
...
}
 post\_fee\_amount = pre\_fee\_amount - \lfloor \frac{pre\_fee\_amount * transfer\_fee\_basis\_points  + ONE\_IN\_BASIS\_POINTS - 1}{ONE\_IN\_BASIS\_POINTS} \rfloor 


raw\_pre\_fee\_amount = \lfloor\frac{post\_fee\_amount * ONE\_IN\_BASIS\_POINTS + ONE\_IN\_BASIS\_POINTS - transfer\_fee\_basis\_points - 1}{ONE\_IN\_BASIS\_POINTS - transfer\_fee\_basis\_points}\rfloor


但是这里存在一个小问题:由于整数运算过程中的精度损失问题,这里没法保证下列等式始终成立: x=calculate_pre_fee_amount(xcalculate_fee(x))x = calculate\_pre\_fee\_amount(x - calculate\_fee(x))

此时站在合约开发者的视角来看,需要尽量避免自身合约中的reserve资金因为fee计算过程精度的问题而蒙受损失,因此通常需要由用户方来承担由于精度问题产生的surplus。

这里一种推荐的做法是在 calculate_pre_fee_amount 中调整计算raw_pre_fee_amount的算法,将计算的公式改成:

        let raw_pre_fee_amount = numerator
.checked_add(ONE_IN_BASIS_POINTS)?
.checked_sub(1)?
.checked_div(denominator)?;

这样在数学上可以严格保证 xcalculate_pre_fee_amount(xcalculate_fee(x))x \le calculate\_pre\_fee\_amount(x - calculate\_fee(x)) 详细证明见附录1。

计算 pre fee amount时,如果原始的input存在一个max最大值限制,此时计算出来的 pre fee amount还需要与 max input一起做一个min操作:min(pre_fee_amount, max_input)。这样才能保证不会因为反向的计算而导致非预期的资金损失。

符合预期的transfer fee

对于合约 / 用户,如果希望在转账的过程中收取的fee是符合预期的,可以使用Token2022中提供的transfer_checked_with_fee IX,在这个IX中,只有传入了计算正确的费用,转账才会成功,这样就能避免转账过程中出现任何意外。

Transfer fee 生效时间

Token2022中还增加一个延迟更新Fee配置的安全保证,修改后的新的Fee配置不会立刻生效,而是在配置完 2 个 epoch (≈4 days)后才会生效,在此之前仍然会按照 旧的 Fee配置来收取fee。这种延迟更新是对用户的安全保证,以避免频繁地修改fee而导致的用户资金损失。

如果开发过程中涉及到更新Fee 配置,开发者需要意识到新配置生效前,还存在2个epoch的过渡期,仍是用的旧的Fee配置。

可以使用 getTransferFeeConfiggetEpochFee 来获取mint在当前epoch的transferFee。

maximum_fee必须设置

maximum_fee 是每次transfer 收取fee的最大值,如果没有设置,则为默认值0。这样Transfer时等价于没有收Fee

开发过程中需要需要设置或更新 Fee Config,一定记得确认maximum_fee是否设置。

TransferFeeConfig.withheld_amount 不是最新的数据

TransferFeeConfig.withheld_amount 是 Mint 中的数据,这个值和实际已经发生的 transfer fee是不同步的,一般TransferFeeConfig.withheld_amount会小于实际已经收取的所有Transfer fee。

如果要更新数据,需要invoke HarvestWithheldTokensToMint IX从一组TokenAccount的 TransferFeeCAmount.withheld_amount 来手动同步。

注意这个同步不是必须的。

\

6. Transfer Hook

如果Mint 开启了Transfer Hook,则每次在该mint上发生的transfer都会触发一个CPI调用到mint关联的Transfer Hook program上的invoke。

invoke前可能需要提前准备这个hook program 所需要的account,对于复杂的hook程序,这些accounts可能不能满足需要,因此使用该方法 spl_tlv_account_resolution::state::ExtraAccountMetaList::add_to_cpi_instruction,将 account_info_iter 中剩下的 accounts 也按需添加到 CPI 中。

注意

这里开发者能做的是什么??

  • Transfer Hook的调用是在Transfer 结束之前调用的,此时已经完成了Transfer的所有操作(包括Transfer Fee计算)。如果依赖Transfer开始之前的状态,则开发者需要自行注意这一点。
  • Transfer Hook如果依赖Extra Account Meta,需要确保Meta生成的正确性:对生成Meta的account,包括remaining_account,需要做相应的检查,避免因为顺序导致生成了错误的Account Meta。

[TODO]