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