本文分析了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 验证、签名者检查和数学运算——包括成本高昂但必要的定点算术。
在深入研究基准测试之前,至关重要的是了解我们正在使用的约束。 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 指令的文章。
// 检查账户是否为签名者
if !account.is_signer {
//
return Err(ProgramError::MissingRequiredSignature);
}
// 公钥相等性检查
if account.owner != expected_owner {
return Err(ProgramError::IncorrectProgramId);
}
// 检查账户大小或 lamports
if account.data_len() != EXPECTED_SIZE {
return Err(ProgramError::InvalidAccountData);
}
// 检查鉴别器(anchor 中的前 8 个字节)
if &account_data[..8] != DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
let rent = Rent::get()?;
if !rent.is_exempt(account.lamports(), account_data_len) {
return Err(ProgramError::AccountNotRentExempt);
}
比基本检查贵十倍,但仍然可以承受。 这包括获取租金系统变量并执行计算。
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 成本无关紧要,因为交易无论如何都会中止。
注意:所有值都已通过减去调用 sol_log_compute_units
和 std::hint::black_box
的 104 CU 的基线成本进行调整
操作 | 检查 | 未检查 | 差异 | 开销 % |
---|---|---|---|---|
加法 | 3 | 1 | 2 | 200% |
减法 | 13 | 1 | 12 | 1200% |
乘法 | 8 | 2 | 6 | 300% |
除法 | 10 | 7 | 3 | 43% |
操作 | 检查 | 未检查 | 差异 | 开销 % |
---|---|---|---|---|
加法 | 7 | 5 | 2 | 40% |
减法 | 11 | 5 | 6 | 120% |
乘法 | 11 | 4 | 7 | 175% |
除法 | 6 | 4 | 2 | 50% |
操作 | 检查 | 未检查 | 差异 | 开销 % |
---|---|---|---|---|
加法 | 7 | 5 | 2 | 40% |
减法 | 11 | 5 | 6 | 120% |
乘法 | 11 | 4 | 3 | 75% |
除法 | 2,285 | 2,281 | 4 | 0.2% |
操作 | 检查 | 未检查 | 差异 | 开销 % |
---|---|---|---|---|
加法 | 124 | 117 | 7 | 6% |
减法 | 126 | 117 | 9 | 8% |
乘法 | 84 | 78 | 6 | 8% |
除法 | 669 | 662 | 7 | 1% |
与普遍的看法相反,使用 Rust 的 checked_*
方法对发布版本的性能影响最小。 安全性几乎是免费的:
真正的冲击来自定点算术。 虽然 I80F48
加法和减法与 u128 操作相当,但除法是灾难性的,为 2,285 CU。
rust decimal crate 在基本操作中表现不佳,加法、减法和乘法的开销超过 100 CU。 除法比 I80F48
除法便宜约 3-4 倍。
有关更安全的数学模式和最佳实践,请参阅我们的 100 个 Solana 技巧中的技巧 #13。
现在我们来看看可能决定你的计算预算的操作。 这些密码学操作对于安全性至关重要,但成本很高。
let (pda, bump) = Pubkey::find_program_address(
&[b"seed", user.key.as_ref()],
program_id
);
PDA 推导的成本为每次检查 bump 1500 CU。 bump 不会导致生成的地址偏离曲线的概率为 50%。 这意味着低 bump 值的可能性呈指数级下降。
通过明智的设计选择,你可以在这里节省数千个计算单元:
使用存储的 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(())
}
// 在初始化时存储 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,
}
此方法将运行时成本转换为存储成本。 通过保存利用一个字节的数据,我们节省了大量的计算成本。
let ata = spl_associated_token_address::get_associated_token_address(wallet, mint);
在内部,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)
}
// 在初始化时存储 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,
}
安全性不必昂贵。 通过智能优化(如存储 bump 种子、缓存推导和选择正确的数学运算),你可以构建安全程序,这些程序可以舒适地适应 Solana 的计算限制。
选择不是在安全性和性能之间。 而是了解成本并进行智能优化。
- 原文链接: [accretion.xyz/blog/cost-...]()
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!