Skip to content
Fe 26.2 is not production-ready. This is an initial release of a new compiler. Learn more

Deposit Contract

This chapter presents a complete, real-world contract: a Fe reimplementation of the Ethereum 2.0 deposit contract (mainnet 0x00000000219ab540356cBB839Cbe05303d7705Fa).

It is a shape-for-shape port of the Solidity reference. It demonstrates patterns that go beyond a typical token: dynamic bytes message inputs with exact length validation, an incremental Merkle tree built on the std::evm::ssz Merkleization primitives, SSZ serialization, the RawMem effect, ERC-165 interface detection, and the #[payable] handler attribute.

// Fe reimplementation of the ETH 2.0 Deposit Contract.
//
// Dynamic `bytes` inputs arrive as `std::abi::Bytes`, and the runtime validates
// their lengths (48, 32, 96) exactly like Solidity's `require` checks. The
// branchless Merkle-tree algorithm and SHA-256 construction match the original
// contract bit-for-bit, built on top of the `std::evm::ssz` Merkleization
// primitives.
use std::evm::{
ether,
gwei,
ssz,
wei,
Ctx,
Evm,
Log,
Merkleize,
RawMem,
}
use std::abi::{
Bytes,
Bytes4,
Bytes32,
bytes_from_word,
bytes_from_words,
bytes_from_words_prefix,
sol,
}
use core::num::{IntDowncast, IntWord}
const DEPOSIT_CONTRACT_TREE_DEPTH: usize = 32
// NOTE: this also ensures `deposit_count` will fit into 64-bits
const MAX_DEPOSIT_COUNT: u256 = 2 ** (DEPOSIT_CONTRACT_TREE_DEPTH as u256) - 1
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,
}
#[event]
struct DepositEvent {
pubkey: Bytes,
withdrawal_credentials: Bytes,
amount: Bytes,
signature: Bytes,
index: Bytes,
}
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,
// Precompute zero_hashes for empty subtrees. The Solidity original leaves
// zero_hashes[0] = 0 (implicit default) and writes levels 1..DEPOSIT_CONTRACT_TREE_DEPTH.
// We have to match that exact layout — otherwise every root is shifted
// one level.
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")
// Truncating cast matches Solidity's `uint64(x)`. The assert above
// guarantees no information is lost.
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",
)
// Emit DepositEvent (index is the pre-increment 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
}
// Unreachable: the MAX_DEPOSIT_COUNT check above guarantees we exit
// via the `size & 1 == 1` branch within DEPOSIT_CONTRACT_TREE_DEPTH iterations.
assert!(false)
}
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
}
// Finalize with SSZ mix-in-length over `deposit_count`.
// MAX_DEPOSIT_COUNT = 2**32 - 1 means the truncating cast never
// loses information.
ssz::mix_in_length(root: node, len: store.deposit_count.downcast_truncate())
}
GetDepositCount -> Bytes uses (store, mut mem) {
let count: u64 = store.deposit_count.downcast_truncate()
ssz::serialize_u64(count)
}
SupportsInterface { interface_id } -> bool {
interface_id.val == 0x01ffc9a7 || interface_id.val == 0x85640907
}
}
}
// Reconstruct the deposit-data SSZ root from its constituent parts. Exposed at
// module level so tests can precompute a valid `deposit_data_root` to pass in.
#[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()
}

The contract’s public ABI is declared with a msg block. Each variant carries an explicit Solidity selector, so the on-chain ABI matches the canonical deposit contract exactly. Dynamic arguments are typed as std::abi::Bytes:

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,
// ...
}

State lives in a DepositStore storage struct holding the incremental Merkle branch, the deposit_count, and a precomputed table of zero_hashes for empty subtrees. The init block fills zero_hashes level by level so the tree layout matches the Solidity original bit-for-bit:

struct DepositStore {
branch: [u256; DEPOSIT_CONTRACT_TREE_DEPTH],
deposit_count: u256,
zero_hashes: [u256; DEPOSIT_CONTRACT_TREE_DEPTH],
}
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)
}
}

The Deposit handler is marked #[payable] because it receives ETH. It mirrors the Solidity require checks using the assert! macro — including the message string that becomes the revert reason — validates the deposit amount in gwei, reconstructs the SSZ deposit-data root and checks it against the caller-supplied value, emits the DepositEvent, and finally appends the new leaf to the incremental Merkle tree:

#[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")
// ...
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")
// ... reconstruct root, emit event, update branch ...
}

GetDepositRoot walks the branch combining it with the zero-hash table, then mixes in the deposit count via ssz::mix_in_length. GetDepositCount returns the count as a little-endian SSZ-serialized Bytes. Both are read-only handlers (uses (store, mut mem)):

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(root: node, len: store.deposit_count.downcast_truncate())
}

SupportsInterface implements ERC-165 interface detection, returning true for the ERC-165 selector itself (0x01ffc9a7) and the deposit-contract interface id (0x85640907).

The example ships with a property-style test suite that runs under fe test. It exercises ERC-165 detection, rejects deposits with too little value, non-gwei amounts, and mismatched data roots (using #[test(should_revert)]), and verifies that successive deposits advance both the count and the Merkle root. Test handlers drive the contract through the Evm effect:

#[test(balance = 4_000_000_000_000_000_000)]
fn test_deposit_success_updates_root_and_count() uses (evm: mut Evm, mem: mut RawMem) {
let c = evm.create2<DepositContract>(value: 0, args: (), salt: 0)
// ... make a valid deposit, then assert the count and root changed ...
assert!(root1 != root0)
}
PatternWhere
Dynamic bytes inputspubkey, withdrawal_credentials, signature typed as Bytes
Exact length validationassert!(pubkey.len == 48, "...")
Revert reasonsassert!(cond, "message") macro
Payable handler#[payable] on Deposit
Memory effectuses (mem: mut RawMem)
SSZ / Merkleizationstd::evm::ssz, .merkleize(), ssz::mix_in_length
Value helpersether(1), gwei(1), wei(1)
Narrowing casts.downcast_truncate() with a guarding assert!
ERC-165SupportsInterface handler