用Fe语言重写以太坊存款合约
文章作者用Fe语言重写了以太坊存款合约,无需内联汇编或手动内存管理,代码更简洁。与最优化Solidity版本相比,Fe实现字节码更小(运行时代码减少16.9%),部署Gas降低6.5%,每次调用Gas平均节省3.9%。文章详细介绍了Fe的消息模型、事件、存储结构及存款逻辑,并附有完整对比数据。

我用 Fe 重写了以太坊存款合约——也就是每个质押的 ETH 都要经过的合约。没有内联汇编,没有手动内存管理,全是高级代码。它在字节码大小、部署成本和调用 Gas 上依然优于经过最优化编译的 Solidity 版本。
让我们深入了解!
什么是存款合约?
存款合约是以太坊中执行层与共识层之间的桥梁。想要运行验证者的人需要向该合约存入 32 ETH,并提供一些信息,例如验证者的公钥和提款凭证。存款合约维护一个包含所有存款的默克尔树,并生成存款事件,共识层可以监听这些事件并激活相应的验证者。
用 Fe 实现存款合约
DepositMsg 消息
Fe 中的合约遵循基于消息的编程模型。合约并没有可调用的函数,而是接收消息,合约的行为由其处理这些消息的方式定义。
这不仅类似于 EVM 底层的运行方式(消息是交互的基本单元),还为 Fe 带来了更强大的编程模型,我将在未来的文章中探讨这一点。
现在,我们来定义 DepositMsg,它包含实现合约对外功能所需的四个变体:Deposit、SupportsInterface、GetDepositRoot 和 GetDepositCount。每个变体都有一个选择器,用于定义外部如何调用它,以及它包含哪些字段。Deposit 是主要的变体,当用户想要进行存款时会调用它。其他三个变体用于查询合约状态。
msg DepositMsg {
#[selector = sol("deposit(bytes,bytes,bytes,bytes32)")]
Deposit {
pubkey: Bytes,
withdrawal_credentials: Bytes,
signature: Bytes,
deposit_data_root: Bytes32,
},
#[selector = sol("supportsInterface(bytes4)")]
SupportsInterface { interface_id: Bytes4 } -> bool,
#[selector = sol("get_deposit_root()")]
GetDepositRoot -> u256,
#[selector = sol("get_deposit_count()")]
GetDepositCount -> Bytes,
}
注意:选择器属性中的 sol 函数并不是什么神奇的编译器技巧,它实际上是一个在标准库中定义的 const fn,在编译时计算 4 字节选择器。
事件和存储结构体
结构体是 Fe 中定义数据结构的主要方式。与 Rust 一样,你可以通过 impl 块为结构体添加函数,并使用泛型和 trait 创建强大的抽象。结构体也是 Fe 中定义事件和存储布局的常用方式。
要将结构体用作事件,它需要实现 Event trait。幸运的是,这不需要手动完成:用 #[event] 属性标注结构体会让编译器自动实现该 trait。
#[event]
struct DepositEvent {
pubkey: Bytes,
withdrawal_credentials: Bytes,
amount: Bytes,
signature: Bytes,
index: Bytes,
}
与 Solidity 不同,Fe 中的函数不能自动访问全局状态。相反,你必须显式地将 存储效果 传递给每个希望读写存储的函数,并且必须在合约级别声明存储效果。你还需要明确表达意图,说明是只读还是可写。
处理存储的一种常见方法是将连贯的存储片段封装到一个结构体中,如下面的 DepositStore。
struct DepositStore {
branch: [u256; DEPOSIT_CONTRACT_TREE_DEPTH],
deposit_count: u256,
zero_hashes: [u256; DEPOSIT_CONTRACT_TREE_DEPTH],
}
pub contract DepositContract uses (ctx: Ctx, mem: mut RawMem, log: mut Log) {
mut store: DepositStore, // <-- 这里 `DepositStore` 作为可变的存储效果可用
// 合约其余部分省略
}
接受存款
存款合约的主要任务是接受存款并维护存款的默克尔树。Fe 实现是对等价的 Solidity 代码进行逐行翻译,但尽可能使用更高级的抽象。由于 Fe 自带丰富的标准库,我可以从 ssz 导入一些方便的功能,以帮助进行存款合约所需的基于 SSZ 的哈希和序列化。
第一段代码是对输入数据的验证。我还根据输入数据重新计算 deposit_data_root,并检查它是否与用户提供的值匹配。如果输入数据无效,尽早失败并给出清晰的错误信息非常重要,以防止用户因发送格式错误的存款而丢失资金。请注意,我故意将 compute_deposit_data_root 实现为一个自由函数,因为这样在单元测试中使用也很方便。
接下来,我发出包含存款相关信息的 DepositEvent。这样共识层就能监听新存款并激活相应的验证者。
注意,在 Fe 中,事件通过 log 效果发出,DepositEvent 结构体可以直接传递给 emit 函数,无需手动编码。在后台,编译器会生成必要的代码来实现 Event trait,因为该结构体使用了 #[event] 标注。
最后,我用新存款更新默克尔树。由于这涉及更新合约状态,因此需要合约级别声明的 store 效果。
pub contract DepositContract uses (ctx: Ctx, mem: mut RawMem, log: mut Log) {
mut store: DepositStore,
init()
uses (mut store, mut mem)
{
for height in 0 .. DEPOSIT_CONTRACT_TREE_DEPTH - 1 {
let cur: u256 = store.zero_hashes[height]
store.zero_hashes[height + 1] = ssz::hash_pair(left: cur, right: cur)
}
}
recv DepositMsg {
#[payable]
Deposit { pubkey, withdrawal_credentials, signature, deposit_data_root }
uses (mut store, ctx, mut mem, mut log)
{
assert!(pubkey.len == 48, "DepositContract: invalid pubkey length")
assert!(withdrawal_credentials.len == 32, "DepositContract: invalid withdrawal_credentials length")
assert!(signature.len == 96, "DepositContract: invalid signature length")
let value: u256 = ctx.value()
assert!(value >= ether(1), "DepositContract: deposit value too low")
assert!(value % gwei(1) == 0, "DepositContract: deposit value not multiple of gwei")
let deposit_amount: u256 = value / gwei(1)
assert!(deposit_amount <= u64::MASK, "DepositContract: deposit value too high")
// 截断转换匹配 Solidity 的 `uint64(x)`。上面的断言保证信息不会丢失。
let amount_gwei: u64 = deposit_amount.downcast_truncate()
let mut node: u256 = compute_deposit_data_root(
pubkey,
withdrawal_credentials,
signature,
amount_gwei,
)
assert!(
node == deposit_data_root.val,
"DepositContract: reconstructed DepositData does not match supplied deposit_data_root",
)
// 发出 DepositEvent(index 是自增前的 deposit_count)。
log.emit(
DepositEvent {
pubkey,
withdrawal_credentials,
amount: ssz::serialize_u64(amount_gwei),
signature,
index: ssz::serialize_u64(store.deposit_count.downcast_truncate()),
},
)
assert!(store.deposit_count < MAX_DEPOSIT_COUNT, "DepositContract: merkle tree full")
store.deposit_count += 1
let mut size: u256 = store.deposit_count
for height in 0 .. DEPOSIT_CONTRACT_TREE_DEPTH {
if size & 1 == 1 {
store.branch[height] = node
return
}
node = ssz::hash_pair(left: store.branch[height], right: node)
size = size / 2
}
// 不可达:上面的 MAX_DEPOSIT_COUNT 检查保证我们在 DEPOSIT_CONTRACT_TREE_DEPTH 次迭代内通过 `size & 1 == 1` 分支退出。
assert!(false)
}
// 其他消息处理程序省略
}
}
// 从各部分重新构造存款数据的 SSZ 根。在模块级别暴露,以便测试可以预计算有效的 `deposit_data_root` 传入。
#[inline(always)]
pub fn compute_deposit_data_root(
pubkey: Bytes,
withdrawal_credentials: Bytes,
signature: Bytes,
amount_gwei: u64,
) -> u256
uses (mem: mut RawMem)
{
assert!(withdrawal_credentials.len == 32)
(
ssz::hash_tree_root<ssz::ByteVector<48>>(pubkey),
withdrawal_credentials.word_at(0),
ssz::u64_chunk(amount_gwei),
ssz::hash_tree_root<ssz::ByteVector<96>>(signature),
)
.merkleize()
}
其他消息处理程序
剩下的处理程序很简单。GetDepositCount 返回当前存款数量,SupportsInterface 实现标准的 ERC-165 接口发现,两者基本上都是一行代码,你可以在文末链接的完整源码中找到。唯一有趣的是 GetDepositRoot,它计算存款树的当前默克尔根:
GetDepositRoot -> u256 uses (store, mut mem) {
let mut node: u256 = 0
let mut size: u256 = store.deposit_count
for height in 0 .. DEPOSIT_CONTRACT_TREE_DEPTH {
if size & 1 == 1 {
node = ssz::hash_pair(left: store.branch[height], right: node)
} else {
node = ssz::hash_pair(left: node, right: store.zero_hashes[height])
}
size = size / 2
}
// 使用 SSZ mix-in-length 在 `deposit_count` 上最终化。
// MAX_DEPOSIT_COUNT = 2**32 - 1 意味着截断转换永远不会丢失信息。
ssz::mix_in_length(root: node, len: store.deposit_count.downcast_truncate())
}
Gas 费用与字节码大小比较
现在来看数据。我通过一系列测试和基准测试运行了两个实现,结果如下:
bytecode size
path fe-O2 sol sol-IR fe vs sol-IR
-----------------------------------------------------------
init 2562 4819 3082 -520 (-16.9%)
runtime 2478 4445 2844 -366 (-12.9%)
deployment gas
path fe-O2 sol sol-IR fe vs sol-IR
-----------------------------------------------------------
deploy 1290072 1738847 1379411 -89339 (-6.5%)
call gas
path fe-O2 sol sol-IR fe vs sol-IR
-------------------------------------------------------------------------------
supportsInterface(erc165) 21600 21603 21600 +0 (+0.0%)
supportsInterface(deposit) 21600 21641 21600 +0 (+0.0%)
supportsInterface(unknown) 21600 21641 21600 +0 (+0.0%)
get_deposit_root(empty) 102969 117558 109178 -6209 (-5.7%)
get_deposit_count(empty) 23616 24510 24099 -483 (-2.0%)
deposit#0 79719 84626 81089 -1370 (-1.7%)
get_deposit_root(after#0) 102977 117570 109174 -6197 (-5.7%)
get_deposit_count(after#0) 23616 24510 24099 -483 (-2.0%)
deposit#1 65088 70411 66629 -1541 (-2.3%)
get_deposit_root(after#1) 102977 117570 109174 -6197 (-5.7%)
get_deposit_count(after#1) 23616 24510 24099 -483 (-2.0%)
deposit#2 45507 50414 46877 -1370 (-2.9%)
get_deposit_root(after#2) 102985 117582 109170 -6185 (-5.7%)
get_deposit_count(after#2) 23616 24510 24099 -483 (-2.0%)
TOTAL 761486 838656 792487 -31001 (-3.9%)
Fe 实现全面优于经过最优化编译的 Solidity 构建:字节码更小,部署消耗更少的 Gas,并且每次合约交互至少同样便宜,大多数情况下更便宜。约束条件三:✅
结论
以上就是整个合约:高级、可读的 Fe,在大小、部署和每次调用上都击败了经过 Gas 优化的最优化 Solidity 版本。你可以在 GitHub 仓库 中找到完整代码,包括我上面跳过的处理程序。如果你对此产生了兴趣,Fe 文档 是开始使用该语言的最佳起点。我计划在未来的文章中介绍更复杂的合约,我很想听听你的想法,欢迎随时回复。
- 原文链接: x.com/cburgdorf/status/2...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~