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`, `codes` and `public_keys` carry no element-count bound: #3356 made
384// them `ProgressiveList`, which grows without a declared capacity, so the
385// upstream `MAX_WITNESS_NODES` / `MAX_WITNESS_CODES` / `MAX_PUBLIC_KEYS`
386// constants were deleted along with it. The per-element byte caps below stay,
387// and `headers` keeps its count bound — it is deliberately still an `SszList`.
388
389/// MAX_BYTES_PER_WITNESS_NODE — max size of a single witness node.
390const MAX_BYTES_PER_WITNESS_NODE: usize = 1_048_576; // 2^20
391/// MAX_BYTES_PER_CODE — max size of a single code preimage (EIP-7954).
392const MAX_BYTES_PER_CODE: usize = 16_777_216; // 2^24
393/// MAX_WITNESS_HEADERS — max RLP-encoded block headers in witness (up to 256).
394const MAX_WITNESS_HEADERS: usize = 256;
395/// MAX_BYTES_PER_HEADER — max size of a single RLP-encoded header.
396const MAX_BYTES_PER_HEADER: usize = 1_024; // 2^10
397/// PUBLIC_KEY_BYTES — an uncompressed secp256k1 public key is 65 bytes.
398const PUBLIC_KEY_BYTES: usize = 65;
399
400// ── Stateless validation types ───────────────────────────────────
401//
402// Mirror the definitions in execution-specs (projects/zkevm branch) at the
403// commit EIP-8025 PR #11604 pins:
404// https://github.com/ethereum/execution-specs/blob/85fc20ca5937719a854472a87cb48d01ef1dffca/src/ethereum/forks/amsterdam/stateless_ssz.py
405
406/// Schema id of the stateless input wire format: `fork_index << 8 | revision`,
407/// where fork `0x15` is Amsterdam and revision `0x01` is the current encoding.
408///
409/// The 2-byte big-endian prefix is how the fork reaches the guest at all — #3278
410/// removed all chain configuration from the SSZ body — and it is echoed as a
411/// public output field so a verifier can pin which rules were applied. Upstream
412/// rejects any other id outright; so does ethrex.
413///
414/// Note that upstream keeps `0x1501` across incompatible body changes, so the id
415/// does **not** identify the encoding. Three dialects have shipped under it:
416/// `tests-zkevm@v0.6.2`, then #3248 + #3278, then #3356 (which moved `state`,
417/// `codes` and `public_keys` to `ProgressiveList`). ethrex speaks the last one.
418/// Releases since have only rearranged the Python that declares it, and SSZ
419/// encoding is positional, so renames and module moves leave the wire alone;
420/// which bundle is actually pinned lives in `tooling/ef_tests/.fixtures_url_zkevm`.
421/// A bundle from an older dialect will not be caught by this prefix — it fails
422/// later, in decode or on a mismatched root.
423pub const STATELESS_INPUT_SCHEMA_ID: u16 = 0x1501;
424
425/// Byte length of the big-endian [`STATELESS_INPUT_SCHEMA_ID`] prefix.
426pub const STATELESS_INPUT_SCHEMA_ID_SIZE: usize = 2;
427
428/// SSZ shape of `SszStatelessInput::public_keys`: one fixed-size 65-byte
429/// uncompressed secp256k1 key per transaction.
430///
431/// Aliased so consumers can name the type without restating it here.
432pub type SszPublicKeys = ProgressiveList<SszVector<u8, PUBLIC_KEY_BYTES>>;
433
434/// SSZ `ExecutionWitness` container matching the execution-specs definition.
435///
436/// Contains all data needed for stateless execution:
437/// - `state`: trie-node preimages
438/// - `codes`: contract code preimages
439/// - `headers`: RLP-encoded parent block headers (up to 256)
440///
441/// `state` and `codes` are `ProgressiveList` as of execution-specs #3356;
442/// `headers` stays a bounded `SszList`, since execution only ever exposes the
443/// previous 256 block hashes. The mixed shape is upstream's, not an oversight.
444#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
445pub struct SszExecutionWitness {
446    pub state: ProgressiveList<SszList<u8, MAX_BYTES_PER_WITNESS_NODE>>,
447    pub codes: ProgressiveList<SszList<u8, MAX_BYTES_PER_CODE>>,
448    pub headers: SszList<SszList<u8, MAX_BYTES_PER_HEADER>, MAX_WITNESS_HEADERS>,
449}
450
451/// SSZ `StatelessInput` — the top-level input to `verify_stateless_new_payload`.
452///
453/// Wraps a `NewPayloadRequest` together with the execution witness,
454/// chain configuration, and (optionally) pre-recovered public keys.
455#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
456pub struct SszStatelessInput {
457    pub new_payload_request: NewPayloadRequest,
458    pub witness: SszExecutionWitness,
459    /// Chain identifier. #3278 removed `ChainConfig` from the wire: fork
460    /// activation info and blob schedules are guest-internal, keyed by
461    /// `(chain_id, fork)`, with the fork coming from the schema-id prefix.
462    pub chain_id: u64,
463    pub public_keys: SszPublicKeys,
464}
465
466/// SSZ `StatelessValidationResult` — the output of `verify_stateless_new_payload`.
467#[derive(Debug, Clone, Default, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
468pub struct SszStatelessValidationResult {
469    pub new_payload_request_root: [u8; 32],
470    pub successful_validation: bool,
471    /// Chain identifier echoed from the input — real even when validation fails;
472    /// only a decode failure yields the all-zero default.
473    pub chain_id: u64,
474    /// The full schema id the guest decoded (`0x1501` for Amsterdam revision 1).
475    /// Public so a verifier can pin which fork rules and encoding were used, for
476    /// forks that share a payload shape. Added by execution-specs #3278.
477    pub schema_id: u16,
478}
479
480// ── Conversions to internal types ────────────────────────────────
481
482impl SszExecutionWitness {
483    /// Extract raw bytes from SSZ lists for codes.
484    pub fn codes_as_vecs(&self) -> Vec<Vec<u8>> {
485        self.codes.iter().map(|c| c.to_vec()).collect()
486    }
487
488    /// Extract raw bytes from SSZ lists for headers.
489    pub fn headers_as_vecs(&self) -> Vec<Vec<u8>> {
490        self.headers.iter().map(|h| h.to_vec()).collect()
491    }
492
493    /// Extract raw bytes from SSZ lists for state nodes.
494    pub fn state_as_vecs(&self) -> Vec<Vec<u8>> {
495        self.state.iter().map(|n| n.to_vec()).collect()
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use libssz_merkle::Sha2Hasher;
503
504    const HASHER: Sha2Hasher = Sha2Hasher;
505
506    // ── EIP-8025 helpers ─────────────────────────────────────────
507
508    fn sample_payload() -> ExecutionPayload {
509        ExecutionPayload {
510            parent_hash: [1u8; 32],
511            fee_recipient: Bytes20([2u8; 20]),
512            state_root: [3u8; 32],
513            receipts_root: [4u8; 32],
514            logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom length"),
515            prev_randao: [5u8; 32],
516            block_number: 42,
517            gas_limit: 30_000_000,
518            gas_used: 21_000,
519            timestamp: 1_700_000_000,
520            extra_data: vec![0xAB, 0xCD].try_into().expect("extra_data fits"),
521            base_fee_per_gas: {
522                let mut b = [0u8; 32];
523                b[0] = 7; // 7 in LE
524                b
525            },
526            block_hash: [6u8; 32],
527            transactions: vec![
528                vec![0xDE, 0xAD, 0xBE, 0xEF]
529                    .try_into()
530                    .expect("tx bytes fit"),
531            ]
532            .try_into()
533            .expect("txs fit"),
534            withdrawals: vec![Withdrawal {
535                index: 0,
536                validator_index: 1,
537                address: Bytes20([7u8; 20]),
538                amount: 1_000_000,
539            }]
540            .try_into()
541            .expect("withdrawals fit"),
542            blob_gas_used: 0,
543            excess_blob_gas: 0,
544            block_access_list: ProgressiveList::new(), // TODO(Plan 02): populate full BAL
545            slot_number: 0,
546        }
547    }
548
549    fn empty_requests() -> ExecutionRequests {
550        ExecutionRequests {
551            deposits: Vec::new().into(),
552            withdrawals: Vec::new().into(),
553            consolidations: Vec::new().into(),
554            builder_deposits: Vec::new().into(),
555            builder_exits: Vec::new().into(),
556        }
557    }
558
559    fn sample_request() -> NewPayloadRequest {
560        NewPayloadRequest {
561            execution_payload: sample_payload(),
562            versioned_hashes: Vec::new().into(),
563            parent_beacon_block_root: [8u8; 32],
564            execution_requests: empty_requests(),
565        }
566    }
567
568    #[test]
569    fn test_ssz_root_changes_with_different_data() {
570        let request1 = sample_request();
571        let mut request2 = sample_request();
572        request2.execution_payload.block_number = 99;
573
574        assert_ne!(
575            request1.hash_tree_root(&HASHER),
576            request2.hash_tree_root(&HASHER),
577            "Different payloads must produce different roots"
578        );
579    }
580
581    #[test]
582    fn test_ssz_root_is_deterministic() {
583        let request = sample_request();
584        let root1 = request.hash_tree_root(&HASHER);
585        let root2 = request.hash_tree_root(&HASHER);
586        assert_eq!(root1, root2, "Same request must produce same root");
587    }
588
589    #[test]
590    fn test_execution_requests_to_encoded_bytes() {
591        let requests = ExecutionRequests {
592            deposits: vec![DepositRequest {
593                pubkey: [0x11; 48],
594                withdrawal_credentials: [0x22; 32],
595                amount: 32_000_000_000,
596                signature: [0x33; 96],
597                index: 7,
598            }]
599            .into(),
600            withdrawals: vec![WithdrawalRequest {
601                source_address: Bytes20([0x44; 20]),
602                validator_pubkey: [0x55; 48],
603                amount: 1_000_000,
604            }]
605            .into(),
606            consolidations: vec![ConsolidationRequest {
607                source_address: Bytes20([0x66; 20]),
608                source_pubkey: [0x77; 48],
609                target_pubkey: [0x88; 48],
610            }]
611            .into(),
612            builder_deposits: ProgressiveList::new(),
613            builder_exits: ProgressiveList::new(),
614        };
615
616        let encoded = requests.to_encoded_requests();
617        // Five entries since EIP-8282: the two builder lists are empty here, and
618        // `compute_requests_hash` skips entries of length <= 1, so an empty
619        // builder list leaves the requests hash unchanged.
620        assert_eq!(encoded.len(), 5, "must emit 5 EIP-7685 entries");
621
622        // Deposit: [0x00] ++ 192 bytes
623        assert_eq!(encoded[0].0[0], DEPOSIT_REQUEST_TYPE);
624        assert_eq!(encoded[0].0.len(), 1 + 192);
625
626        // Withdrawal: [0x01] ++ 76 bytes
627        assert_eq!(encoded[1].0[0], WITHDRAWAL_REQUEST_TYPE);
628        assert_eq!(encoded[1].0.len(), 1 + 76);
629
630        // Consolidation: [0x02] ++ 116 bytes
631        assert_eq!(encoded[2].0[0], CONSOLIDATION_REQUEST_TYPE);
632        assert_eq!(encoded[2].0.len(), 1 + 116);
633    }
634
635    // ── Stateless helpers ────────────────────────────────────────
636
637    fn list<T: SszEncode + SszDecode, const N: usize>(items: Vec<T>) -> SszList<T, N> {
638        let mut list = SszList::new();
639        for item in items {
640            list.push(item).expect("test list capacity exceeded");
641        }
642        list
643    }
644
645    fn round_trip<T: SszEncode + SszDecode + PartialEq + std::fmt::Debug>(value: &T) {
646        let mut buf = Vec::new();
647        value.ssz_append(&mut buf);
648        let decoded = T::from_ssz_bytes(&buf).expect("SSZ decode failed");
649        assert_eq!(*value, decoded, "round-trip mismatch");
650    }
651
652    #[test]
653    fn ssz_execution_witness_round_trip() {
654        let witness = SszExecutionWitness {
655            state: vec![list(vec![1u8, 2, 3]), list(vec![4u8, 5])].into(),
656            codes: vec![list(vec![0x60u8, 0x00, 0x60, 0x00, 0xf3])].into(),
657            headers: list(vec![list(vec![0xf9u8, 0x02, 0x11])]),
658        };
659        round_trip(&witness);
660    }
661
662    #[test]
663    fn ssz_execution_witness_empty_round_trip() {
664        let witness = SszExecutionWitness {
665            state: ProgressiveList::new(),
666            codes: ProgressiveList::new(),
667            headers: SszList::new(),
668        };
669        round_trip(&witness);
670    }
671
672    #[test]
673    fn ssz_execution_payload_has_block_access_list_round_trip() {
674        let payload = ExecutionPayload {
675            parent_hash: [0x11; 32],
676            fee_recipient: Bytes20([0x22; 20]),
677            state_root: [0x33; 32],
678            receipts_root: [0x44; 32],
679            logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom length"),
680            prev_randao: [0x55; 32],
681            block_number: 7,
682            gas_limit: 30_000_000,
683            gas_used: 21_000,
684            timestamp: 1_700_000_000,
685            extra_data: list(vec![0xde, 0xad]),
686            base_fee_per_gas: [0u8; 32],
687            block_hash: [0x66; 32],
688            transactions: ProgressiveList::new(),
689            withdrawals: ProgressiveList::new(),
690            blob_gas_used: 0,
691            excess_blob_gas: 0,
692            block_access_list: vec![0x01u8, 0x02, 0x03].into(),
693            slot_number: 0,
694        };
695        round_trip(&payload);
696        assert_eq!(payload.block_access_list.len(), 3);
697    }
698
699    #[test]
700    fn ssz_public_keys_are_65_byte_vectors_round_trip() {
701        let key: SszVector<u8, PUBLIC_KEY_BYTES> =
702            vec![0x04u8; 65].try_into().expect("pubkey length");
703        let mut keys: SszPublicKeys = ProgressiveList::new();
704        keys.push(key);
705        round_trip(&keys);
706        assert_eq!(keys.first().unwrap().len(), 65);
707    }
708
709    #[test]
710    fn ssz_stateless_validation_result_round_trip() {
711        let result = SszStatelessValidationResult {
712            new_payload_request_root: [0xab; 32],
713            successful_validation: true,
714            chain_id: 42,
715            schema_id: 0x1501,
716        };
717        round_trip(&result);
718
719        let result_false = SszStatelessValidationResult {
720            new_payload_request_root: [0x00; 32],
721            successful_validation: false,
722            chain_id: 1,
723            schema_id: 0x1501,
724        };
725        round_trip(&result_false);
726    }
727
728    // ── NativeRollup.sol SSZ offset cross-checks (I17) ───────────────
729    //
730    // These constants MUST equal NativeRollup.sol's SSZ offset constants
731    // (crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol). If a container
732    // field is reordered/resized, this test fails instead of advance() silently
733    // reading the wrong bytes on L1.
734
735    const SOL_RESULT_SUCCESS_OFFSET: usize = 32;
736    // Since #3278 the result is entirely fixed-size (32 + 1 + 8 + 2 = 43 bytes):
737    // chain_id is read directly at 33, schema_id at 41. There is no longer an
738    // offset to dereference.
739    const SOL_RESULT_CHAIN_ID_OFFSET: usize = 33;
740    const SOL_RESULT_SCHEMA_ID_OFFSET: usize = 41;
741    const SOL_RESULT_FIXED_LEN: usize = 43;
742    const SOL_EP_STATE_ROOT_OFFSET: usize = 52;
743    const SOL_EP_BLOCK_NUMBER_OFFSET: usize = 404;
744    const SOL_EP_GAS_LIMIT_OFFSET: usize = 412;
745    const SOL_EP_BLOCK_HASH_OFFSET: usize = 472;
746    // block_access_list's offset slot sits at EP+528; slot_number (EIP-7843) is the
747    // trailing fixed u64 at EP+532; the fixed prefix is 540.
748    const SOL_EP_BAL_OFFSET_POS: usize = 528;
749    const SOL_EP_SLOT_NUMBER_OFFSET: usize = 532;
750    const SOL_EP_FIXED_PREFIX_LEN: usize = 540;
751
752    fn u32_le(bytes: &[u8], off: usize) -> usize {
753        (bytes[off] as usize)
754            | ((bytes[off + 1] as usize) << 8)
755            | ((bytes[off + 2] as usize) << 16)
756            | ((bytes[off + 3] as usize) << 24)
757    }
758
759    fn sample_execution_payload() -> ExecutionPayload {
760        ExecutionPayload {
761            parent_hash: [0x11; 32],
762            fee_recipient: Bytes20([0x22; 20]),
763            state_root: [0x33; 32],
764            receipts_root: [0x44; 32],
765            logs_bloom: vec![0u8; 256].try_into().expect("bloom"),
766            prev_randao: [0x55; 32],
767            block_number: 7,
768            gas_limit: 30_000_000,
769            gas_used: 21_000,
770            timestamp: 1_700_000_000,
771            extra_data: SszList::new(),
772            base_fee_per_gas: [0u8; 32],
773            block_hash: [0x66; 32],
774            transactions: ProgressiveList::new(),
775            withdrawals: ProgressiveList::new(),
776            blob_gas_used: 0,
777            excess_blob_gas: 0,
778            block_access_list: ProgressiveList::new(),
779            slot_number: 0x7843,
780        }
781    }
782
783    #[test]
784    fn nativerollup_sol_result_layout_matches() {
785        // Encode a StatelessValidationResult and confirm the contract's fixed
786        // offsets. Under #3278 every field is fixed-size, so all three are direct
787        // reads and the total length is exactly 43 bytes.
788        let result = SszStatelessValidationResult {
789            new_payload_request_root: [0xAA; 32],
790            successful_validation: true,
791            chain_id: 0x1122334455667788,
792            schema_id: 0x1501,
793        };
794        let mut buf = Vec::new();
795        result.ssz_append(&mut buf);
796
797        assert_eq!(
798            buf.len(),
799            SOL_RESULT_FIXED_LEN,
800            "result must be exactly 43 fixed bytes; a variable tail means \
801             ChainConfig is still in there"
802        );
803        assert_eq!(
804            buf[SOL_RESULT_SUCCESS_OFFSET], 1,
805            "successful_validation must be byte 32"
806        );
807        let chain_id = u64::from_le_bytes(
808            buf[SOL_RESULT_CHAIN_ID_OFFSET..SOL_RESULT_CHAIN_ID_OFFSET + 8]
809                .try_into()
810                .unwrap(),
811        );
812        assert_eq!(
813            chain_id, 0x1122334455667788,
814            "chain_id must be a direct u64 LE read at 33"
815        );
816        let schema_id = u16::from_le_bytes(
817            buf[SOL_RESULT_SCHEMA_ID_OFFSET..SOL_RESULT_SCHEMA_ID_OFFSET + 2]
818                .try_into()
819                .unwrap(),
820        );
821        assert_eq!(schema_id, 0x1501, "schema_id must be a u16 LE read at 41");
822    }
823
824    #[test]
825    fn nativerollup_sol_ep_offsets_match() {
826        // Encode a StatelessInput and confirm the ExecutionPayload fixed-field
827        // offsets the contract reads (relative to the EP absolute offset).
828        let ep = sample_execution_payload();
829        let npr = NewPayloadRequest {
830            execution_payload: ep,
831            versioned_hashes: ProgressiveList::new(),
832            parent_beacon_block_root: [0x00; 32],
833            execution_requests: ExecutionRequests {
834                deposits: ProgressiveList::new(),
835                withdrawals: ProgressiveList::new(),
836                consolidations: ProgressiveList::new(),
837                builder_deposits: ProgressiveList::new(),
838                builder_exits: ProgressiveList::new(),
839            },
840        };
841        let input = SszStatelessInput {
842            new_payload_request: npr,
843            witness: SszExecutionWitness {
844                state: ProgressiveList::new(),
845                codes: ProgressiveList::new(),
846                headers: SszList::new(),
847            },
848            chain_id: 1,
849            public_keys: ProgressiveList::new(),
850        };
851        let mut buf = Vec::new();
852        input.ssz_append(&mut buf);
853
854        // StatelessInput fixed part = npr offset(4) + witness offset(4) +
855        // chain_id(8) + public_keys offset(4) = 20 bytes. new_payload_request is
856        // still field 0, so its offset is at byte 0 and the contract's dynamic
857        // read there is unaffected.
858        let npr_abs = u32_le(&buf, 0);
859        // NewPayloadRequest fixed prefix: execution_payload offset @ npr_abs.
860        let ep_abs = npr_abs + u32_le(&buf, npr_abs);
861        // The EP fixed FIELD offsets the contract reads must land where expected:
862        let actual_block_number = u64::from_le_bytes(
863            buf[ep_abs + SOL_EP_BLOCK_NUMBER_OFFSET..ep_abs + SOL_EP_BLOCK_NUMBER_OFFSET + 8]
864                .try_into()
865                .unwrap(),
866        );
867        assert_eq!(
868            actual_block_number, 7,
869            "block_number must be at EP offset 404, actual offset produces: {}",
870            actual_block_number
871        );
872        let actual_gas_limit = u64::from_le_bytes(
873            buf[ep_abs + SOL_EP_GAS_LIMIT_OFFSET..ep_abs + SOL_EP_GAS_LIMIT_OFFSET + 8]
874                .try_into()
875                .unwrap(),
876        );
877        assert_eq!(
878            actual_gas_limit, 30_000_000,
879            "gas_limit must be at EP offset 412, actual offset produces: {}",
880            actual_gas_limit
881        );
882        assert_eq!(
883            &buf[ep_abs + SOL_EP_STATE_ROOT_OFFSET..ep_abs + SOL_EP_STATE_ROOT_OFFSET + 32],
884            &[0x33; 32],
885            "state_root @52"
886        );
887        assert_eq!(
888            &buf[ep_abs + SOL_EP_BLOCK_HASH_OFFSET..ep_abs + SOL_EP_BLOCK_HASH_OFFSET + 32],
889            &[0x66; 32],
890            "block_hash @472"
891        );
892        // block_access_list's offset slot is at EP+528 (slot_number, a trailing fixed
893        // u64, follows it at EP+532). SSZ offsets are container-relative, so with all
894        // variable fields empty the block_access_list data starts at the fixed-prefix
895        // length (540) — this pins NativeRollup.sol's EP_FIXED_PREFIX_LEN.
896        assert_eq!(
897            u32_le(&buf, ep_abs + SOL_EP_BAL_OFFSET_POS),
898            SOL_EP_FIXED_PREFIX_LEN,
899            "block_access_list offset slot @EP+528 must equal the EP fixed-prefix length (540)",
900        );
901        // slot_number (EIP-7843) round-trips as a trailing fixed LE u64 at EP+532.
902        assert_eq!(
903            u64::from_le_bytes(
904                buf[ep_abs + SOL_EP_SLOT_NUMBER_OFFSET..ep_abs + SOL_EP_SLOT_NUMBER_OFFSET + 8]
905                    .try_into()
906                    .unwrap()
907            ),
908            0x7843,
909            "slot_number must be readable at EP+532",
910        );
911    }
912}