Skip to content

Commit 5e812df

Browse files
committed
feat(evm-ee): implement deposit validation
- Validate deposits from BlockInputs against block withdrawals field - Add conversion helpers for SubjectId to Address and satoshis to gwei
1 parent da1ff54 commit 5e812df

File tree

4 files changed

+205
-4
lines changed

4 files changed

+205
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/evm-ee/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ bincode.workspace = true
4040
itertools.workspace = true
4141

4242
[dev-dependencies]
43+
alloy-eips.workspace = true
4344
serde.workspace = true
4445
serde_json.workspace = true
4546

crates/evm-ee/src/execution.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use strata_ee_chain_types::{BlockInputs, BlockOutputs};
2222

2323
use crate::{
2424
types::{EvmBlock, EvmHeader, EvmPartialState, EvmWriteBatch},
25-
utils::{build_and_recover_block, compute_hashed_post_state},
25+
utils::{build_and_recover_block, compute_hashed_post_state, validate_deposits_against_block},
2626
};
2727

2828
//FIXME: should be set with real bridge gateway account
@@ -80,7 +80,7 @@ impl ExecutionEnvironment for EvmExecutionEnvironment {
8080
&self,
8181
pre_state: &Self::PartialState,
8282
exec_payload: &ExecPayload<'_, Self::Block>,
83-
_inputs: &BlockInputs,
83+
inputs: &BlockInputs,
8484
) -> EnvResult<ExecBlockOutput<Self>> {
8585
// Step 1: Build block from exec_payload and recover senders
8686
let block = build_and_recover_block(exec_payload)?;
@@ -96,8 +96,7 @@ impl ExecutionEnvironment for EvmExecutionEnvironment {
9696
// Step 2a: Validate deposits from BlockInputs against block withdrawals
9797
// The withdrawals header field is hijacked to represent deposits from the OL.
9898
// We need to ensure the authenticated deposits from BlockInputs match what's in the block.
99-
// TODO:
100-
//validate_deposits_against_block(&block, inputs)?;
99+
validate_deposits_against_block(&block, inputs)?;
101100

102101
// Step 3: Prepare witness database from partial state
103102
let db = {

crates/evm-ee/src/utils.rs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use reth_evm::execute::BlockExecutionOutput;
88
use reth_primitives::{Receipt as EthereumReceipt, RecoveredBlock, TransactionSigned};
99
use reth_primitives_traits::Block;
1010
use reth_trie::{HashedPostState, KeccakKeyHasher};
11+
use revm_primitives::Address;
12+
use strata_acct_types::SubjectId;
1113
use strata_ee_acct_types::{EnvError, EnvResult, ExecPayload};
14+
use strata_ee_chain_types::BlockInputs;
1215

1316
use crate::types::EvmBlock;
1417

@@ -38,3 +41,200 @@ pub(crate) fn compute_hashed_post_state(
3841
) -> HashedPostState {
3942
HashedPostState::from_bundle_state::<KeccakKeyHasher>(&execution_output.state.state)
4043
}
44+
45+
/// Converts a SubjectId (32 bytes) to an EVM address (20 bytes).
46+
fn subject_id_to_address(subject_id: SubjectId) -> Address {
47+
let subject_bytes: [u8; 32] = subject_id.into();
48+
// Take the last 20 bytes to form an address
49+
let mut address_bytes = [0u8; 20];
50+
address_bytes.copy_from_slice(&subject_bytes[12..32]);
51+
Address::from(address_bytes)
52+
}
53+
54+
/// Converts satoshis to gwei for EVM compatibility.
55+
///
56+
/// In Alpen: 1 BTC = 10^8 sats = 10^9 gwei
57+
/// Therefore: 1 sat = 10 gwei
58+
///
59+
/// Per EIP-4895, withdrawal amounts are stored in Gwei (not Wei).
60+
fn sats_to_gwei(sats: u64) -> Option<u64> {
61+
sats.checked_mul(10)
62+
}
63+
64+
/// Validates that deposits from BlockInputs match the withdrawals field in the block.
65+
///
66+
/// In Alpen, the EIP-4895 withdrawals field is hijacked to represent deposits from the
67+
/// orchestration layer. This function ensures that the authenticated deposits in BlockInputs
68+
/// match what's committed in the block's withdrawals field.
69+
///
70+
/// # Warning
71+
/// **Deposits and withdrawals must be in the same order.** This function performs a
72+
/// pairwise comparison using `zip()`, so the nth deposit must match the nth withdrawal.
73+
/// Any reordering will cause validation to fail.
74+
///
75+
/// # Mapping
76+
/// - `Withdrawal.address` ← last 20 bytes of `SubjectDepositData.dest` (SubjectId)
77+
/// - `Withdrawal.amount` ← `SubjectDepositData.value` in Gwei
78+
/// - `Withdrawal.index` and `Withdrawal.validator_index` are ignored (not meaningful for deposits)
79+
///
80+
/// # Returns
81+
/// - `Ok(())` if deposits match
82+
/// - `Err(EnvError::InvalidBlock)` if there's a mismatch in count, address, or amount
83+
pub(crate) fn validate_deposits_against_block(
84+
block: &RecoveredBlock<AlloyBlock<TransactionSigned>>,
85+
inputs: &BlockInputs,
86+
) -> EnvResult<()> {
87+
// Get withdrawals from the block body (this is where deposits are stored)
88+
// Access the sealed block's body withdrawals through the nested structure
89+
let block_withdrawals = block
90+
.sealed_block()
91+
.body()
92+
.withdrawals
93+
.as_ref()
94+
.map(|w| w.as_slice())
95+
.unwrap_or(&[]);
96+
97+
let subject_deposits = inputs.subject_deposits();
98+
99+
// Check counts match
100+
if block_withdrawals.len() != subject_deposits.len() {
101+
return Err(EnvError::InvalidBlock);
102+
}
103+
104+
// Validate each deposit matches the corresponding withdrawal
105+
for (withdrawal, deposit) in block_withdrawals.iter().zip(subject_deposits.iter()) {
106+
// Convert SubjectId to Address (last 20 bytes)
107+
let expected_address = subject_id_to_address(deposit.dest());
108+
109+
// Convert satoshis to gwei (1 sat = 10 gwei, per 1 BTC = 10^8 sats = 10^9 gwei)
110+
let expected_amount =
111+
sats_to_gwei(deposit.value().to_sat()).ok_or(EnvError::InvalidBlock)?;
112+
113+
// Validate address and amount match
114+
if withdrawal.address != expected_address || withdrawal.amount != expected_amount {
115+
return Err(EnvError::InvalidBlock);
116+
}
117+
118+
// Note: withdrawal.index and withdrawal.validator_index are not validated
119+
// as they are not meaningful in the deposit context
120+
}
121+
122+
Ok(())
123+
}
124+
125+
#[cfg(test)]
126+
mod tests {
127+
use alloy_consensus::{BlockBody, Header};
128+
use alloy_eips::eip4895::Withdrawal;
129+
use reth_primitives::RecoveredBlock;
130+
use strata_acct_types::{BitcoinAmount, SubjectId};
131+
use strata_ee_chain_types::{BlockInputs, SubjectDepositData};
132+
133+
use super::*;
134+
135+
#[test]
136+
fn test_validate_deposits_valid_match() {
137+
// Create subject ID where last 20 bytes form an address
138+
let mut subject_bytes = [0u8; 32];
139+
subject_bytes[31] = 0x42; // Last byte
140+
let subject_id = SubjectId::new(subject_bytes);
141+
let expected_address = subject_id_to_address(subject_id);
142+
143+
// Create a block with matching withdrawal
144+
let header = Header::default();
145+
let body = BlockBody {
146+
transactions: vec![],
147+
ommers: vec![],
148+
withdrawals: Some(
149+
vec![Withdrawal {
150+
index: 0,
151+
validator_index: 0,
152+
address: expected_address,
153+
amount: 100, // 10 sats * 10 = 100 gwei
154+
}]
155+
.into(),
156+
),
157+
};
158+
let block = AlloyBlock { header, body };
159+
let recovered_block: RecoveredBlock<AlloyBlock<TransactionSigned>> =
160+
block.try_into_recovered().unwrap();
161+
162+
// Create matching deposit input
163+
let mut inputs = BlockInputs::new_empty();
164+
let deposit = SubjectDepositData::new(subject_id, BitcoinAmount::from_sat(10));
165+
inputs.add_subject_deposit(deposit);
166+
167+
// Should succeed - perfect match
168+
let result = validate_deposits_against_block(&recovered_block, &inputs);
169+
assert!(result.is_ok());
170+
}
171+
172+
#[test]
173+
fn test_validate_deposits_address_mismatch() {
174+
let subject_id = SubjectId::new([1u8; 32]);
175+
176+
// Create a block with different address
177+
let header = Header::default();
178+
let body = BlockBody {
179+
transactions: vec![],
180+
ommers: vec![],
181+
withdrawals: Some(
182+
vec![Withdrawal {
183+
index: 0,
184+
validator_index: 0,
185+
address: Address::ZERO, // Wrong address
186+
amount: 100,
187+
}]
188+
.into(),
189+
),
190+
};
191+
let block = AlloyBlock { header, body };
192+
let recovered_block: RecoveredBlock<AlloyBlock<TransactionSigned>> =
193+
block.try_into_recovered().unwrap();
194+
195+
// Create deposit with different address
196+
let mut inputs = BlockInputs::new_empty();
197+
let deposit = SubjectDepositData::new(subject_id, BitcoinAmount::from_sat(10));
198+
inputs.add_subject_deposit(deposit);
199+
200+
// Should fail - address mismatch
201+
let result = validate_deposits_against_block(&recovered_block, &inputs);
202+
assert!(result.is_err());
203+
}
204+
205+
#[test]
206+
fn test_validate_deposits_amount_mismatch() {
207+
let mut subject_bytes = [0u8; 32];
208+
subject_bytes[31] = 0x42;
209+
let subject_id = SubjectId::new(subject_bytes);
210+
let expected_address = subject_id_to_address(subject_id);
211+
212+
// Create a block with wrong amount
213+
let header = Header::default();
214+
let body = BlockBody {
215+
transactions: vec![],
216+
ommers: vec![],
217+
withdrawals: Some(
218+
vec![Withdrawal {
219+
index: 0,
220+
validator_index: 0,
221+
address: expected_address,
222+
amount: 200, // Wrong amount (should be 100)
223+
}]
224+
.into(),
225+
),
226+
};
227+
let block = AlloyBlock { header, body };
228+
let recovered_block: RecoveredBlock<AlloyBlock<TransactionSigned>> =
229+
block.try_into_recovered().unwrap();
230+
231+
// Create deposit with different amount
232+
let mut inputs = BlockInputs::new_empty();
233+
let deposit = SubjectDepositData::new(subject_id, BitcoinAmount::from_sat(10)); // 10 sats = 100 gwei
234+
inputs.add_subject_deposit(deposit);
235+
236+
// Should fail - amount mismatch
237+
let result = validate_deposits_against_block(&recovered_block, &inputs);
238+
assert!(result.is_err());
239+
}
240+
}

0 commit comments

Comments
 (0)