TL;DR
Solana的交易机制并非表面看起来那么简单,从签名验证到状态提交的每个环节都藏着细节魔鬼;本文同时复盘了一起流动性质押协议的算术漏洞——修复一个”用户多付费”的问题,反而因双重舍入引入了更隐蔽的”未抵押代币”漏洞,教训极为深刻;理解舍入不变量、在金融逻辑中保持算术一致性,是每一个Solana开发者必须刻进骨子里的认知。
一、Solana交易是什么?从结构到本质
1.1 Transaction的解剖刀
要理解Solana,首先得搞清楚Transaction到底是什么。很多新手以为它是一条指令,错了——它本质上是一个签名数组 + 消息体的组合:
pub struct Transaction {
signatures: Vec<Signature>, // 64字节签名数组
message: Message, // 实际要执行什么
}
Transaction总大小硬限制为1232字节,这包括了所有签名和消息体。这个数字不是拍脑袋来的,它是UDP数据包的安全最大值,也是Solana能实现高吞吐的网络基础之一。
消息体里不装原始指令,而是装一个Message——它定义了:哪些指令要跑、需要哪些账户、以及用哪个最近的blockhash将交易锚定到当前链状态。v0 Message和Address Lookup Tables的出现,让开发者能引用远超过最初约35个账户限制的账户,这是Solana持续演进的关键一笔。
1.2 签名的数学:Ed25519如何避免循环依赖
Solana使用Ed25519签名算法,每个64字节签名都是某个 signer 账户用自己的私钥对Message(而非Transaction本身)进行签名后生成的。这里有个精妙的设计:签名是对Message签的,不是对Transaction签的——因为Transaction包含Signature数组,如果对Transaction本身签名就形成了循环依赖:你需要签名来验证Transaction,但签名本身又是Transaction的一部分。
第一个签名永远来自fee-payer,这也是你在区块浏览器上查询交易时看到的主签名。签名数组中的每一个签名,都对应Message里标记为signer的账户——如果少了一个,验证直接失败。
二、交易费用:基础费用 + 优先费的秘密
2.1 费用的双重结构
Solana的费用模型分为两部分:基础费用(base fee)和优先费(priority fee)。基础费用是每个交易都必须支付的”入场费”,而优先费则基于计算预算(Compute Units, CU)来浮动——你愿意付更多CU,就能让验证者优先处理你的交易。
Solana对每个指令设置了20万CU的默认限制,每个交易最多140万CU。如果你写的程序逻辑复杂、需要的计算量大自然就要付出更多优先费。这是 Solana vs Ethereum EVM 模型的一个关键差异:Solidity按gas计费,而Solana按CU计费,后者对开发者写高效代码的激励机制更加直接。
2.2 为什么Blockhash会过期?
每个Transaction都包含一个recent blockhash,它将交易”锚定”到链的特定时间点。Blockhash的有效期约为150个区块(约1分多钟)。这个设计是为了防止”重放攻击”——如果交易永久有效,恶意者可以无限期地重放已签名的交易。
一旦blockhash过期,交易就会被网络拒绝。如果你用过Solana钱包签名后长时间没提交,可能会遇到”Transaction too old”的报错——就是这个原因。
三、交易生命周期:验证者眼中的完整路径
3.1 五步走:从签名到原子提交
当一个Transaction被提交到Solana网络,验证者会经历以下五个阶段:
- 签名验证:并行验证所有签名,Ed25519硬件加速在此发挥威力。任何签名失败,交易直接丢弃。
- 账户加载与锁定:加载Transaction引用的所有账户,同时锁定(lock)这些账户——这决定了后续哪些指令可以并行执行。
- 指令执行:按顺序执行Message中的每条指令。Solana的Sealevel运行时能并行执行不共享账户的指令,这是其高吞吐量的核心技术。
- 状态验证:检查所有写账户的结果是否有效。
- 原子提交:整个Transaction要么全部成功,要么全部回滚——没有部分成功这回事。
3.2 为什么Simulation有时和真实执行结果不同?
开发者常用simulateTransaction来调试,但它有时会产生与真实执行不同的结果。原因在于:Simulation使用的是当前状态的快照,而不包含Transaction自身的签名验证副作用。如果交易本身会修改签名的账户状态(比如一个程序会更新签名者的某个字段),Simulation就会漏掉这个效果。这是调试Solana程序时的一个著名陷阱,踩过的开发者无不印象深刻。
四、真实案例:Stake Pool里的双重舍入漏洞
4.1 问题起源:用户被多收了两道钱
某知名Solana流动性质押协议从主流实现分叉而来,用户将SOL存入共享池后收到LP代币代表按比例所有权。提取时,用户销毁LP代币并取回质押SOL加累积奖励——看似简单,但漏洞就藏在提现逻辑里。
初始流程:用户指定要赎回的LP代币数量 → 扣除提现手续费得到实际销毁量 → 将销毁量按当前兑换率转换为SOL(取整,朝有利于协议的方向)→ 协议从池中提取对应SOL并销毁LP代币。
审计发现的第一个问题是:当用户通过新路径提取stake时,协议会创建一个PDA stake account临时持有用户提取的stake。每个Solana账户必须持有最低余额(rent)才能存在,而这个PDA需要注入stake_rent lamports。
关键Bug:无论谁支付这笔rent,协议都按包含rent的完整withdraw_lamports来销毁LP代币。但用户实际只收到withdraw_lamports – stake_rent(split_lamports)。当用户自己支付rent时,他们不仅直接转出了SOL为rent买单,协议还把这部分rent也当作他们”收到的stake”一样又销毁了他们对应的LP份额——用户被收了两次。
4.2 修复引入的新漏洞:双重舍入
审计团队给出了修复建议:如果用户是payer,就只销毁相当于split_lamports的LP份额。开发团队接受了这个建议并实施修复——但修复本身引入了更隐蔽的bug。
修复逻辑是:取split_lamports,然后反向通过calc_lp_tokens_for_deposit将其转换回LP代币数量,再销毁这个数量。问题出在两次连续的向下取整:
- 第一次取整发生在LP代币→SOL的兑换计算中(计算用户应得SOL时)
- 第二次取整发生在SOL→LP代币的反向计算中(计算应销毁多少LP时)
每次取整都是向下取整(floor),方向总是对协议有利。两次取整叠加后,实际销毁的LP代币数量少于应有的数量。这直接导致:流通中的LP代币总量大于池中实际质押SOL对应的LP份额,产生了未抵押的代币——这些代币稀释了池中所有合法LP持有人的价值。
4.3 正确修复:回到原始逻辑
最终解决方案出人意料:恢复原始销毁逻辑,但改为由协议储备支付PDA的rent。这样:用户支付的rent成本被消除,原始的LP销毁逻辑(基于withdraw_lamports)就能正常工作,不再需要反向计算从而避免双重舍入。
这个案例的教训极其深刻:金融逻辑中的舍入不变量(rounding invariants)必须被当作不可破坏的契约。任何涉及取整的数学运算,只要方向有利于某一方,连续多次取整的叠加效应就可能将”微小的偏差”放大成真实的财务损失。
五、给开发者的血泪建议
5.1 模糊测试和数值模拟是必修课
这个漏洞的特别之处在于:它不涉及明显的逻辑错误或边界条件判断失误,而是纯粹的算术累积误差。只有在大量随机输入下才能观测到偏差。这提示我们:涉及代币数量、分摊计算的任何程序,都要引入模糊测试(fuzzing)和数值边界模拟。
5.2 理解Solana的并行执行模型
Sealevel是Solana区别于其他公链的核心竞争力。它能并行执行不共享账户状态的指令——这要求开发者从”顺序执行”的思维中跳出来,主动设计账户隔离方案。很多Solana程序性能差,不是语言问题,是账户设计的架构问题。
5.3 交易模拟不等于真实执行
Simulation只能作为初筛,不能作为正确性证明。重要交易必须上测试网验证。
六、结语
Solana的技术深度远超大多数人的预期。表面上看它是一个”快”的链,但”快”背后的工程复杂度、无数细节陷阱,才是真正区分普通开发者和顶尖工程师的分水岭。Stake Pool的这个漏洞案例,完美印证了Web3安全的一条铁律:修复问题时引入的新问题,往往比原始问题更隐蔽、更危险。
保持对算术的敬畏,对不确定性的警惕,才是走这条路的正确姿势。
FAQ:常见问题解答
Q1:Solana的Transaction大小为什么限制在1232字节?
A:1232字节是UDP数据包的安全最大值。Solana选择UDP而非TCP来实现交易广播和复制,这个设计决策直接影响了个交易大小上限。换句话说,这个限制是网络层设计的历史遗留,也是Solana能在全球数万个验证者之间实现高吞吐网络通信的基础之一。
Q2:为什么Solana能实现并行执行,而Ethereum只能串行?
A:关键在于账户模型。Solana的Sealevel运行时在执行前会分析Transaction涉及的所有账户,如果两个Transaction读/写的账户集合完全不重叠,它们就可以并行在不同核心上执行。Ethereum的EVM则是全局状态模型,所有交易共享同一个状态空间,无法并行。代价是Solana开发者需要主动设计账户结构来支持并行,而Ethereum开发者基本不需要考虑这个。
Q3:Stake Pool漏洞中的”双重舍入”具体是怎么计算的?
A:简化示例:假设1 LP = 10 SOL(汇率)。用户提现100 LP理应获得1000 SOL。第一次取整(LP→SOL)后实际算得999 SOL(向下取整)。协议再用999 SOL反向计算应销毁多少LP,得到99.9 LP,再向下取整得99 LP。结果:用户拿到了999 SOL,但只销毁了99 LP,比应有的100 LP少了1个——这1个LP就是”未抵押”的,会稀释整个池子。
Q4:普通用户如何判断自己使用的Solana协议是否安全?
A:主要看三点:①项目是否经过知名审计机构(如OtterSec、Trail of Bits、Neodyme)的审计报告;②审计报告是否有后续修复确认;③协议代码是否开源。对于Stake Pool这类涉及大量算术逻辑的协议,开源+第三方审计是最基本的安全门槛。
