Solana程序中安全操作的成本

  • accretion
  • 发布于 2025-08-26 23:16
  • 阅读 14

本文分析了Solana开发中常见的安全操作的计算成本,并提供了优化策略。文章指出,基础安全检查成本极低,不应省略;PDA推导的成本较高,可以通过缓存bump seed来优化;I80F48除法运算的成本很高,应尽量避免;checked math 的开销几乎可以忽略不计。通过理解这些成本,开发者可以在Solana的计算限制内构建安全的程序。

每个 Solana 开发者都面临着同样的困境:我能负担得起多少安全性?

在每个交易 200,000 个计算单元的软限制下(可以升级到 140 万),每次安全检查都会带来成本。 你应该验证那个 PDA 吗? 检查账户是否免租? 验证每个签名者? 每一个决定都会蚕食你的计算预算。

我们经常看到开发者为了节省计算单元而跳过必要的安全检查,因此我们决定确定每个安全操作的具体成本。 结果可能会让你大吃一惊。

如果时间紧迫,请跳转到基准测试结果

为什么这很重要

在 Solana 上,计算单元不仅仅是一个技术细节。 它们通过优先费用和交易成功率直接影响你的用户。 了解安全操作的确切成本有助于你就是否必要的检查以及哪些优化值得实施做出明智的决定。

本指南为常见的安全操作提供了具体的数字,以及可以为每个交易节省数千个计算单元的优化策略。

在先前工作的基础上构建

这项研究扩展了 Solana Labs 在计算优化和他们的 CU 优化存储库 方面所做的出色工作。 我们复制了他们的结果,并将分析扩展到包括更多的安全模式和定点算术。 此外,如果你喜欢视频内容,我们强烈推荐 SolAndy 关于 CU 优化的两部分系列这里这里

有关包括不变量、断言和最佳实践在内的全面安全模式,请参阅我们的100 个 Solana 技巧

我们的方法

所有基准测试都使用 sol_log_compute_units() 测量实际的链上计算单元。 我们专注于每个 Solana 开发者都使用的操作:公钥比较、PDA 验证、签名者检查和数学运算——包括成本高昂但必要的定点算术。

了解 Solana 的计算预算

在深入研究基准测试之前,至关重要的是了解我们正在使用的约束。 Agave 验证器(Solana 的验证器实现)强制执行 execution_budget.rs 中定义的严格限制。

以下是影响每个 Solana 程序的关键常量:

