Skip to main content

ethrex_levm/
execute_precompile.rs

1//! EXECUTE precompile for Native Rollups — aligned with the l2beat spec.
2//!
3//! This is a thin wrapper around `verify_stateless_new_payload` (accessed via
4//! the `StatelessValidator` trait). The precompile:
5//! 1. Deserializes SSZ `StatelessInput` once (to read gas_limit and check L2 constraints)
6//! 2. Validates L2-specific constraints (no blobs, no withdrawals, etc.)
7//! 3. Charges gas equal to `gas_limit + calldata.len() * EXECUTE_GAS_PER_WITNESS_BYTE`
8//! 4. Delegates to the `StatelessValidator` trait, passing the already-decoded input
9//! 5. On success returns the SSZ-encoded `StatelessValidationResult`.
10//!
11//! Fail-closed semantics: both invalid input and a failed validation return an
12//! `ExceptionalHalt` (CALL-level `success=false`), so a `require(success)` caller
13//! is safe. Invalid input (undecodable SSZ or an L2-constraint violation) is
14//! rejected in steps 1-2, *before* the step-3 gas charge — those checks are cheap
15//! and already priced by the L1 calldata gas the attacker paid to submit the blob.
16//! A *failed validation* (step 4) is rejected *after* the step-3 charge, so an
17//! attacker who forces full re-execution still pays for it: that is the DoS bound.
18
19use bytes::Bytes;
20
21use ethrex_common::types::Fork;
22use ethrex_crypto::Crypto;
23
24use crate::errors::{InternalError, PrecompileError, VMError};
25use crate::precompiles::increase_precompile_consumed_gas;
26
27/// WIP DA/decode cost — the SSZ witness decode + trie rebuild scales with input size;
28/// 16 mirrors calldata non-zero byte cost. Tune when EIP-8079 pricing is finalized.
29pub const EXECUTE_GAS_PER_WITNESS_BYTE: u64 = 16;
30
31/// EXECUTE precompile entrypoint.
32///
33/// Input: SSZ-encoded `StatelessInput`.
34/// Output: SSZ-encoded `StatelessValidationResult`.
35///
36/// This wrapper exists to mimic the standard precompile signature
37/// (`fn(&Bytes, &mut u64, Fork, &dyn Crypto) -> Result<Bytes, VMError>`) plus
38/// the extra `stateless_validator` slot threaded through the dispatcher. It
39/// also converts the `Option` into an error, so `run_execute` can work with a
40/// guaranteed-`Some` validator.
41pub fn execute_precompile(
42    calldata: &Bytes,
43    gas_remaining: &mut u64,
44    _fork: Fork,
45    _crypto: &dyn Crypto,
46    stateless_validator: Option<&dyn crate::StatelessValidator>,
47) -> Result<Bytes, VMError> {
48    let validator = stateless_validator.ok_or_else(|| {
49        VMError::Internal(InternalError::Custom(
50            "EXECUTE precompile requires a StatelessValidator but none was provided".to_string(),
51        ))
52    })?;
53    run_execute(validator, calldata, gas_remaining)
54}
55
56/// Validate L2 constraints, charge gas, delegate.
57fn run_execute(
58    validator: &dyn crate::StatelessValidator,
59    calldata: &Bytes,
60    gas_remaining: &mut u64,
61) -> Result<Bytes, VMError> {
62    use ethrex_common::types::stateless_ssz::{
63        STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_SIZE, SszStatelessInput,
64    };
65    use libssz::SszDecode;
66
67    // Schema-prefixed `statelessInputBytes`: a 2-byte big-endian schema id, then
68    // the SSZ body. Since execution-specs #3278 the prefix is the only thing
69    // identifying the fork, so an unexpected id must be rejected. All of it is
70    // attacker-controlled, hence CALL-level failures rather than invariants.
71    let (schema_bytes, body) = calldata
72        .split_first_chunk::<STATELESS_INPUT_SCHEMA_ID_SIZE>()
73        .ok_or(VMError::from(PrecompileError::ExecuteInvalidInput))?;
74    let schema_id = u16::from_be_bytes(*schema_bytes);
75    if schema_id != STATELESS_INPUT_SCHEMA_ID {
76        return Err(VMError::from(PrecompileError::ExecuteInvalidInput));
77    }
78
79    let input = SszStatelessInput::from_ssz_bytes(body)
80        .map_err(|_| VMError::from(PrecompileError::ExecuteInvalidInput))?;
81
82    validate_l2_constraints(&input)?;
83
84    // `gas_used` is attacker-controlled and does NOT bound verify()'s work:
85    // a malicious sequencer can set gas_used=0 while verify() still does full
86    // native re-execution (bounded by gas_limit), witness decode, and trie rebuild.
87    // `gas_limit` IS the true upper bound on EVM re-execution (execute_block
88    // enforces the block gas limit). The per-byte term bounds witness decode and
89    // trie rebuild costs that scale with input size.
90    let gas_limit = input.new_payload_request.execution_payload.gas_limit;
91    #[expect(
92        clippy::as_conversions,
93        reason = "usize to u64: safe on 64-bit targets; calldata len cannot exceed u64::MAX"
94    )]
95    let witness_charge = (calldata.len() as u64).saturating_mul(EXECUTE_GAS_PER_WITNESS_BYTE);
96    let charge = gas_limit.saturating_add(witness_charge);
97    increase_precompile_consumed_gas(charge, gas_remaining)?;
98
99    // Hand the already-decoded `input` to the validator so it doesn't re-parse
100    // the (potentially large) witness a second time.
101    let result = validator.verify(&input)?;
102
103    use ethrex_common::types::stateless_ssz::SszStatelessValidationResult;
104    let parsed = SszStatelessValidationResult::from_ssz_bytes(&result).map_err(|e| {
105        VMError::Internal(InternalError::Custom(format!(
106            "EXECUTE: bad validation result: {e}"
107        )))
108    })?;
109    if !parsed.successful_validation {
110        // Attacker-controlled: validation failure must be a CALL-level failure so the tx is
111        // includable and the attacker pays Task 1's gas charge. `PrecompileError` → `ExceptionalHalt`
112        // → should_propagate()==false. Using `Internal` here would abort the tx, making it
113        // non-includable and defeating the DoS bound.
114        return Err(PrecompileError::ExecuteValidationFailed.into());
115    }
116
117    Ok(Bytes::from(result))
118}
119
120/// Validate L2-specific constraints on the ExecutionPayload.
121fn validate_l2_constraints(
122    input: &ethrex_common::types::stateless_ssz::SszStatelessInput,
123) -> Result<(), VMError> {
124    let payload = &input.new_payload_request.execution_payload;
125
126    // All checks below are attacker-controllable input violations — CALL-level failures.
127    if payload.blob_gas_used != 0 {
128        return Err(PrecompileError::ExecuteInvalidInput.into());
129    }
130    if payload.excess_blob_gas != 0 {
131        return Err(PrecompileError::ExecuteInvalidInput.into());
132    }
133    if !payload.withdrawals.is_empty() {
134        return Err(PrecompileError::ExecuteInvalidInput.into());
135    }
136    let reqs = &input.new_payload_request.execution_requests;
137    if !reqs.deposits.is_empty()
138        || !reqs.withdrawals.is_empty()
139        || !reqs.consolidations.is_empty()
140        || !reqs.builder_deposits.is_empty()
141        || !reqs.builder_exits.is_empty()
142    {
143        return Err(PrecompileError::ExecuteInvalidInput.into());
144    }
145    for tx_bytes in payload.transactions.iter() {
146        // Rejected by leading EIP-2718 envelope byte: `0x03` (EIP-4844) because
147        // L2 blocks carry no blobs, and `0x06` (frame) / `0x7e` (privileged)
148        // because they carry an explicit sender and no signature — the stateless
149        // input commits one public key per transaction, so admitting them would
150        // make a well-formed block unrepresentable. Native rollups relay L1→L2
151        // messages as signed EIP-1559 transactions, so this costs no function.
152        if let Some(&(0x03 | 0x06 | 0x7e)) = tx_bytes.iter().next() {
153            return Err(PrecompileError::ExecuteInvalidInput.into());
154        }
155    }
156
157    Ok(())
158}
159
160#[cfg(test)]
161#[allow(
162    clippy::unwrap_used,
163    clippy::expect_used,
164    clippy::arithmetic_side_effects,
165    clippy::as_conversions
166)]
167mod tests {
168    use super::{EXECUTE_GAS_PER_WITNESS_BYTE, run_execute};
169    use bytes::Bytes;
170    use ethrex_common::types::stateless_ssz::{
171        BuilderDepositRequest, BuilderExitRequest, Bytes20, DepositRequest, ExecutionPayload,
172        ExecutionRequests, NewPayloadRequest, SszExecutionWitness, SszStatelessInput,
173        SszStatelessValidationResult, Withdrawal,
174    };
175    use libssz::SszEncode;
176
177    /// Mock `StatelessValidator` that returns a successful SSZ-encoded
178    /// `SszStatelessValidationResult` without performing any real execution.
179    struct MockValidator;
180
181    /// Mock `StatelessValidator` that returns `successful_validation: false`.
182    struct MockInvalidValidator;
183
184    impl crate::StatelessValidator for MockValidator {
185        fn verify(&self, _input: &SszStatelessInput) -> Result<Vec<u8>, crate::errors::VMError> {
186            let result = SszStatelessValidationResult {
187                new_payload_request_root: [0u8; 32],
188                successful_validation: true,
189                chain_id: 1,
190                schema_id: 0x1501,
191            };
192            let mut buf = Vec::new();
193            result.ssz_append(&mut buf);
194            Ok(buf)
195        }
196    }
197
198    /// Encode an input as `statelessInputBytes`: the 2-byte big-endian schema id
199    /// followed by the SSZ body. Every test must go through this — a bare body is
200    /// rejected at the prefix check, which would silently turn each constraint
201    /// test below into a test of the prefix check.
202    fn calldata_for(input: &SszStatelessInput) -> Bytes {
203        let mut buf = ethrex_common::types::stateless_ssz::STATELESS_INPUT_SCHEMA_ID
204            .to_be_bytes()
205            .to_vec();
206        input.ssz_append(&mut buf);
207        Bytes::from(buf)
208    }
209
210    /// Assert an error is the constraint rejection `ExecuteInvalidInput`, and is a
211    /// CALL-level failure (includable, attacker pays) rather than a tx-abort.
212    fn assert_invalid_input(err: &crate::errors::VMError, what: &str) {
213        assert_eq!(
214            err,
215            &crate::errors::VMError::from(crate::errors::PrecompileError::ExecuteInvalidInput),
216            "{what} must be rejected as ExecuteInvalidInput; got: {err:?}"
217        );
218        assert!(
219            !err.should_propagate(),
220            "{what} must be a CALL-level failure (non-propagating); got: {err:?}"
221        );
222    }
223
224    /// Build a minimal L2-valid `SszStatelessInput` with the given gas fields.
225    /// No blobs, no withdrawals, no execution requests — satisfies all
226    /// `validate_l2_constraints` checks.
227    fn l2_valid_input(gas_used: u64, gas_limit: u64) -> SszStatelessInput {
228        SszStatelessInput {
229            new_payload_request: NewPayloadRequest {
230                execution_payload: ExecutionPayload {
231                    parent_hash: [0u8; 32],
232                    fee_recipient: Bytes20([0u8; 20]),
233                    state_root: [0u8; 32],
234                    receipts_root: [0u8; 32],
235                    logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom"),
236                    prev_randao: [0u8; 32],
237                    block_number: 1,
238                    gas_limit,
239                    gas_used,
240                    timestamp: 1_000_000,
241                    extra_data: vec![].try_into().expect("extra_data"),
242                    base_fee_per_gas: [0u8; 32],
243                    block_hash: [0u8; 32],
244                    transactions: Vec::new().into(),
245                    withdrawals: Vec::new().into(),
246                    blob_gas_used: 0,
247                    excess_blob_gas: 0,
248                    block_access_list: Vec::new().into(),
249                    slot_number: 0,
250                },
251                versioned_hashes: Vec::new().into(),
252                parent_beacon_block_root: [0u8; 32],
253                execution_requests: ExecutionRequests {
254                    deposits: Vec::new().into(),
255                    withdrawals: Vec::new().into(),
256                    consolidations: Vec::new().into(),
257                    builder_deposits: Vec::new().into(),
258                    builder_exits: Vec::new().into(),
259                },
260            },
261            witness: SszExecutionWitness {
262                state: vec![].try_into().expect("state"),
263                codes: vec![].try_into().expect("codes"),
264                headers: vec![].try_into().expect("headers"),
265            },
266            chain_id: 1,
267            public_keys: vec![].try_into().expect("public_keys"),
268        }
269    }
270
271    impl crate::StatelessValidator for MockInvalidValidator {
272        fn verify(&self, _input: &SszStatelessInput) -> Result<Vec<u8>, crate::errors::VMError> {
273            let result = SszStatelessValidationResult {
274                new_payload_request_root: [0u8; 32],
275                successful_validation: false,
276                chain_id: 1,
277                schema_id: 0x1501,
278            };
279            let mut buf = Vec::new();
280            result.ssz_append(&mut buf);
281            Ok(buf)
282        }
283    }
284
285    /// Fail-closed: `run_execute` must return `Err` when `successful_validation` is `false`,
286    /// and `Ok` when it is `true`.
287    #[test]
288    fn execute_fails_closed_on_invalid() {
289        let input = l2_valid_input(0, 1_000_000);
290        let calldata = calldata_for(&input);
291
292        // Invalid result → must Err (fail-closed) with a NON-propagating error so the tx is
293        // INCLUDABLE and the attacker pays Task 1's gas charge (I1×I13 regression guard).
294        let mut gas = 100_000_000u64;
295        let mock_invalid = MockInvalidValidator;
296        let r = run_execute(&mock_invalid, &calldata, &mut gas);
297        let err = r.expect_err("invalid validation must revert (fail-closed), not return Ok");
298        assert!(
299            !err.should_propagate(),
300            "invalid EXECUTE must be a CALL-level failure (includable, attacker pays), not a tx-abort; got: {err:?}"
301        );
302
303        // Valid result → must Ok.
304        let mut gas2 = 100_000_000u64;
305        let mock_valid = MockValidator;
306        let r2 = run_execute(&mock_valid, &calldata, &mut gas2);
307        assert!(r2.is_ok(), "valid validation must return Ok(result)");
308    }
309
310    /// `gas_used=0, gas_limit=1_000_000`: the charge must be `gas_limit +
311    /// calldata.len() * EXECUTE_GAS_PER_WITNESS_BYTE`, NOT 0 (the old
312    /// `gas_used`-based charge). Proves the fix bounds verify()'s work.
313    #[test]
314    fn execute_charges_gas_limit_not_gas_used() {
315        let input = l2_valid_input(0, 1_000_000);
316        let calldata = calldata_for(&input);
317
318        let start_gas = 100_000_000u64;
319        let mut gas_remaining = start_gas;
320        let mock = MockValidator;
321        let _ = run_execute(&mock, &calldata, &mut gas_remaining);
322
323        let charged = start_gas.saturating_sub(gas_remaining);
324        let expected = 1_000_000u64.saturating_add(
325            u64::try_from(calldata.len())
326                .expect("calldata len fits u64")
327                .saturating_mul(EXECUTE_GAS_PER_WITNESS_BYTE),
328        );
329
330        // Must charge at least gas_limit, NOT 0 (old gas_used-based charge).
331        assert!(
332            charged >= 1_000_000,
333            "must charge at least gas_limit, got {charged}"
334        );
335        // Must equal gas_limit + per-byte witness charge exactly.
336        assert_eq!(
337            charged, expected,
338            "charge must be gas_limit + calldata.len() * EXECUTE_GAS_PER_WITNESS_BYTE"
339        );
340    }
341
342    /// I1 regression (moved from `stateless.rs`) + ElFantasma malformed-SSZ ask:
343    /// `run_execute` must fail closed at the CALL level (non-propagating) when
344    /// the calldata is not a decodable SSZ `StatelessInput`, rejecting it at the
345    /// up-front decode *before* the validator runs — so a garbage or
346    /// bad-offset blob can never be turned into wrong proven values, and the
347    /// error is includable rather than a tx-abort.
348    #[test]
349    fn execute_rejects_malformed_ssz_input() {
350        // 4 bytes of 0xff: an out-of-range SSZ offset, undecodable as StatelessInput.
351        let calldata = Bytes::from(vec![0xffu8, 0xff, 0xff, 0xff]);
352        let mut gas = 100_000_000u64;
353        let err = run_execute(&MockValidator, &calldata, &mut gas)
354            .expect_err("malformed SSZ input must be rejected");
355        assert_invalid_input(&err, "malformed SSZ input");
356    }
357
358    // ── Negative constraint tests (I11) ──────────────────────────────────────
359    // Each test builds a fully valid `SszStatelessInput`, violates exactly ONE
360    // `validate_l2_constraints` check, and asserts:
361    //   (a) `run_execute` returns `Err`, and
362    //   (b) the error is non-propagating (CALL-level failure, so the tx is
363    //       includable rather than aborted). Note: a constraint violation is
364    //       rejected *before* the precompile gas charge (cheap reject, already
365    //       priced by L1 calldata gas), unlike a failed *validation*, which is
366    //       charged first — see the module-level fail-closed note.
367
368    /// Constraint 1: `blob_gas_used` must be zero.
369    #[test]
370    fn execute_rejects_blob_gas_used_nonzero() {
371        let mut input = l2_valid_input(0, 1_000_000);
372        input.new_payload_request.execution_payload.blob_gas_used = 1;
373        let calldata = calldata_for(&input);
374        let mut gas = 100_000_000u64;
375        let err = run_execute(&MockValidator, &calldata, &mut gas)
376            .expect_err("blob_gas_used != 0 must be rejected");
377        assert_invalid_input(&err, "blob_gas_used != 0");
378    }
379
380    /// Constraint 2: `excess_blob_gas` must be zero.
381    #[test]
382    fn execute_rejects_excess_blob_gas_nonzero() {
383        let mut input = l2_valid_input(0, 1_000_000);
384        input.new_payload_request.execution_payload.excess_blob_gas = 1;
385        let calldata = calldata_for(&input);
386        let mut gas = 100_000_000u64;
387        let err = run_execute(&MockValidator, &calldata, &mut gas)
388            .expect_err("excess_blob_gas != 0 must be rejected");
389        assert_invalid_input(&err, "excess_blob_gas != 0");
390    }
391
392    /// Constraint 3: `withdrawals` list must be empty.
393    #[test]
394    fn execute_rejects_nonempty_withdrawals() {
395        let mut input = l2_valid_input(0, 1_000_000);
396        input.new_payload_request.execution_payload.withdrawals = vec![Withdrawal {
397            index: 0,
398            validator_index: 0,
399            address: Bytes20([0u8; 20]),
400            amount: 1,
401        }]
402        .try_into()
403        .expect("withdrawals");
404        let calldata = calldata_for(&input);
405        let mut gas = 100_000_000u64;
406        let err = run_execute(&MockValidator, &calldata, &mut gas)
407            .expect_err("non-empty withdrawals must be rejected");
408        assert_invalid_input(&err, "non-empty withdrawals");
409    }
410
411    /// Constraint 4: `execution_requests` (deposits/withdrawals/consolidations) must all be empty.
412    /// Here we add one deposit — the same check fires for any non-empty field.
413    #[test]
414    fn execute_rejects_nonempty_execution_requests() {
415        let mut input = l2_valid_input(0, 1_000_000);
416        input.new_payload_request.execution_requests.deposits = vec![DepositRequest {
417            pubkey: [0u8; 48],
418            withdrawal_credentials: [0u8; 32],
419            amount: 1,
420            signature: [0u8; 96],
421            index: 0,
422        }]
423        .try_into()
424        .expect("deposits");
425        let calldata = calldata_for(&input);
426        let mut gas = 100_000_000u64;
427        let err = run_execute(&MockValidator, &calldata, &mut gas)
428            .expect_err("non-empty execution_requests must be rejected");
429        assert_invalid_input(&err, "non-empty execution_requests");
430    }
431
432    /// Constraint 4b: the EIP-8282 builder request lists must be empty too. They
433    /// were added to `ExecutionRequests` after the original three, so this pins
434    /// them against being reached by the constraint check only by accident.
435    #[test]
436    fn execute_rejects_nonempty_builder_requests() {
437        let mut input = l2_valid_input(0, 1_000_000);
438        input
439            .new_payload_request
440            .execution_requests
441            .builder_deposits = vec![BuilderDepositRequest {
442            pubkey: [0u8; 48],
443            withdrawal_credentials: [0u8; 32],
444            amount: 1,
445            signature: [0u8; 96],
446        }]
447        .try_into()
448        .expect("builder_deposits");
449        let mut gas = 100_000_000u64;
450        let err = run_execute(&MockValidator, &calldata_for(&input), &mut gas)
451            .expect_err("non-empty builder_deposits must be rejected");
452        assert_invalid_input(&err, "non-empty builder_deposits");
453
454        let mut input = l2_valid_input(0, 1_000_000);
455        input.new_payload_request.execution_requests.builder_exits = vec![BuilderExitRequest {
456            source_address: Bytes20([0u8; 20]),
457            pubkey: [0u8; 48],
458        }]
459        .try_into()
460        .expect("builder_exits");
461        let mut gas = 100_000_000u64;
462        let err = run_execute(&MockValidator, &calldata_for(&input), &mut gas)
463            .expect_err("non-empty builder_exits must be rejected");
464        assert_invalid_input(&err, "non-empty builder_exits");
465    }
466
467    /// Constraint 5: no transaction may carry a rejected EIP-2718 type byte.
468    ///
469    /// `0x03` is a blob transaction. `0x06` (frame) and `0x7e` (privileged) carry
470    /// an explicit sender and no signature, so no public key can be recovered for
471    /// them — and the stateless input commits one key per transaction. Rejecting
472    /// them here is what makes "every transaction in the payload is
473    /// signature-bearing" an enforced invariant rather than a property of the
474    /// current producer, which `build_ssz_stateless_input` relies on to populate
475    /// `public_keys` without gaps.
476    #[test]
477    fn execute_rejects_unsupported_transaction_types() {
478        for (type_byte, what) in [
479            (0x03u8, "blob-typed transaction"),
480            (0x06u8, "frame transaction"),
481            (0x7eu8, "privileged transaction"),
482        ] {
483            let mut input = l2_valid_input(0, 1_000_000);
484            let tx = vec![type_byte, 0x00, 0x00].try_into().expect("tx bytes");
485            input.new_payload_request.execution_payload.transactions =
486                vec![tx].try_into().expect("transactions");
487            let calldata = calldata_for(&input);
488            let mut gas = 100_000_000u64;
489            let err = run_execute(&MockValidator, &calldata, &mut gas)
490                .expect_err("unsupported transaction type must be rejected");
491            assert_invalid_input(&err, what);
492        }
493
494        // A type byte just outside the rejected set must still be accepted, so the
495        // test cannot pass by rejecting everything.
496        let mut input = l2_valid_input(0, 1_000_000);
497        let tx = vec![0x02u8, 0x00, 0x00].try_into().expect("tx bytes");
498        input.new_payload_request.execution_payload.transactions =
499            vec![tx].try_into().expect("transactions");
500        let calldata = calldata_for(&input);
501        let mut gas = 100_000_000u64;
502        run_execute(&MockValidator, &calldata, &mut gas)
503            .expect("an EIP-1559 transaction must be accepted");
504    }
505
506    /// The schema prefix is mandatory, and only `STATELESS_INPUT_SCHEMA_ID` is
507    /// accepted. Since #3278 removed chain configuration from the wire, the prefix
508    /// is the sole carrier of the fork, so an unexpected id must reject rather than
509    /// be validated under Amsterdam rules by default.
510    #[test]
511    fn execute_requires_the_expected_schema_prefix() {
512        let input = l2_valid_input(0, 1_000_000);
513        let mut body = Vec::new();
514        input.ssz_append(&mut body);
515
516        // No prefix at all: the body's first bytes are an SSZ offset, not a schema id.
517        let mut gas = 100_000_000u64;
518        let err = run_execute(&MockValidator, &Bytes::from(body.clone()), &mut gas)
519            .expect_err("unprefixed input must be rejected");
520        assert_invalid_input(&err, "unprefixed input");
521
522        // A well-formed but different schema id.
523        let mut wrong = vec![0x15u8, 0x02];
524        wrong.extend_from_slice(&body);
525        let mut gas = 100_000_000u64;
526        let err = run_execute(&MockValidator, &Bytes::from(wrong), &mut gas)
527            .expect_err("unexpected schema id must be rejected");
528        assert_invalid_input(&err, "unexpected schema id");
529
530        // Too short to hold an id at all.
531        let mut gas = 100_000_000u64;
532        let err = run_execute(&MockValidator, &Bytes::from(vec![0x15u8]), &mut gas)
533            .expect_err("truncated input must be rejected");
534        assert_invalid_input(&err, "input too short for a schema id");
535
536        // And the expected prefix is accepted, so the test is not vacuous.
537        let mut gas = 100_000_000u64;
538        run_execute(&MockValidator, &calldata_for(&input), &mut gas)
539            .expect("the expected schema prefix must be accepted");
540    }
541}