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