常量 描述
MAX_INSTRUCTION_STACK_DEPTH 5 最大指令堆栈深度。 这是事务期间可能发生的指令的最大嵌套
MAX_CALL_DEPTH 64 最大调用深度。 这是程序中可能发生的 SBF 到 SBF 调用的最大嵌套
STACK_FRAME_SIZE 4,096 一个 SBF 堆栈帧的大小
MAX_COMPUTE_UNIT_LIMIT 1,400,000 最大计算单元限制
DEFAULT_HEAP_COST 8 大约 0.5us/页,其中页为 32K; 假设大约 15CU/us,则默认堆页成本 = 0.5 * 15 ~= 8CU/页
DEFAULT_INSTRUCTION_COMPUTE_UNIT_LIMIT 200,000 默认指令计算单元限制
MAX_BUILTIN_ALLOCATION_COMPUTE_UNIT_LIMIT 3,000 SIMD-170 定义了分配给任何尚未迁移到 sBPF 程序的内置程序指令的最大 CU
MAX_HEAP_FRAME_BYTES 262,144 最大堆帧字节数 (256 * 1024)
MIN_HEAP_FRAME_BYTES HEAP_LENGTH 最小堆帧字节数
MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES 67,108,864 事务可以加载的总账户数据限制为 64MiB,以避免今天在 Mainnet-beta 中破坏任何人。 可以通过 set_loaded_accounts_data_size_limit 指令设置
字段 默认值 描述
compute_unit_limit 1,400,000 事务或单个指令允许消耗的计算单元数。 计算单元由程序执行、他们使用的资源等消耗...
max_instruction_stack_depth 5 最大程序指令调用堆栈深度。 调用堆栈深度从事务指令的 1 开始,每次程序调用指令时堆栈深度都会递增,而程序返回时堆栈深度都会递减
max_instruction_trace_length 64 每个事务的最大跨程序调用和指令数
sha256_max_slices 20,000 每次系统调用哈希的最大切片数
max_call_depth 64 SBF 到 BPF 的最大调用深度
stack_frame_size 4,096 堆栈帧的大小(以字节为单位),必须与 LLVM SBF 后端中指定的大小匹配
max_cpi_instruction_size 1,280 最大跨程序调用指令大小(IPv6 最小 MTU 大小)
heap_size HEAP_LENGTH 程序堆区域大小,默认值:solana_program_entrypoint::HEAP_LENGTH
log_64_units 100 log_u64 调用消耗的计算单元数
create_program_address_units 1,500 create_program_address 调用消耗的计算单元数
invoke_units 1,000 invoke 调用消耗的计算单元数(不包括被调用程序产生的成本)
sha256_base_cost 85 调用 SHA256 消耗的基本计算单元数
sha256_byte_cost 1 SHA256 消耗的增量单元数(基于字节数)
log_pubkey_units 100 记录 Pubkey 消耗的计算单元数
cpi_bytes_per_unit 250 跨程序调用期间收取的每个计算单元的账户数据字节数(在 200,000 个单元时约为 50MB)
sysvar_base_cost 100 获取 sysvar 消耗的基本计算单元数
secp256k1_recover_cost 25,000 调用 secp256k1_recover 消耗的计算单元数
syscall_base_cost 100 执行没有任何工作的系统调用消耗的计算单元数
curve25519_edwards_validate_ point_cost 159 验证 curve25519 edwards 点消耗的计算单元数
curve25519_edwards_add_cost 473 添加两个 curve25519 edwards 点消耗的计算单元数
curve25519_edwards_subtract_cost 475 减去两个 curve25519 edwards 点消耗的计算单元数
curve25519_edwards_multiply_cost 2,177 乘以一个 curve25519 edwards 点消耗的计算单元数
curve25519_edwards_msm_base_cost 2,273 用于 edwards 点的多标量乘法 (msm) 消耗的计算单元数。 总成本计算为 msm_base_cost + (length - 1) * msm_incremental_cost
curve25519_edwards_msm_ incremental_cost 758 用于 edwards 点的多标量乘法 (msm) 消耗的计算单元数。 总成本计算为 msm_base_cost + (length - 1) * msm_incremental_cost
curve25519_ristretto_validate_ point_cost 169 验证 curve25519 ristretto 点消耗的计算单元数
curve25519_ristretto_add_cost 521 添加两个 curve25519 ristretto 点消耗的计算单元数
curve25519_ristretto_subtract_cost 519 减去两个 curve25519 ristretto 点消耗的计算单元数
curve25519_ristretto_multiply_cost 2,208 乘以一个 curve25519 ristretto 点消耗的计算单元数
curve25519_ristretto_msm_base_cost 2,303 用于 ristretto 点的多标量乘法 (msm) 消耗的计算单元数。 总成本计算为 msm_base_cost + (length - 1) * msm_incremental_cost
curve25519_ristretto_msm_ incremental_cost 788 用于 ristretto 点的多标量乘法 (msm) 消耗的计算单元数。 总成本计算为 msm_base_cost + (length - 1) * msm_incremental_cost
heap_cost 8 默认值之上每增加 32k 堆消耗的计算单元数(在 15 个单位/us 时,每 32k 约为 .5 us 四舍五入)
mem_op_base_cost 10 内存操作系统调用基本成本
alt_bn128_addition_cost 334 调用 alt_bn128_addition 消耗的计算单元数
alt_bn128_multiplication_cost 3,840 调用 alt_bn128_multiplication 消耗的计算单元数
alt_bn128_pairing_one_pair_ cost_first 36,364 总成本将为 alt_bn128_pairing_one_pair_cost_first + alt_bn128_pairing_one_pair_cost_other * (num_elems - 1)
alt_bn128_pairing_one_pair_ cost_other 12,121 总成本将为 alt_bn128_pairing_one_pair_cost_first + alt_bn128_pairing_one_pair_cost_other * (num_elems - 1)
big_modular_exponentiation_ base_cost 190 大整数模幂运算基本成本
big_modular_exponentiation_ cost_divisor 2 大整数模幂运算成本除数。 模幂运算成本计算为 input_length / big_modular_exponentiation_ cost_divisor + big_modular_exponentiation_base_cost
poseidon_cost_coefficient_a 61 二次函数的系数 a,它决定了对于给定数量的输入,调用 poseidon 系统调用所消耗的计算单元数
poseidon_cost_coefficient_c 542 二次函数的系数 c,它决定了对于给定数量的输入,调用 poseidon 系统调用所消耗的计算单元数
get_remaining_compute_units_cost 100 访问剩余计算单元所消耗的计算单元数
alt_bn128_g1_compress 30 调用 alt_bn128_g1_compress 消耗的计算单元数
alt_bn128_g1_decompress 398 调用 alt_bn128_g1_decompress 消耗的计算单元数
alt_bn128_g2_compress 86 调用 alt_bn128_g2_compress 消耗的计算单元数
alt_bn128_g2_decompress 13,610 调用 alt_bn128_g2_decompress 消耗的计算单元数

