本文深入探讨了Solana中lamport转移的潜在危险,通过一个“King of the SOL”的智能合约游戏案例,揭示了rent-exemption、可写账户的lamport转移失败以及write-demotion等问题可能导致程序出错甚至瘫痪。文章强调了在Solana上转移lamport并非总是直接的,需要考虑多种runtime-specific的特殊情况。
Solana 的 lamport 转账逻辑隐藏着危险的极端情况 —— 从租金豁免怪癖到写入降级陷阱。我们剖析了一个看似简单的智能合约游戏,以揭示向任意账户转账如何悄无声息地失败、破坏你的程序或加冕一个永恒的国王。
在 Solana 上,向任意地址转移 lamports 是否安全?答案可能会让你惊讶。
在这篇文章中,我们将探索一个受 以太之王 启发的看似简单的智能合约游戏。通过它,我们将强调 Solana 账户模型中可能破坏你的程序的微妙陷阱 —— 尤其是在转移 lamports 时。
游戏是这样运作的:
很简单,对吧?
这是核心逻辑:
#[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. 任何可写账户都可以作为新国王。
...我们的假设正确吗?
在 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
我们不想给不公平的国王捐任何东西!因此,让我们更新我们的程序,仅在老国王在转账后获得租金豁免时才进行报销:
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 转账失败的唯一原因吗?不完全是。
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。
为了避免这个陷阱,让我们明确跳过任何可执行账户:
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))
}
现在我们安全了...对吧?
在 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()
成为永恒的国王,没有人可以推翻它。
好的,让我们尝试修复这个又一个极端情况 —— 并希望结束这本书。
一种幼稚的解决方案是拒绝任何已知的保留账户,例如:
pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> {
+ assert!(ctx.accounts.new_king.key() != secp256r1_program::id());
这在短期内有效,但无法扩展 —— 你无法预测 ReservedAccount
列表的所有未来添加。一旦引入新的保留账户,你的程序将再次变得脆弱。
更具前瞻性的修复方法是完全避免向任意账户转移 lamports。
一种干净的方法是将退款 lamports 存储在由你的程序拥有的 PDA vault 中。这可以防止你的逻辑依赖于你没有完全控制权的账户,并避免任何写入降级或未来账户限制的风险。
在 Solana 上转移 lamports 并非总是那么简单,并且存在潜在的风险。单独的账户约束不足以确保安全,尤其是在处理运行时特定的极端情况时。
在以下条件下,我们可以安全地将 lamports 转移到账户:
此问题并非纯粹是理论上的;它已经影响了现实世界的程序。最近,Jito 通过错误赏金报告了一个重要案例,这可能导致不正确的提示付款。
- 原文链接: osec.io/blog/2025-05-14-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!