Skip to main content

ethrex_common/types/
stateless_ssz.rs

1//! SSZ containers for EIP-8025 (Optional Execution Proofs) and the
2//! stateless validation flow used by native rollups.
3//!
4//! The first section mirrors the CL-side SSZ definitions used for
5//! tree-hashing `NewPayloadRequest` and producing the `PublicInput`
6//! committed to by execution proofs. The second section layers the
7//! native-rollup types (`SszStatelessInput`, `SszStatelessValidationResult`,
8//! `SszExecutionWitness`) on top of those.
9
10use bytes::Bytes;
11use libssz::{SszDecode, SszEncode};
12use libssz_derive::{HashTreeRoot, SszDecode, SszEncode};
13use libssz_merkle::{
14    HashTreeRoot, Node, Sha256Hasher, merkleize_progressive, mix_in_active_fields,
15};
16use libssz_types::{ProgressiveList, SszList, SszVector};
17
18use super::requests::EncodedRequests;
19
20// ============================================================================
21// EIP-8025 containers
22// ============================================================================
23
24// ── Spec limits (Electra) ──────────────────────────────────────────
25
26/// `MAX_EXTRA_DATA_BYTES`.
27const MAX_EXTRA_DATA_BYTES: usize = 32;
28
29// ── EIP-7685 request type prefixes ─────────────────────────────────
30
31const DEPOSIT_REQUEST_TYPE: u8 = 0x00;
32const WITHDRAWAL_REQUEST_TYPE: u8 = 0x01;
33const CONSOLIDATION_REQUEST_TYPE: u8 = 0x02;
34const BUILDER_DEPOSIT_REQUEST_TYPE: u8 = 0x03;
35const BUILDER_EXIT_REQUEST_TYPE: u8 = 0x04;
36
37// ── Bytes20 wrapper (address) ──────────────────────────────────────
38//
39// libssz implements `SszEncode`/`SszDecode` for `[u8; 20]` but NOT
40// `HashTreeRoot`. Per the SSZ spec, a 20-byte basic value is
41// right-padded with zeros to 32 bytes for its tree hash leaf.
42
43/// A 20-byte value (e.g. an execution address) with SSZ + HTR support.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[repr(transparent)]
46pub struct Bytes20(pub [u8; 20]);
47
48impl SszEncode for Bytes20 {
49    fn is_fixed_size() -> bool {
50        true
51    }
52    fn fixed_size() -> usize {
53        20
54    }
55    fn encoded_len(&self) -> usize {
56        20
57    }
58    fn ssz_append(&self, buf: &mut Vec<u8>) {
59        self.0.ssz_append(buf);
60    }
61}
62
63impl SszDecode for Bytes20 {
64    fn is_fixed_size() -> bool {
65        true
66    }
67    fn fixed_size() -> usize {
68        20
69    }
70    fn from_ssz_bytes(bytes: &[u8]) -> Result<Self, libssz::DecodeError> {
71        <[u8; 20]>::from_ssz_bytes(bytes).map(Self)
72    }
73}
74
75impl HashTreeRoot for Bytes20 {
76    fn hash_tree_root(&self, _hasher: &impl Sha256Hasher) -> libssz_merkle::Node {
77        let mut node = [0u8; 32];
78        node[..20].copy_from_slice(&self.0);
79        node
80    }
81}
82
83impl From<[u8; 20]> for Bytes20 {
84    fn from(bytes: [u8; 20]) -> Self {
85        Self(bytes)
86    }
87}
88
89impl From<Bytes20> for [u8; 20] {
90    fn from(b: Bytes20) -> Self {
91        b.0
92    }
93}
94
95// ── LogsBloom type alias ───────────────────────────────────────────
96//
97// `logs_bloom` is `ByteVector[BYTES_PER_LOGS_BLOOM]` in the CL spec —
98// a fixed-length SSZ vector of 256 bytes.
99
100/// `BYTES_PER_LOGS_BLOOM` from the CL spec.
101pub const BYTES_PER_LOGS_BLOOM: usize = 256;
102
103/// `ByteVector[256]` — the logs bloom as a fixed-size SSZ vector.
104pub type LogsBloom = SszVector<u8, BYTES_PER_LOGS_BLOOM>;
105
106// ── Sub-containers ─────────────────────────────────────────────────
107
108/// SSZ `Withdrawal` container matching the CL spec.
109#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
110pub struct Withdrawal {
111    pub index: u64,
112    pub validator_index: u64,
113    pub address: Bytes20,
114    pub amount: u64,
115}
116
117/// SSZ `DepositRequest` container (EIP-6110).
118#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
119pub struct DepositRequest {
120    pub pubkey: [u8; 48],
121    pub withdrawal_credentials: [u8; 32],
122    pub amount: u64,
123    pub signature: [u8; 96],
124    pub index: u64,
125}
126
127/// SSZ `WithdrawalRequest` container (EIP-7002).
128#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
129pub struct WithdrawalRequest {
130    pub source_address: Bytes20,
131    pub validator_pubkey: [u8; 48],
132    pub amount: u64,
133}
134
135/// SSZ `ConsolidationRequest` container (EIP-7251).
136#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
137pub struct ConsolidationRequest {
138    pub source_address: Bytes20,
139    pub source_pubkey: [u8; 48],
140    pub target_pubkey: [u8; 48],
141}
142
143/// SSZ `BuilderDepositRequest` container (EIP-8282).
144#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
145pub struct BuilderDepositRequest {
146    pub pubkey: [u8; 48],
147    pub withdrawal_credentials: [u8; 32],
148    pub amount: u64,
149    pub signature: [u8; 96],
150}
151
152/// SSZ `BuilderExitRequest` container (EIP-8282).
153#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
154pub struct BuilderExitRequest {
155    pub source_address: Bytes20,
156    pub pubkey: [u8; 48],
157}
158
159// ── ExecutionPayload ───────────────────────────────────────────────
160
161/// SSZ container matching `SszExecutionPayload` at execution-specs `3c3b6f4af`
162/// (#3248): a `ProgressiveContainer(active_fields=[1; 19])` whose transaction,
163/// withdrawal and block-access-list fields are progressive lists.
164///
165/// `HashTreeRoot` is hand-written because `libssz-derive` has no progressive
166/// support; encode/decode still derive, since nothing progressive changes the
167/// wire layout (`ProgressiveList` delegates both to `Vec<T>`).
168#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)]
169pub struct ExecutionPayload {
170    pub parent_hash: [u8; 32],
171    pub fee_recipient: Bytes20,
172    pub state_root: [u8; 32],
173    pub receipts_root: [u8; 32],
174    pub logs_bloom: LogsBloom,
175    pub prev_randao: [u8; 32],
176    pub block_number: u64,
177    pub gas_limit: u64,
178    pub gas_used: u64,
179    pub timestamp: u64,
180    pub extra_data: SszList<u8, MAX_EXTRA_DATA_BYTES>,
181    /// `base_fee_per_gas` encoded as a 256-bit unsigned integer (little-endian).
182    pub base_fee_per_gas: [u8; 32],
183    pub block_hash: [u8; 32],
184    pub transactions: ProgressiveList<ProgressiveList<u8>>,
185    pub withdrawals: ProgressiveList<Withdrawal>,
186    pub blob_gas_used: u64,
187    pub excess_blob_gas: u64,
188    /// EIP-7928 block-level access list (full serialized BAL bytes).
189    pub block_access_list: ProgressiveList<u8>,
190    /// EIP-7843 slot number (Amsterdam+). Last field, matching the execution-specs
191    /// `ExecutionPayloadV4` layout so the native path is byte-compatible with the
192    /// spec-current payload. Provided by the L2 producer and carried to L1.
193    pub slot_number: u64,
194}
195
196/// `ProgressiveContainer` merkleization, per EIP-7495/EIP-7916:
197/// `mix_in_active_fields(merkleize_progressive(field_roots), active_fields)`.
198///
199/// Hand-written because `libssz-derive` has no progressive support.
200///
201/// The roots are verified against remerkleable by
202/// `test/tests/common/progressive_ssz_tests.rs`. `libssz-merkle 0.2.2` had its
203/// progressive subtree children reversed relative to the reference, which made
204/// every root here wrong; the workspace pins 0.3.0, which fixes it, and those
205/// tests are what hold that pin in place.
206fn progressive_container_root<const N: usize>(
207    hasher: &impl Sha256Hasher,
208    field_roots: [Node; N],
209) -> Node {
210    let root = merkleize_progressive(hasher, &field_roots);
211    mix_in_active_fields(hasher, &root, &[true; N])
212}
213
214impl HashTreeRoot for ExecutionPayload {
215    fn hash_tree_root(&self, hasher: &impl Sha256Hasher) -> Node {
216        // Destructured without `..` on purpose: this list is hand-maintained, so
217        // a field added to the struct must fail to compile here rather than be
218        // silently left out of the root and out of `active_fields`.
219        // Declaration order is the SSZ field order; do not reorder.
220        let Self {
221            parent_hash,
222            fee_recipient,
223            state_root,
224            receipts_root,
225            logs_bloom,
226            prev_randao,
227            block_number,
228            gas_limit,
229            gas_used,
230            timestamp,
231            extra_data,
232            base_fee_per_gas,
233            block_hash,
234            transactions,
235            withdrawals,
236            blob_gas_used,
237            excess_blob_gas,
238            block_access_list,
239            slot_number,
240        } = self;
241        progressive_container_root(
242            hasher,
243            [
244                parent_hash.hash_tree_root(hasher),
245                fee_recipient.hash_tree_root(hasher),
246                state_root.hash_tree_root(hasher),
247                receipts_root.hash_tree_root(hasher),
248                logs_bloom.hash_tree_root(hasher),
249                prev_randao.hash_tree_root(hasher),
250                block_number.hash_tree_root(hasher),
251                gas_limit.hash_tree_root(hasher),
252                gas_used.hash_tree_root(hasher),
253                timestamp.hash_tree_root(hasher),
254                extra_data.hash_tree_root(hasher),
255                base_fee_per_gas.hash_tree_root(hasher),
256                block_hash.hash_tree_root(hasher),
257                transactions.hash_tree_root(hasher),
258                withdrawals.hash_tree_root(hasher),
259                blob_gas_used.hash_tree_root(hasher),
260                excess_blob_gas.hash_tree_root(hasher),
261                block_access_list.hash_tree_root(hasher),
262                slot_number.hash_tree_root(hasher),
263            ],
264        )
265    }
266}
267
268// ── ExecutionRequests ──────────────────────────────────────────────
269
270/// SSZ `ExecutionRequests` at execution-specs `3c3b6f4af`: a
271/// `ProgressiveContainer(active_fields=[1; 5])` carrying the EIP-7685 bundle
272/// including the EIP-8282 builder requests.
273///
274/// `HashTreeRoot` is hand-written; see [`ExecutionPayload`].
275#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)]
276pub struct ExecutionRequests {
277    pub deposits: ProgressiveList<DepositRequest>,
278    pub withdrawals: ProgressiveList<WithdrawalRequest>,
279    pub consolidations: ProgressiveList<ConsolidationRequest>,
280    pub builder_deposits: ProgressiveList<BuilderDepositRequest>,
281    pub builder_exits: ProgressiveList<BuilderExitRequest>,
282}
283
284impl ExecutionRequests {
285    /// Produce the EIP-7685 encoded form: five `EncodedRequests` entries,
286    /// one per request type, each `[type_byte] ++ concat(ssz_encode(item))`.
287    ///
288    /// The five request types are all fixed-size SSZ containers, so their
289    /// SSZ encoding is byte-for-byte the EL wire concatenation that
290    /// `compute_requests_hash` expects.
291    pub fn to_encoded_requests(&self) -> Vec<EncodedRequests> {
292        fn encode<T: SszEncode>(
293            type_byte: u8,
294            items: impl IntoIterator<Item = T>,
295        ) -> EncodedRequests {
296            let mut buf = Vec::new();
297            buf.push(type_byte);
298            for item in items {
299                item.ssz_append(&mut buf);
300            }
301            EncodedRequests(Bytes::from(buf))
302        }
303
304        vec![
305            encode(DEPOSIT_REQUEST_TYPE, self.deposits.iter().cloned()),
306            encode(WITHDRAWAL_REQUEST_TYPE, self.withdrawals.iter().cloned()),
307            encode(
308                CONSOLIDATION_REQUEST_TYPE,
309                self.consolidations.iter().cloned(),
310            ),
311            encode(
312                BUILDER_DEPOSIT_REQUEST_TYPE,
313                self.builder_deposits.iter().cloned(),
314            ),
315            encode(
316                BUILDER_EXIT_REQUEST_TYPE,
317                self.builder_exits.iter().cloned(),
318            ),
319        ]
320    }
321}
322
323impl HashTreeRoot for ExecutionRequests {
324    fn hash_tree_root(&self, hasher: &impl Sha256Hasher) -> Node {
325        // Destructured without `..` — see [`ExecutionPayload`].
326        let Self {
327            deposits,
328            withdrawals,
329            consolidations,
330            builder_deposits,
331            builder_exits,
332        } = self;
333        progressive_container_root(
334            hasher,
335            [
336                deposits.hash_tree_root(hasher),
337                withdrawals.hash_tree_root(hasher),
338                consolidations.hash_tree_root(hasher),
339                builder_deposits.hash_tree_root(hasher),
340                builder_exits.hash_tree_root(hasher),
341            ],
342        )
343    }
344}
345
346// ── NewPayloadRequest ──────────────────────────────────────────────
347
348/// SSZ `NewPayloadRequest` — the key container whose `hash_tree_root` is
349/// the public input committed to by an execution proof.
350#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
351pub struct NewPayloadRequest {
352    pub execution_payload: ExecutionPayload,
353    pub versioned_hashes: ProgressiveList<[u8; 32]>,
354    pub parent_beacon_block_root: [u8; 32],
355    pub execution_requests: ExecutionRequests,
356}
357
358// ── PublicInput ────────────────────────────────────────────────────
359
360/// The public input for an execution proof: the `hash_tree_root` of the
361/// `NewPayloadRequest`.
362#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
363pub struct PublicInput {
364    pub new_payload_request_root: [u8; 32],
365}
366
367impl NewPayloadRequest {
368    /// Compute the `hash_tree_root` of this request — the value that
369    /// becomes the execution proof's public input.
370    pub fn public_input(&self, hasher: &impl Sha256Hasher) -> PublicInput {
371        PublicInput {
372            new_payload_request_root: self.hash_tree_root(hasher),
373        }
374    }
375}
376
377// ============================================================================
378// Stateless validation containers (native rollups / EXECUTE precompile)
379// ============================================================================
380
381// ── Stateless validation limits ──────────────────────────────────
382
383// `state` and `codes` carry no element-count bound: #3356 made them
384// `ProgressiveList`, which grows without a declared capacity, so the upstream
385// `MAX_WITNESS_NODES` / `MAX_WITNESS_CODES` constants were deleted along with it. The per-element byte caps below stay,
386// and `headers` keeps its count bound — it is deliberately still an `SszList`.
387
388/// MAX_BYTES_PER_WITNESS_NODE — max size of a single witness node.
389const MAX_BYTES_PER_WITNESS_NODE: usize = 1_048_576; // 2^20
390/// MAX_BYTES_PER_CODE — max size of a single code preimage (EIP-7954).
391const MAX_BYTES_PER_CODE: usize = 16_777_216; // 2^24
392/// MAX_WITNESS_HEADERS — max RLP-encoded block headers in witness (up to 256).
393const MAX_WITNESS_HEADERS: usize = 256;
394/// MAX_BYTES_PER_HEADER — max size of a single RLP-encoded header.
395const MAX_BYTES_PER_HEADER: usize = 1_024; // 2^10
396
397// ── Stateless validation types ───────────────────────────────────
398//
399// Mirror the definitions in execution-specs (projects/zkevm branch) at the
400// commit EIP-8025 PR #11604 pins:
401// https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless_ssz.py
402
403/// Schema id of the stateless input wire format: `fork_index << 8 | revision`,
404/// where fork `0x15` is Amsterdam and revision `0x01` is the current encoding.
405///
406/// The 2-byte big-endian prefix is how the fork reaches the guest at all — #3278
407/// removed all chain configuration from the SSZ body — and it is echoed as a
408/// public output field so a verifier can pin which rules were applied. Upstream
409/// rejects any other id outright; so does ethrex.
410///
411/// Note that upstream keeps `0x1501` across incompatible body changes, so the id
412/// does **not** identify the encoding. Four dialects have shipped under it:
413/// `tests-zkevm@v0.6.2`, then #3248 + #3278, then #3356 (which moved `state`,
414/// `codes` and `public_keys` to `ProgressiveList`), then #3652 (which removed
415/// `public_keys` altogether, in `tests-zkevm@v21.0.1`). ethrex speaks the last one.
416/// Releases since have only rearranged the Python that declares it, and SSZ
417/// encoding is positional, so renames and module moves leave the wire alone;
418/// which bundle is actually pinned lives in `tooling/ef_tests/.fixtures_url_zkevm`.
419/// A bundle from an older dialect will not be caught by this prefix — it fails
420/// later, in decode or on a mismatched root.
421pub const STATELESS_INPUT_SCHEMA_ID: u16 = 0x1501;
422
423/// Byte length of the big-endian [`STATELESS_INPUT_SCHEMA_ID`] prefix.
424pub const STATELESS_INPUT_SCHEMA_ID_SIZE: usize = 2;
425
426/// SSZ `ExecutionWitness` container matching the execution-specs definition.
427///
428/// Contains all data needed for stateless execution:
429/// - `state`: trie-node preimages
430/// - `codes`: contract code preimages
431/// - `headers`: RLP-encoded parent block headers (up to 256)
432///
433/// `state` and `codes` are `ProgressiveList` as of execution-specs #3356;
434/// `headers` stays a bounded `SszList`, since execution only ever exposes the
435/// previous 256 block hashes. The mixed shape is upstream's, not an oversight.
436#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
437pub struct SszExecutionWitness {
438    pub state: ProgressiveList<SszList<u8, MAX_BYTES_PER_WITNESS_NODE>>,
439    pub codes: ProgressiveList<SszList<u8, MAX_BYTES_PER_CODE>>,
440    pub headers: SszList<SszList<u8, MAX_BYTES_PER_HEADER>, MAX_WITNESS_HEADERS>,
441}
442
443/// SSZ `StatelessInput` — the top-level input to `verify_stateless_new_payload`.
444///
445/// Wraps a `NewPayloadRequest` together with the execution witness and the
446/// chain id. It carries no sender public keys: execution-specs #3652 removed
447/// them, so the guest recovers every sender itself, which it had to do anyway
448/// because a supplied key cannot bind the signature's recovery id.
449#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
450pub struct SszStatelessInput {
451    pub new_payload_request: NewPayloadRequest,
452    pub witness: SszExecutionWitness,
453    /// Chain identifier. #3278 removed `ChainConfig` from the wire: fork
454    /// activation info and blob schedules are guest-internal, keyed by
455    /// `(chain_id, fork)`, with the fork coming from the schema-id prefix.
456    pub chain_id: u64,
457}
458
459/// SSZ `StatelessValidationResult` — the output of `verify_stateless_new_payload`.
460#[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
461pub struct SszStatelessValidationResult {
462    pub new_payload_request_root: [u8; 32],
463    pub successful_validation: bool,
464    /// Chain identifier echoed from the input — real even when validation fails;
465    /// only a decode failure yields the all-zero default.
466    pub chain_id: u64,
467    /// The full schema id the guest decoded (`0x1501` for Amsterdam revision 1).
468    /// Public so a verifier can pin which fork rules and encoding were used, for
469    /// forks that share a payload shape. Added by execution-specs #3278.
470    pub schema_id: u16,
471}
472
473// ── Conversions to internal types ────────────────────────────────
474
475impl SszExecutionWitness {
476    /// Extract raw bytes from SSZ lists for codes.
477    pub fn codes_as_vecs(&self) -> Vec<Vec<u8>> {
478        self.codes.iter().map(|c| c.to_vec()).collect()
479    }
480
481    /// Extract raw bytes from SSZ lists for headers.
482    pub fn headers_as_vecs(&self) -> Vec<Vec<u8>> {
483        self.headers.iter().map(|h| h.to_vec()).collect()
484    }
485
486    /// Extract raw bytes from SSZ lists for state nodes.
487    pub fn state_as_vecs(&self) -> Vec<Vec<u8>> {
488        self.state.iter().map(|n| n.to_vec()).collect()
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use libssz_merkle::Sha2Hasher;
496
497    const HASHER: Sha2Hasher = Sha2Hasher;
498
499    // ── EIP-8025 helpers ─────────────────────────────────────────
500
501    fn sample_payload() -> ExecutionPayload {
502        ExecutionPayload {
503            parent_hash: [1u8; 32],
504            fee_recipient: Bytes20([2u8; 20]),
505            state_root: [3u8; 32],
506            receipts_root: [4u8; 32],
507            logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom length"),
508            prev_randao: [5u8; 32],
509            block_number: 42,
510            gas_limit: 30_000_000,
511            gas_used: 21_000,
512            timestamp: 1_700_000_000,
513            extra_data: vec![0xAB, 0xCD].try_into().expect("extra_data fits"),
514            base_fee_per_gas: {
515                let mut b = [0u8; 32];
516                b[0] = 7; // 7 in LE
517                b
518            },
519            block_hash: [6u8; 32],
520            transactions: vec![
521                vec![0xDE, 0xAD, 0xBE, 0xEF]
522                    .try_into()
523                    .expect("tx bytes fit"),
524            ]
525            .try_into()
526            .expect("txs fit"),
527            withdrawals: vec![Withdrawal {
528                index: 0,
529                validator_index: 1,
530                address: Bytes20([7u8; 20]),
531                amount: 1_000_000,
532            }]
533            .try_into()
534            .expect("withdrawals fit"),
535            blob_gas_used: 0,
536            excess_blob_gas: 0,
537            block_access_list: ProgressiveList::new(), // TODO(Plan 02): populate full BAL
538            slot_number: 0,
539        }
540    }
541
542    fn empty_requests() -> ExecutionRequests {
543        ExecutionRequests {
544            deposits: Vec::new().into(),
545            withdrawals: Vec::new().into(),
546            consolidations: Vec::new().into(),
547            builder_deposits: Vec::new().into(),
548            builder_exits: Vec::new().into(),
549        }
550    }
551
552    fn sample_request() -> NewPayloadRequest {
553        NewPayloadRequest {
554            execution_payload: sample_payload(),
555            versioned_hashes: Vec::new().into(),
556            parent_beacon_block_root: [8u8; 32],
557            execution_requests: empty_requests(),
558        }
559    }
560
561    #[test]
562    fn test_ssz_root_changes_with_different_data() {
563        let request1 = sample_request();
564        let mut request2 = sample_request();
565        request2.execution_payload.block_number = 99;
566
567        assert_ne!(
568            request1.hash_tree_root(&HASHER),
569            request2.hash_tree_root(&HASHER),
570            "Different payloads must produce different roots"
571        );
572    }
573
574    #[test]
575    fn test_ssz_root_is_deterministic() {
576        let request = sample_request();
577        let root1 = request.hash_tree_root(&HASHER);
578        let root2 = request.hash_tree_root(&HASHER);
579        assert_eq!(root1, root2, "Same request must produce same root");
580    }
581
582    #[test]
583    fn test_execution_requests_to_encoded_bytes() {
584        let requests = ExecutionRequests {
585            deposits: vec![DepositRequest {
586                pubkey: [0x11; 48],
587                withdrawal_credentials: [0x22; 32],
588                amount: 32_000_000_000,
589                signature: [0x33; 96],
590                index: 7,
591            }]
592            .into(),
593            withdrawals: vec![WithdrawalRequest {
594                source_address: Bytes20([0x44; 20]),
595                validator_pubkey: [0x55; 48],
596                amount: 1_000_000,
597            }]
598            .into(),
599            consolidations: vec![ConsolidationRequest {
600                source_address: Bytes20([0x66; 20]),
601                source_pubkey: [0x77; 48],
602                target_pubkey: [0x88; 48],
603            }]
604            .into(),
605            builder_deposits: ProgressiveList::new(),
606            builder_exits: ProgressiveList::new(),
607        };
608
609        let encoded = requests.to_encoded_requests();
610        // Five entries since EIP-8282: the two builder lists are empty here, and
611        // `compute_requests_hash` skips entries of length <= 1, so an empty
612        // builder list leaves the requests hash unchanged.
613        assert_eq!(encoded.len(), 5, "must emit 5 EIP-7685 entries");
614
615        // Deposit: [0x00] ++ 192 bytes
616        assert_eq!(encoded[0].0[0], DEPOSIT_REQUEST_TYPE);
617        assert_eq!(encoded[0].0.len(), 1 + 192);
618
619        // Withdrawal: [0x01] ++ 76 bytes
620        assert_eq!(encoded[1].0[0], WITHDRAWAL_REQUEST_TYPE);
621        assert_eq!(encoded[1].0.len(), 1 + 76);
622
623        // Consolidation: [0x02] ++ 116 bytes
624        assert_eq!(encoded[2].0[0], CONSOLIDATION_REQUEST_TYPE);
625        assert_eq!(encoded[2].0.len(), 1 + 116);
626    }
627
628    // ── Stateless helpers ────────────────────────────────────────
629
630    fn list<T: SszEncode + SszDecode, const N: usize>(items: Vec<T>) -> SszList<T, N> {
631        let mut list = SszList::new();
632        for item in items {
633            list.push(item).expect("test list capacity exceeded");
634        }
635        list
636    }
637
638    fn round_trip<T: SszEncode + SszDecode + PartialEq + std::fmt::Debug>(value: &T) {
639        let mut buf = Vec::new();
640        value.ssz_append(&mut buf);
641        let decoded = T::from_ssz_bytes(&buf).expect("SSZ decode failed");
642        assert_eq!(*value, decoded, "round-trip mismatch");
643    }
644
645    #[test]
646    fn ssz_execution_witness_round_trip() {
647        let witness = SszExecutionWitness {
648            state: vec![list(vec![1u8, 2, 3]), list(vec![4u8, 5])].into(),
649            codes: vec![list(vec![0x60u8, 0x00, 0x60, 0x00, 0xf3])].into(),
650            headers: list(vec![list(vec![0xf9u8, 0x02, 0x11])]),
651        };
652        round_trip(&witness);
653    }
654
655    #[test]
656    fn ssz_execution_witness_empty_round_trip() {
657        let witness = SszExecutionWitness {
658            state: ProgressiveList::new(),
659            codes: ProgressiveList::new(),
660            headers: SszList::new(),
661        };
662        round_trip(&witness);
663    }
664
665    #[test]
666    fn ssz_execution_payload_has_block_access_list_round_trip() {
667        let payload = ExecutionPayload {
668            parent_hash: [0x11; 32],
669            fee_recipient: Bytes20([0x22; 20]),
670            state_root: [0x33; 32],
671            receipts_root: [0x44; 32],
672            logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom length"),
673            prev_randao: [0x55; 32],
674            block_number: 7,
675            gas_limit: 30_000_000,
676            gas_used: 21_000,
677            timestamp: 1_700_000_000,
678            extra_data: list(vec![0xde, 0xad]),
679            base_fee_per_gas: [0u8; 32],
680            block_hash: [0x66; 32],
681            transactions: ProgressiveList::new(),
682            withdrawals: ProgressiveList::new(),
683            blob_gas_used: 0,
684            excess_blob_gas: 0,
685            block_access_list: vec![0x01u8, 0x02, 0x03].into(),
686            slot_number: 0,
687        };
688        round_trip(&payload);
689        assert_eq!(payload.block_access_list.len(), 3);
690    }
691
692    #[test]
693    fn ssz_stateless_validation_result_round_trip() {
694        let result = SszStatelessValidationResult {
695            new_payload_request_root: [0xab; 32],
696            successful_validation: true,
697            chain_id: 42,
698            schema_id: 0x1501,
699        };
700        round_trip(&result);
701
702        let result_false = SszStatelessValidationResult {
703            new_payload_request_root: [0x00; 32],
704            successful_validation: false,
705            chain_id: 1,
706            schema_id: 0x1501,
707        };
708        round_trip(&result_false);
709    }
710
711    // ── NativeRollup.sol SSZ offset cross-checks (I17) ───────────────
712    //
713    // These constants MUST equal NativeRollup.sol's SSZ offset constants
714    // (crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol). If a container
715    // field is reordered/resized, this test fails instead of advance() silently
716    // reading the wrong bytes on L1.
717
718    const SOL_RESULT_SUCCESS_OFFSET: usize = 32;
719    // Since #3278 the result is entirely fixed-size (32 + 1 + 8 + 2 = 43 bytes):
720    // chain_id is read directly at 33, schema_id at 41. There is no longer an
721    // offset to dereference.
722    const SOL_RESULT_CHAIN_ID_OFFSET: usize = 33;
723    const SOL_RESULT_SCHEMA_ID_OFFSET: usize = 41;
724    const SOL_RESULT_FIXED_LEN: usize = 43;
725    const SOL_EP_STATE_ROOT_OFFSET: usize = 52;
726    const SOL_EP_BLOCK_NUMBER_OFFSET: usize = 404;
727    const SOL_EP_GAS_LIMIT_OFFSET: usize = 412;
728    const SOL_EP_BLOCK_HASH_OFFSET: usize = 472;
729    // block_access_list's offset slot sits at EP+528; slot_number (EIP-7843) is the
730    // trailing fixed u64 at EP+532; the fixed prefix is 540.
731    const SOL_EP_BAL_OFFSET_POS: usize = 528;
732    const SOL_EP_SLOT_NUMBER_OFFSET: usize = 532;
733    const SOL_EP_FIXED_PREFIX_LEN: usize = 540;
734
735    fn u32_le(bytes: &[u8], off: usize) -> usize {
736        (bytes[off] as usize)
737            | ((bytes[off + 1] as usize) << 8)
738            | ((bytes[off + 2] as usize) << 16)
739            | ((bytes[off + 3] as usize) << 24)
740    }
741
742    fn sample_execution_payload() -> ExecutionPayload {
743        ExecutionPayload {
744            parent_hash: [0x11; 32],
745            fee_recipient: Bytes20([0x22; 20]),
746            state_root: [0x33; 32],
747            receipts_root: [0x44; 32],
748            logs_bloom: vec![0u8; 256].try_into().expect("bloom"),
749            prev_randao: [0x55; 32],
750            block_number: 7,
751            gas_limit: 30_000_000,
752            gas_used: 21_000,
753            timestamp: 1_700_000_000,
754            extra_data: SszList::new(),
755            base_fee_per_gas: [0u8; 32],
756            block_hash: [0x66; 32],
757            transactions: ProgressiveList::new(),
758            withdrawals: ProgressiveList::new(),
759            blob_gas_used: 0,
760            excess_blob_gas: 0,
761            block_access_list: ProgressiveList::new(),
762            slot_number: 0x7843,
763        }
764    }
765
766    #[test]
767    fn nativerollup_sol_result_layout_matches() {
768        // Encode a StatelessValidationResult and confirm the contract's fixed
769        // offsets. Under #3278 every field is fixed-size, so all three are direct
770        // reads and the total length is exactly 43 bytes.
771        let result = SszStatelessValidationResult {
772            new_payload_request_root: [0xAA; 32],
773            successful_validation: true,
774            chain_id: 0x1122334455667788,
775            schema_id: 0x1501,
776        };
777        let mut buf = Vec::new();
778        result.ssz_append(&mut buf);
779
780        assert_eq!(
781            buf.len(),
782            SOL_RESULT_FIXED_LEN,
783            "result must be exactly 43 fixed bytes; a variable tail means \
784             ChainConfig is still in there"
785        );
786        assert_eq!(
787            buf[SOL_RESULT_SUCCESS_OFFSET], 1,
788            "successful_validation must be byte 32"
789        );
790        let chain_id = u64::from_le_bytes(
791            buf[SOL_RESULT_CHAIN_ID_OFFSET..SOL_RESULT_CHAIN_ID_OFFSET + 8]
792                .try_into()
793                .unwrap(),
794        );
795        assert_eq!(
796            chain_id, 0x1122334455667788,
797            "chain_id must be a direct u64 LE read at 33"
798        );
799        let schema_id = u16::from_le_bytes(
800            buf[SOL_RESULT_SCHEMA_ID_OFFSET..SOL_RESULT_SCHEMA_ID_OFFSET + 2]
801                .try_into()
802                .unwrap(),
803        );
804        assert_eq!(schema_id, 0x1501, "schema_id must be a u16 LE read at 41");
805    }
806
807    #[test]
808    fn nativerollup_sol_ep_offsets_match() {
809        // Encode a StatelessInput and confirm the ExecutionPayload fixed-field
810        // offsets the contract reads (relative to the EP absolute offset).
811        let ep = sample_execution_payload();
812        let npr = NewPayloadRequest {
813            execution_payload: ep,
814            versioned_hashes: ProgressiveList::new(),
815            parent_beacon_block_root: [0x00; 32],
816            execution_requests: ExecutionRequests {
817                deposits: ProgressiveList::new(),
818                withdrawals: ProgressiveList::new(),
819                consolidations: ProgressiveList::new(),
820                builder_deposits: ProgressiveList::new(),
821                builder_exits: ProgressiveList::new(),
822            },
823        };
824        let input = SszStatelessInput {
825            new_payload_request: npr,
826            witness: SszExecutionWitness {
827                state: ProgressiveList::new(),
828                codes: ProgressiveList::new(),
829                headers: SszList::new(),
830            },
831            chain_id: 1,
832        };
833        let mut buf = Vec::new();
834        input.ssz_append(&mut buf);
835
836        // StatelessInput fixed part = npr offset(4) + witness offset(4) +
837        // chain_id(8) = 16 bytes. new_payload_request is
838        // still field 0, so its offset is at byte 0 and the contract's dynamic
839        // read there is unaffected.
840        let npr_abs = u32_le(&buf, 0);
841        // NewPayloadRequest fixed prefix: execution_payload offset @ npr_abs.
842        let ep_abs = npr_abs + u32_le(&buf, npr_abs);
843        // The EP fixed FIELD offsets the contract reads must land where expected:
844        let actual_block_number = u64::from_le_bytes(
845            buf[ep_abs + SOL_EP_BLOCK_NUMBER_OFFSET..ep_abs + SOL_EP_BLOCK_NUMBER_OFFSET + 8]
846                .try_into()
847                .unwrap(),
848        );
849        assert_eq!(
850            actual_block_number, 7,
851            "block_number must be at EP offset 404, actual offset produces: {}",
852            actual_block_number
853        );
854        let actual_gas_limit = u64::from_le_bytes(
855            buf[ep_abs + SOL_EP_GAS_LIMIT_OFFSET..ep_abs + SOL_EP_GAS_LIMIT_OFFSET + 8]
856                .try_into()
857                .unwrap(),
858        );
859        assert_eq!(
860            actual_gas_limit, 30_000_000,
861            "gas_limit must be at EP offset 412, actual offset produces: {}",
862            actual_gas_limit
863        );
864        assert_eq!(
865            &buf[ep_abs + SOL_EP_STATE_ROOT_OFFSET..ep_abs + SOL_EP_STATE_ROOT_OFFSET + 32],
866            &[0x33; 32],
867            "state_root @52"
868        );
869        assert_eq!(
870            &buf[ep_abs + SOL_EP_BLOCK_HASH_OFFSET..ep_abs + SOL_EP_BLOCK_HASH_OFFSET + 32],
871            &[0x66; 32],
872            "block_hash @472"
873        );
874        // block_access_list's offset slot is at EP+528 (slot_number, a trailing fixed
875        // u64, follows it at EP+532). SSZ offsets are container-relative, so with all
876        // variable fields empty the block_access_list data starts at the fixed-prefix
877        // length (540) — this pins NativeRollup.sol's EP_FIXED_PREFIX_LEN.
878        assert_eq!(
879            u32_le(&buf, ep_abs + SOL_EP_BAL_OFFSET_POS),
880            SOL_EP_FIXED_PREFIX_LEN,
881            "block_access_list offset slot @EP+528 must equal the EP fixed-prefix length (540)",
882        );
883        // slot_number (EIP-7843) round-trips as a trailing fixed LE u64 at EP+532.
884        assert_eq!(
885            u64::from_le_bytes(
886                buf[ep_abs + SOL_EP_SLOT_NUMBER_OFFSET..ep_abs + SOL_EP_SLOT_NUMBER_OFFSET + 8]
887                    .try_into()
888                    .unwrap()
889            ),
890            0x7843,
891            "slot_number must be readable at EP+532",
892        );
893    }
894}