基准测试结果

让我们从你在每个指令中使用的操作开始。 我们多次测量每个操作,并减去基线成本以获得纯计算单元消耗。

基本安全检查:好消息

大多数基本安全检查都非常便宜。 如此便宜以至于你不应该跳过它们。 每个操作的成本约为 30 个计算单元,这在 200,000 个 CU 预算中可以忽略不计。

重要提示:这些数字假设原始类型比较。 自定义 PartialEq 实现可能会消耗任意计算单元,因此请始终注意你要比较的内容。 有关鉴别器检查和类型安全的更多信息,请参阅我们关于隐藏 IDL 指令的文章。

  1. 布尔/签名者检查 - 成本:约 30 CU
// 检查账户是否为签名者
if !account.is_signer {
       //
       return Err(ProgramError::MissingRequiredSignature);
}
  1. 字段访问检查 - 成本:约 30 CU
// 公钥相等性检查
if account.owner != expected_owner {
       return Err(ProgramError::IncorrectProgramId);
}
  1. 大小和余额检查 - 成本:约 30 CU
// 检查账户大小或 lamports
if account.data_len() != EXPECTED_SIZE {
       return Err(ProgramError::InvalidAccountData);
}
  1. 账户数据验证 - 成本:约 30 CU
// 检查鉴别器(anchor 中的前 8 个字节)
if &account_data[..8] != DISCRIMINATOR {
       return Err(ProgramError::InvalidAccountData);
}
  1. 租金豁免检查 - 成本:约 300 CU
let rent = Rent::get()?;
if !rent.is_exempt(account.lamports(), account_data_len) {
       return Err(ProgramError::AccountNotRentExempt);
}

比基本检查贵十倍,但仍然可以承受。 这包括获取租金系统变量并执行计算。

  1. 错误处理 - 成本:约 400 CU
       fn error_test(a: Pubkey, b: Pubkey) -> Result<()> {
           if a != b {
               solana_program::log::sol_log_compute_units();
               return Err(ErrorCode::AccountDidNotDeserialize.into());
           }
           Ok(())
       }

       // 在此函数结束时,将消耗 414 CU
       fn error_test_with_propagation(a: Pubkey, b: Pubkey) -> Result<()> {
           error_test(a, b)?;
           Ok(())
       }

       // 在此函数结束时,将消耗 415 CU
       fn error_test_without_propagation(a: Pubkey, b: Pubkey) -> Result<()> {
           if let Err(e) = error_test(a, b) {
               return Err(e);
           }
           Ok(())
       }

对于错误路径。 此开销仅在你尝试从错误中恢复时才重要。 如果你只是传播错误以使交易失败,则 CU 成本无关紧要,因为交易无论如何都会中止。

  1. 检查的数学运算
检查与未检查操作比较(基线调整)

注意:所有值都已通过减去调用 sol_log_compute_unitsstd::hint::black_box 的 104 CU 的基线成本进行调整

u64 操作
操作 检查 未检查 差异 开销 %
加法 3 1 2 200%
减法 13 1 12 1200%
乘法 8 2 6 300%
除法 10 7 3 43%
u128 操作
操作 检查 未检查 差异 开销 %
加法 7 5 2 40%
减法 11 5 6 120%
乘法 11 4 7 175%
除法 6 4 2 50%
I80F48 操作
操作 检查 未检查 差异 开销 %
加法 7 5 2 40%
减法 11 5 6 120%
乘法 11 4 3 75%
除法 2,285 2,281 4 0.2%

