Solana: Lamport 转账的隐藏危险
Solana 的 lamport 转账逻辑隐藏着危险的极端情况 —— 从租金豁免怪癖到写入降级陷阱。我们剖析了一个看似简单的智能合约游戏,以揭示向任意账户转账如何悄无声息地失败、破坏你的程序或加冕一个永恒的国王。

简介
在 Solana 上,向任意地址转移 lamports 是否安全?答案可能会让你惊讶。
在这篇文章中,我们将探索一个受 以太之王 启发的看似简单的智能合约游戏。通过它,我们将强调 Solana 账户模型中可能破坏你的程序的微妙陷阱 —— 尤其是在转移 lamports 时。
游戏:SOL 之王
游戏是这样运作的:
- 任何人都可以通过出价至少是前一个出价的 2 倍成为国王。
- 老国王会收到其出价 95% 的报销。
- 剩余的 5% 进入奖金池。
- 如果在位国王在没有被推翻的情况下存活 10 天,他们可以申领整个奖金池。
很简单,对吧?
这是核心逻辑:
#[derive(Accounts)] pub struct ChangeKing<'info> { #[account(mut)] pub throne: Account<'info, Throne>, /// CHECK: old_king gets a 95% refund, so ensure its writable. // CHECK: old_king 获得 95% 的退款,因此请确保它是可写的。 #[account(mut, constraint = old_king.key() == throne.king)] pub old_king: AccountInfo<'info>, /// CHECK: any writable account is allowed as a new king. // CHECK: 任何可写账户都可以作为新国王。 #[account(mut)] pub new_king: AccountInfo<'info>, #[account(mut)] pub payer: Signer<'info>, } #[program] pub mod king_of_the_sol { pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> { // Check that bid_amount is at least 2x last_bid_amount // 检查 bid_amount 至少是 last_bid_amount 的 2 倍 assert!(bid_amount >= ctx.accounts.throne.last_bid_amount * 2); transfer_from_signer( &ctx.accounts.payer, &ctx.accounts.throne.to_account_info(), bid_amount, )?; // Reimburse 95% of the last bid to the old king // 向老国王报销上次出价的 95% let to_reimburse = (ctx.accounts.throne.last_bid_amount * 9500) / 10000; transfer_from_pda( &ctx.accounts.throne.to_account_info(), &ctx.accounts.old_king, to_reimburse, )?; // Set new king // 设置新国王 ctx.accounts.throne.king = ctx.accounts.new_king.key(); ctx.accounts.throne.last_bid_amount = bid_amount; ctx.accounts.throne.last_time = Clock::get()?.unix_timestamp as u64; Ok(()) } } 请注意此注释:
any writable account is allowed as a new king. 任何可写账户都可以作为新国王。
...我们的假设正确吗?
潜伏在下方的 Bug
Bug 1:租金豁免陷阱
在 Solana 上,所有账户必须维持一个 lamports 的最低余额,以保持租金豁免。具体来说,一个账户可以处于以下两种状态之一:
- 未初始化:
lamports = 0 - 已初始化:
lamports >= 租金豁免阈值
这种租金模型的存在是为了防止对验证者的低成本 DoS 攻击。关键的想法是,即使是没有数据的账户(即,零长度数据缓冲区)仍然会消耗链上资源;具体来说,是账户元数据,如其公钥、所有者或 lamport 余额。该元数据必须由验证者持久存储,而这种存储并非免费。
因此,Solana 上的“持久状态”不仅意味着你程序的数据 —— 它还包括基本账户结构本身。即使 data.len() == 0 的账户也必须满足最低租金阈值才能保持活跃,并避免被运行时垃圾回收。
这是在运行时级别强制执行的,相关的逻辑可以在这里找到。
fn transition_allowed(&self, pre_rent_state: &RentState, post_rent_state: &RentState) -> bool { match post_rent_state { RentState::Uninitialized | RentState::RentExempt => true, RentState::RentPaying { data_size: post_data_size, lamports: post_lamports, } => { match pre_rent_state { RentState::Uninitialized | RentState::RentExempt => false, RentState::RentPaying { data_size: pre_data_size, lamports: pre_lamports, } => { // Cannot remain RentPaying if resized or credited. // 如果调整大小或贷记,则不能保持 RentPaying 状态。 post_data_size == pre_data_size && post_lamports <= pre_lamports } } } } } 你可以使用 CLI 检查零数据账户的租金豁免阈值:
solana rent 0 Rent-exempt minimum: 0.00089088 SOL 租金豁免最小值:0.00089088 SOL 修复 1:仅在租金豁免时报销
我们不想给不公平的国王捐任何东西!因此,让我们更新我们的程序,仅在老国王在转账后获得租金豁免时才进行报销:
let to_reimburse = (ctx.accounts.throne.last_bid_amount * 9500) / 10000; +let rent = Rent::get()?; +let balance_after = ctx.accounts.old_king.lamports() + to_reimburse; +if rent.is_exempt(balance_after, ctx.accounts.old_king.data_len()) { transfer_from_pda( &ctx.accounts.throne.to_account_info(), &ctx.accounts.old_king, to_reimburse, )?; +} 但是,租金豁免是导致 lamport 转账失败的唯一原因吗?不完全是。
Bug 2:可写但不可触碰 —— set_lamports 失败
让我们看一下 BorrowedAccount::set_lamports。
/// Overwrites the number of lamports of this account (transaction wide) // 覆盖此账户的 lamports 数量(整个事务范围内) #[cfg(not(target_os = "solana"))] pub fn set_lamports(&mut self, lamports: u64) -> Result<(), InstructionError> { // An account not owned by the program cannot have its balance decrease // 不属于程序的账户不能减少其余额 if !self.is_owned_by_current_program() && lamports < self.get_lamports() { return Err(InstructionError::ExternalAccountLamportSpend); } // The balance of read-only may not change // 只读账户的余额可能不会更改 if !self.is_writable() { return Err(InstructionError::ReadonlyLamportChange); } // The balance of executable accounts may not change // 可执行账户的余额可能不会更改 if self.is_executable_internal() { return Err(InstructionError::ExecutableLamportChange); } // don't touch the account if the lamports do not change // 如果 lamports 没有更改,则不要动账户 if self.get_lamports() == lamports { return Ok(()); } self.touch()?; self.account.set_lamports(lamports); Ok(()) } /// Feature gating to remove `is_executable` flag related checks // 功能门控以删除与“is_executable”标志相关的检查 #[cfg(not(target_os = "solana"))] #[inline] fn is_executable_internal(&self) -> bool { !self .transaction_context .remove_accounts_executable_flag_checks && self.account.executable() } 事实证明:即使是可写的、租金豁免的账户仍然会拒绝 lamport 转账。
具体来说,可执行账户无法接收或发送 lamports —— 运行时将其视为不可变的。
边栏:什么是可执行标志?
executable 标志是一种遗留机制,用于标记持有程序代码的账户。历史上,具有此标志的账户被假定为包含不可变的 BPF 字节码,或者是内置程序的代理,因此将其视为只读的以提高性能是有意义的。
随着 可升级 BPF 加载器的引入,此行为变得有问题。使用了一种解决方法来维持与现有运行时逻辑的兼容性。包含 bpf 字节码的程序数据被拆分为一个单独的账户 ProgramData,程序账户现在仅包含指向 ProgramData 账户的地址:
Program { /// Address of the ProgramData account. // ProgramData 账户的地址。 programdata_address: Pubkey, }, ProgramData { /// Slot that the program was last modified. // 程序上次修改的Slot。 slot: u64, /// Address of the Program's upgrade authority. // 程序的升级权限的地址。 upgrade_authority_address: Option<Pubkey>, // The raw program data follows this serialized structure in the // 账户数据中,原始程序数据遵循此序列化结构。 account's data. }, 最终,可执行标志将按照 SIMD-0162 中的提议完全删除。原因很简单:账户的所有者及其内容足以确定它是否是有效的程序 —— 可执行标志是多余的。
此更改也是支持新的 loader-v4 的硬性要求。与依赖于单独的 ProgramData 代理账户的可升级加载器不同,loader-v4 将所有程序数据直接存储在程序账户本身中。
因此,在部署后无法修改账户的大小,或者在不违反 ExecutableLamportChange 限制的情况下,无法从可升级加载器迁移到 loader-v4。
修复 2:拒绝程序账户
为了避免这个陷阱,让我们明确跳过任何可执行账户:
pub fn can_transfer_lamports(account: &AccountInfo, lamports: u64) -> Result<bool> { fn is_program(account: &AccountInfo) -> bool { account.executable } let rent = Rent::get()?; let balance_after = account.lamports() + lamports; Ok(account.is_writable && rent.is_exempt(balance_after, account.data_len()) && !is_program(account)) } 现在我们安全了...对吧?
Bug 3:写入降级陷阱
在 Solana 上,在事务中作为可写传递的账户可以被静默降级为只读。此行为发生在消息清理期间 —— 甚至在你的程序运行之前。
让我们逐步了解旧消息的逻辑(注意:相同的规则适用于 MessageV0,但旧消息更容易理解):
// https://github.com/anza-xyz/solana-sdk/blob/master/message/src/sanitized.rs#L39-L55 impl LegacyMessage<'_> { pub fn new(message: legacy::Message, reserved_account_keys: &HashSet<Pubkey>) -> Self { let is_writable_account_cache = message .account_keys .iter() .enumerate() .map(|(i, _key)| { message.is_writable_index(i) && !reserved_account_keys.contains(&message.account_keys[i]) && !message.demote_program_id(i) }) .collect::<Vec<_>>(); Self { message: Cow::Owned(message), is_writable_account_cache, } } } // https://github.com/anza-xyz/solana-sdk/blob/master/message/src/legacy.rs#L642-L644 pub fn demote_program_id(&self, i: usize) -> bool { self.is_key_called_as_program(i) && !self.is_upgradeable_loader_present() } 如你所见,写入降级主要有两个原因:
- 该账户出现在保留账户列表中
- 在事务中没有可升级加载器的情况下,该账户被作为程序调用。
第二种情况通常由先前实现的可执行检查覆盖。
然而,第一种情况更加危险 —— 它可能会在没有任何明显原因的情况下静默地破坏你的程序逻辑。让我们深入研究一下。
保留账户列表
Solana 运行时维护一个保留账户列表,其中包括具有特殊语义的地址 —— 例如内置程序、预编译程序和 sysvar。
这些账户最初可能表现得像普通账户。但是,一旦它们在功能门激活后变为保留账户,运行时将自动将它们降级为只读,即使事务将它们标记为可写。
// https://github.com/anza-xyz/agave/blob/0e6d9bf8c81cd94dfdedb500af4ac17328cf7a43/runtime/src/bank.rs#L6469-L6474 // Update active set of reserved account keys which are not allowed to be write locked // 更新不允许被写锁定的保留账户密钥活动集 self.reserved_account_keys = { let mut reserved_keys = ReservedAccountKeys::clone(&self.reserved_account_keys); reserved_keys.update_active_set(&self.feature_set); Arc::new(reserved_keys) }; 后果:静默失败和损坏的程序
当约束程序为可写时,例如,使用 Anchor,此行为尤其危险,使用 account(mut) 约束非常常见:
#[derive(Accounts)] pub struct ChangeKing<'info> { #[account(mut)] pub throne: Account<'info, Throne>, #[account(mut, constraint = old_king.key() == throne.king)] pub old_king: AccountInfo<'info>, #[account(mut)] pub new_king: AccountInfo<'info>, #[account(mut)] pub payer: Signer<'info>, } 这工作正常 —— 直到有一天,old_king 被静默降级。突然,#[account(mut)] 约束失败,你的程序损坏。即使你在事务中传递一个可写账户,运行时也单方面决定覆盖它。
真实示例:使用 secp256r1_program 进行写入降级
这是一个在主网上发生的写入降级陷阱的具体示例 —— 涉及 secp256r1_program,这是一个在功能标志后面进行门控的预编译程序:
ReservedAccount::new_pending( secp256r1_program::id(), feature_set::enable_secp256r1_precompile::id(), ) 在激活 enable_secp256r1_precompile 功能之前,此账户的行为类似于任何普通账户。你可以将 secp256r1_program::id() 分配为合约中的国王。
但是,一旦该功能被打开,运行时会静默地将其标记为只读,从而阻止任何未来的写入。结果,secp256r1_program::id() 成为永恒的国王,没有人可以推翻它。
修复 3:防止写入降级陷阱
好的,让我们尝试修复这个又一个极端情况 —— 并希望结束这本书。
尝试 1:阻止已知的保留账户
一种幼稚的解决方案是拒绝任何已知的保留账户,例如:
pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> { + assert!(ctx.accounts.new_king.key() != secp256r1_program::id()); 这在短期内有效,但无法扩展 —— 你无法预测 ReservedAccount 列表的所有未来添加。一旦引入新的保留账户,你的程序将再次变得脆弱。
尝试 2:使用 PDA Vault
更具前瞻性的修复方法是完全避免向任意账户转移 lamports。
一种干净的方法是将退款 lamports 存储在由你的程序拥有的 PDA vault 中。这可以防止你的逻辑依赖于你没有完全控制权的账户,并避免任何写入降级或未来账户限制的风险。
最后的想法
在 Solana 上转移 lamports 并非总是那么简单,并且存在潜在的风险。单独的账户约束不足以确保安全,尤其是在处理运行时特定的极端情况时。
在以下条件下,我们可以安全地将 lamports 转移到账户:
- 它不可执行。
- 转账后,其余额仍保持租金豁免。
- 它不是保留账户。
此问题并非纯粹是理论上的;它已经影响了现实世界的程序。最近,Jito 通过错误赏金报告了一个重要案例,这可能导致不正确的提示付款。
- 原文链接: osec.io/blog/2025-05-14-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~