u64

rust Decimal
操作 检查 未检查 差异 开销 %
加法 124 117 7 6%
减法 126 117 9 8%
乘法 84 78 6 8%
除法 669 662 7 1%
关于检查的数学运算的惊人真相

与普遍的看法相反,使用 Rust 的 checked_* 方法对发布版本的性能影响最小。 安全性几乎是免费的:

  • u64 操作:检查操作的开销为 2-12 CU
  • u128 操作:开销为 2-7 CU,乘法和除法的基线成本远高于 u64

真正的冲击来自定点算术。 虽然 I80F48 加法和减法与 u128 操作相当,但除法是灾难性的,为 2,285 CU。

rust decimal crate 在基本操作中表现不佳,加法、减法和乘法的开销超过 100 CU。 除法比 I80F48 除法便宜约 3-4 倍。

有关更安全的数学模式和最佳实践,请参阅我们的 100 个 Solana 技巧中的技巧 #13

昂贵的操作:PDA 和密码学

现在我们来看看可能决定你的计算预算的操作。 这些密码学操作对于安全性至关重要,但成本很高。

  1. PDA 推导(每次检查 bump 约 1,500 个 CU)
let (pda, bump) = Pubkey::find_program_address(
       &[b"seed", user.key.as_ref()],
       program_id
);

PDA 推导的成本为每次检查 bump 1500 CU。 bump 不会导致生成的地址偏离曲线的概率为 50%。 这意味着低 bump 值的可能性呈指数级下降。

PDA 的安全优化策略

通过明智的设计选择,你可以在这里节省数千个计算单元:

  1. 使用存储的 bump 重新计算

    通用版本

      pub struct PDAAccount {
          // <...> 理想情况下,账户鉴别器,可以通过 #[account] 实现
          pub bump: u8, // <--- Bump 对于存储很重要,因为你需要它来 `create_program_address`
          // <...> 此账户的其他字段
      }
    
      pub fn verify_authority_raw(
          pda_info: &AccountInfo,
          seeds: &[&[u8]],
      ) -> Result<()> {
          // 加载 PDA 账户以获取 bump
          let pda_data = pda_info.try_borrow_data()?;
          let pda_account = PDAAccount::try_deserialize(&mut &pda_data[..])?; // 跳过鉴别器
    
          // 使用存储的 bump 验证 PDA
          let pda_key = Pubkey::create_program_address(
              &[seeds, &[&[pda_account.bump]]].concat(),
              &crate::ID,
          ).map_err(|_| ProgramError::InvalidSeeds)?;
    
          if pda_info.key() != pda_key {
              return ProgramError::InvalidArgument;
          }
    
          Ok(())
      }
    

    Anchor 版本

      // 在初始化时存储 bump!
      pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
          ctx.accounts.pda_account.bump = ctx.bumps.pda_account;
          Ok(())
      }
    
      #[derive(Accounts)]
      pub struct Initialize<'info> {
          #[account(\
              init,\
              seeds = [b"pda"],\
              bump // <--- 我们可以通过此属性访问 `ctx.bumps.pda_account`
          )]
          pub pda_account: Account<'info, PdaAccount>,
      }
    
      #[derive(Accounts)]
      pub struct UsePda<'info> {
          #[account(\
              seeds = [b"pda"],\
              bump = pda_account.bump  // 告诉 anchor 使用存储的 bump 进行验证,从而大大提高性能并降低 CU 成本
          )]
          pub pda_account: Account<'info, PdaAccount>,
      }
    
      #[account]
      pub struct PdaAccount {
          // `#[account]` 为我们处理鉴别器
          pub bump: u8,
      }
    

此方法将运行时成本转换为存储成本。 通过保存利用一个字节的数据,我们节省了大量的计算成本。

  1. 关联Token地址 (ATA) 推导(每次 bump 约 1,500 个 CU)
let ata = spl_associated_token_address::get_associated_token_address(wallet, mint);
ATA 的优化策略

在内部,get_associated_token_address 调用此函数:

/// 仅供内部使用。
##[doc(hidden)]
pub fn get_associated_token_address_and_bump_seed_internal(
       wallet_address: &Pubkey,
       token_mint_address: &Pubkey,
       program_id: &Pubkey, // <--- 这是 AToken 程序 "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
       token_program_id: &Pubkey, // <--- 这是Token程序 "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
) -> (Pubkey, u8) {
       Pubkey::find_program_address(
           &[\
               &wallet_address.to_bytes(),\
               &token_program_id.to_bytes(),\
               &token_mint_address.to_bytes(),\
           ],
           program_id,
       )
}

这意味着我们可以像针对特定 mint/authority 的 PDA 优化一样对其进行优化。 一个例子可能是这样的:

通用版本
pub struct PDAAccount {
       // <...> 理想情况下,账户鉴别器,可以通过 #[account] 实现
       pub ata_bump: u8,
       // <...> 此账户的其他字段
}

pub fn derive_ata_with_cached_bump(
       authority: &Pubkey,
       mint: &Pubkey,
       ata_bump: u8,
) -> Result<Pubkey> {
       // 使用存储的 bump 验证 PDA
       let ata_key = Pubkey::create_program_address(
           &[\
               authority.as_ref(),\
               spl_token::ID.as_ref(),\
               mint.as_ref(),\
               &[ata_bump]\
           ],
           &spl_associated_token_account::ID,
       ).map_err(|_| ProgramError::InvalidSeeds)?;

       Ok(ata_key)
}
Anchor 版本
// 在初始化时存储 bump!
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
       ctx.accounts.pda_account.bump = ctx.bumps.pda_account;
       ctx.accounts.pda_account.ata_bump = ctx.bumps.ata;
       Ok(())
}

##[derive(Accounts)]
pub struct Initialize<'info> {
       #[account]
       pub authority: UncheckedAccount<'info>
       #[account]
       pub mint: Account<'info, Mint>,
       #[account(\
           seeds = [\
               authority.key().as_ref(),\
               token::ID.as_ref(),\
               mint.key().as_ref(),\
           ],\
           bump, // 我们可以访问 `ctx.bumps.ata`\
           seeds::program = associated_token::ID\
       )]
       pub ata: Account<'info, TokenAccount>,

       pub pda_account: Account<'info, PdaAccount>,
       #[account(\
           init,\
           seeds = [b"pda"],\
           bump // <--- 我们可以通过此属性访问 `ctx.bumps.pda_account`
       )]
       pub pda_account: Account<'info, PdaAccount>,
}

##[derive(Accounts)]
pub struct UsePda<'info> {
       #[account]
       pub authority: UncheckedAccount<'info>
       #[account]
       pub mint: Account<'info, Mint>,
       #[account(\
           seeds = [\
               authority.key().as_ref(),\
               token::ID.as_ref(),\
               mint.key().as_ref(),\
           ],\
           bump = pda_account.ata_bump,\
           seeds::program = associated_token::ID\
       )]
       pub ata: Account<'info, TokenAccount>,

       pub pda_account: Account<'info, PdaAccount>,
       #[account(\
           init,\
           seeds = [b"pda"],\
           bump = pda_account.bump,\
       )]
       pub pda_account: Account<'info, PdaAccount>,
}

##[account]
pub struct PdaAccount {
       // `#[account]` 为我们处理鉴别器
       pub bump: u8,
       pub ata_bump: u8,
}

主要收获

  1. 永远不要跳过基本安全检查 - 每个 30 CU,它们本质上是免费的
  2. 缓存 PDA 推导 - 通过存储 bump 种子来节省每次推导 1,500 CU,甚至通过存储密钥来节省更多
  3. 避免 I80F48 除法 - 每次操作 2,749 CU,考虑替代方案。 如果你出于性能原因而重构远离 I80F48,请确保你正确测试回归
  4. 检查的数学运算实际上是免费的 - 在任何地方都使用它来获得安全性,而无需担心性能

底线

安全性不必昂贵。 通过智能优化(如存储 bump 种子、缓存推导和选择正确的数学运算),你可以构建安全程序,这些程序可以舒适地适应 Solana 的计算限制。

选择不是在安全性和性能之间。 而是了解成本并进行智能优化。


  • 原文链接: [accretion.xyz/blog/cost-...]()
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
accretion
accretion
江湖只有他的大名,没有他的介绍。