Skip to main content

pop_fork/
executor.rs

1// SPDX-License-Identifier: GPL-3.0
2
3//! Runtime executor using smoldot to execute Substrate runtime calls.
4//!
5//! This module provides [`RuntimeExecutor`], a wrapper around smoldot's executor that
6//! runs Substrate runtime calls (like `Core_version`, `BlockBuilder_apply_extrinsic`, etc.)
7//! against storage provided by a [`LocalStorageLayer`].
8//!
9//! # Design Decision: Why smoldot?
10//!
11//! smoldot already implements all ~50 Substrate host functions required for runtime execution:
12//!
13//! - **Storage operations**: get, set, clear, exists, next_key
14//! - **Cryptographic operations**: sr25519, ed25519, ecdsa signature verification
15//! - **Hashing**: blake2, keccak, sha2, twox
16//! - **Memory allocation**: heap management for the WASM runtime
17//! - **Logging and debugging**: runtime log emission
18//!
19//! By using smoldot's `runtime_call` API, we avoid reimplementing these host functions
20//! while gaining full control over storage access. Storage reads are routed through the
21//! [`LocalStorageLayer`], which checks local modifications first,
22//! then deleted prefixes, and finally falls back to the parent layer (typically a
23//! [`RemoteStorageLayer`](crate::RemoteStorageLayer) that lazily fetches from RPC).
24//!
25//! # Executor Architecture
26//!
27//! ```text
28//! ┌─────────────────────────────────────────────────────────────────────┐
29//! │                      RuntimeExecutor                                │
30//! │                                                                     │
31//! │   call(method, args) ──► Create VM Prototype                        │
32//! │                                │                                    │
33//! │                                ▼                                    │
34//! │                         Start runtime_call                          │
35//! │                                │                                    │
36//! │                                ▼                                    │
37//! │                   ┌────── Event Loop ──────┐                        │
38//! │                   │                        │                        │
39//! │                   ▼                        ▼                        │
40//! │            StorageGet?              SignatureVerify?                │
41//! │                   │                        │                        │
42//! │                   ▼                        ▼                        │
43//! │     Query LocalStorageLayer         Verify or mock                  │
44//! │                   │                        │                        │
45//! │                   └──────────┬─────────────┘                        │
46//! │                              ▼                                      │
47//! │                         Finished?                                   │
48//! │                              │                                      │
49//! │                              ▼                                      │
50//! │                   Return RuntimeCallResult                          │
51//! │                   (output + storage_diff)                           │
52//! └─────────────────────────────────────────────────────────────────────┘
53//! ```
54//!
55//! # Example
56//!
57//! ```ignore
58//! use pop_fork::{RuntimeExecutor, LocalStorageLayer, RemoteStorageLayer};
59//!
60//! // Create the storage layers
61//! let remote = RemoteStorageLayer::new(rpc, cache, block_hash);
62//! let local = LocalStorageLayer::new(remote);
63//!
64//! // Create executor with runtime WASM code fetched from chain
65//! let runtime_code = rpc.runtime_code(block_hash).await?;
66//! let executor = RuntimeExecutor::new(runtime_code, None)?;
67//!
68//! // Execute a runtime call against the local storage layer
69//! let result = executor.call("Core_version", &[], &local).await?;
70//! println!("Output: {:?}", result.output);
71//! println!("Storage changes: {:?}", result.storage_diff.len());
72//! ```
73
74use crate::{
75	LocalStorageLayer,
76	error::ExecutorError,
77	local::LocalSharedValue,
78	strings::executor::{magic_signature, storage_prefixes},
79};
80use smoldot::{
81	executor::{
82		self,
83		host::{Config as HostConfig, HostVmPrototype},
84		runtime_call::{self, OffchainContext, RuntimeCall},
85		storage_diff::TrieDiff,
86		vm::{ExecHint, HeapPages},
87	},
88	trie::{TrieEntryVersion, bytes_to_nibbles, nibbles_to_bytes_suffix_extend},
89};
90use std::{collections::BTreeMap, iter, iter::Once, sync::Arc};
91
92struct ArcLocalSharedValue(Arc<LocalSharedValue>);
93
94impl AsRef<[u8]> for ArcLocalSharedValue {
95	fn as_ref(&self) -> &[u8] {
96		self.0.as_ref().as_ref()
97	}
98}
99
100/// Signature mock mode for testing.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102pub enum SignatureMockMode {
103	/// No mock - verify all signatures normally.
104	#[default]
105	None,
106	/// Accept signatures starting with magic bytes `0xdeadbeef` (padded with `0xcd`).
107	///
108	/// This is similar to how Foundry's `vm.prank()` works for EVM testing - it lets you
109	/// impersonate any account for testing purposes. Real signatures are still verified
110	/// normally, but transactions with magic signatures bypass verification.
111	MagicSignature,
112	/// Accept all signatures as valid.
113	AlwaysValid,
114}
115
116/// Result of a runtime call execution.
117#[derive(Debug, Clone)]
118pub struct RuntimeCallResult {
119	/// The output bytes returned by the runtime function.
120	pub output: Vec<u8>,
121	/// Storage changes made during execution.
122	///
123	/// Each entry is `(key, value)` where `value` is `None` for deletions.
124	pub storage_diff: Vec<(Vec<u8>, Option<Vec<u8>>)>,
125	/// Offchain storage changes made during execution.
126	pub offchain_storage_diff: Vec<(Vec<u8>, Option<Vec<u8>>)>,
127	/// Log messages emitted by the runtime.
128	pub logs: Vec<RuntimeLog>,
129}
130
131/// A log message emitted by the runtime.
132#[derive(Debug, Clone)]
133pub struct RuntimeLog {
134	/// The log message.
135	pub message: String,
136	/// Log level (0=error, 1=warn, 2=info, 3=debug, 4=trace).
137	pub level: Option<u32>,
138	/// Log target (e.g., "runtime", "pallet_balances").
139	pub target: Option<String>,
140}
141
142/// Configuration for runtime execution.
143#[derive(Debug, Clone)]
144pub struct ExecutorConfig {
145	/// Signature mock mode for testing.
146	pub signature_mock: SignatureMockMode,
147	/// Whether to allow unresolved imports in the runtime.
148	pub allow_unresolved_imports: bool,
149	/// Maximum log level (0=off, 1=error, 2=warn, 3=info, 4=debug, 5=trace).
150	pub max_log_level: u32,
151	/// Value to return for storage proof size queries.
152	pub storage_proof_size: u64,
153}
154
155impl Default for ExecutorConfig {
156	fn default() -> Self {
157		Self {
158			signature_mock: SignatureMockMode::MagicSignature,
159			allow_unresolved_imports: false,
160			max_log_level: 3, // Info
161			storage_proof_size: 0,
162		}
163	}
164}
165
166/// Runtime executor for executing Substrate runtime calls.
167///
168/// This struct wraps smoldot's executor to run WASM runtime code against
169/// lazy-loaded storage via [`LocalStorageLayer`].
170///
171/// # Thread Safety
172///
173/// `RuntimeExecutor` is `Send + Sync` and can be shared across async tasks.
174/// Each call to [`RuntimeExecutor::call`] creates a new VM instance, so
175/// multiple calls can safely execute concurrently (though they will each
176/// have independent storage views).
177///
178/// # Example
179///
180/// ```ignore
181/// let executor = RuntimeExecutor::new(runtime_code, None)?;
182/// let version = executor.runtime_version()?;
183/// let result = executor.call("Metadata_metadata", &[], &storage).await?;
184/// ```
185///
186/// # Cloning
187///
188/// `RuntimeExecutor` is cheap to clone. The runtime code is stored in an `Arc<[u8]>`,
189/// so cloning only increments a reference count. Multiple executors can share the
190/// same runtime code without copying.
191#[derive(Clone)]
192pub struct RuntimeExecutor {
193	/// The WASM runtime code (shared via Arc to avoid copying large blobs).
194	runtime_code: Arc<[u8]>,
195	/// Number of heap pages available to the runtime.
196	heap_pages: HeapPages,
197	/// Execution configuration.
198	config: ExecutorConfig,
199}
200
201impl RuntimeExecutor {
202	/// Create a new executor with runtime WASM code.
203	///
204	/// # Arguments
205	///
206	/// * `runtime_code` - The WASM runtime code (can be zstd-compressed). Accepts `Vec<u8>`,
207	///   `Arc<[u8]>`, or any type that implements `Into<Arc<[u8]>>`.
208	/// * `heap_pages` - Number of heap pages. Use `None` to read from storage or use default.
209	///
210	/// # Errors
211	///
212	/// Returns an error if the WASM code is invalid.
213	///
214	/// # Example
215	///
216	/// ```ignore
217	/// // From Vec<u8> (takes ownership, no copy if Arc is created)
218	/// let executor = RuntimeExecutor::new(runtime_code_vec, None)?;
219	///
220	/// // From Arc<[u8]> (cheap clone, shares the same allocation)
221	/// let executor = RuntimeExecutor::new(runtime_code_arc.clone(), None)?;
222	/// ```
223	pub fn new(
224		runtime_code: impl Into<Arc<[u8]>>,
225		heap_pages: Option<u32>,
226	) -> Result<Self, ExecutorError> {
227		let runtime_code: Arc<[u8]> = runtime_code.into();
228		let heap_pages = heap_pages.map(HeapPages::from).unwrap_or(executor::DEFAULT_HEAP_PAGES);
229
230		// Validate the WASM code by creating a prototype
231		let _prototype = HostVmPrototype::new(HostConfig {
232			module: &runtime_code,
233			heap_pages,
234			exec_hint: ExecHint::ValidateAndExecuteOnce,
235			allow_unresolved_imports: false,
236		})?;
237
238		Ok(Self { runtime_code, heap_pages, config: ExecutorConfig::default() })
239	}
240
241	/// Create a new executor with custom configuration.
242	///
243	/// # Arguments
244	///
245	/// * `runtime_code` - The WASM runtime code (can be zstd-compressed).
246	/// * `heap_pages` - Number of heap pages. Use `None` to use the default.
247	/// * `config` - Custom execution configuration.
248	pub fn with_config(
249		runtime_code: impl Into<Arc<[u8]>>,
250		heap_pages: Option<u32>,
251		config: ExecutorConfig,
252	) -> Result<Self, ExecutorError> {
253		let mut executor = Self::new(runtime_code, heap_pages)?;
254		executor.config = config;
255		Ok(executor)
256	}
257
258	/// Create a new `HostVmPrototype` optimized for repeated execution.
259	///
260	/// Uses `ExecHint::ValidateAndCompile` which enables ahead-of-time compilation,
261	/// making subsequent calls faster. This is ideal for block building where the
262	/// same runtime is called multiple times (initialize, apply extrinsics, finalize).
263	pub fn create_prototype(&self) -> Result<HostVmPrototype, ExecutorError> {
264		Ok(HostVmPrototype::new(HostConfig {
265			module: &self.runtime_code,
266			heap_pages: self.heap_pages,
267			exec_hint: ExecHint::ValidateAndCompile,
268			allow_unresolved_imports: self.config.allow_unresolved_imports,
269		})?)
270	}
271
272	/// Execute a runtime call, optionally reusing an existing VM prototype.
273	///
274	/// When `prototype` is `Some`, it is reused instead of creating a new one from
275	/// raw WASM bytes. This avoids re-parsing and re-validating the WASM module on
276	/// each call, which is significant for large runtimes (~2.5MB for Asset Hub).
277	///
278	/// Returns the call result along with the recovered prototype (if available)
279	/// for reuse in subsequent calls.
280	///
281	/// # Arguments
282	///
283	/// * `prototype` - An existing VM prototype to reuse, or `None` to create a fresh one.
284	/// * `method` - The runtime method to call.
285	/// * `args` - SCALE-encoded arguments for the method.
286	/// * `storage` - Storage layer for reading state from the forked chain.
287	pub async fn call_with_prototype(
288		&self,
289		prototype: Option<HostVmPrototype>,
290		method: &str,
291		args: &[u8],
292		storage: &LocalStorageLayer,
293	) -> (Result<RuntimeCallResult, ExecutorError>, Option<HostVmPrototype>) {
294		// Reuse the provided prototype or create a fresh one
295		let vm_proto = match prototype {
296			Some(proto) => proto,
297			None => match self.create_prototype() {
298				Ok(proto) => proto,
299				Err(e) => return (Err(e), None),
300			},
301		};
302
303		// Start the runtime call
304		let mut vm = match runtime_call::run(runtime_call::Config {
305			virtual_machine: vm_proto,
306			function_to_call: method,
307			parameter: iter::once(args),
308			storage_main_trie_changes: TrieDiff::default(),
309			max_log_level: self.config.max_log_level,
310			calculate_trie_changes: false,
311			storage_proof_size_behavior:
312				runtime_call::StorageProofSizeBehavior::ConstantReturnValue(
313					self.config.storage_proof_size,
314				),
315		}) {
316			Ok(vm) => vm,
317			Err((err, proto)) => {
318				return (
319					Err(ExecutorError::StartError {
320						method: method.to_string(),
321						message: err.to_string(),
322					}),
323					Some(proto),
324				);
325			},
326		};
327
328		// Track storage changes during execution
329		let mut storage_changes: BTreeMap<Vec<u8>, Option<Vec<u8>>> = BTreeMap::new();
330		let mut offchain_storage_changes: BTreeMap<Vec<u8>, Option<Vec<u8>>> = BTreeMap::new();
331		let mut logs: Vec<RuntimeLog> = Vec::new();
332
333		// Execute the runtime call
334		loop {
335			vm = match vm {
336				RuntimeCall::Finished(result) => {
337					return match result {
338						Ok(success) => {
339							// Collect storage changes from the execution
340							success.storage_changes.storage_changes_iter_unordered().for_each(
341								|(child, key, value)| {
342									let prefixed_key = if let Some(child) = child {
343										prefixed_child_key(
344											child.iter().copied(),
345											key.iter().copied(),
346										)
347									} else {
348										key.to_vec()
349									};
350									storage_changes.insert(prefixed_key, value.map(|v| v.to_vec()));
351								},
352							);
353
354							// Extract output before consuming the virtual machine
355							let output = success.virtual_machine.value().as_ref().to_vec();
356							let proto = success.virtual_machine.into_prototype();
357							(
358								Ok(RuntimeCallResult {
359									output,
360									storage_diff: storage_changes.into_iter().collect(),
361									offchain_storage_diff: offchain_storage_changes
362										.into_iter()
363										.collect(),
364									logs,
365								}),
366								Some(proto),
367							)
368						},
369						Err(err) => {
370							let proto = err.prototype;
371							(Err(err.detail.into()), Some(proto))
372						},
373					};
374				},
375
376				RuntimeCall::StorageGet(req) => {
377					let key = if let Some(child) = req.child_trie() {
378						prefixed_child_key(
379							child.as_ref().iter().copied(),
380							req.key().as_ref().iter().copied(),
381						)
382					} else {
383						req.key().as_ref().to_vec()
384					};
385
386					// Check local changes first
387					if let Some(value) = storage_changes.get(&key) {
388						req.inject_value(
389							value.as_ref().map(|v| (iter::once(v), TrieEntryVersion::V1)),
390						)
391					} else {
392						// Fetch from storage backend at the latest block
393						let block_number = storage.get_current_block_number();
394						let value = match storage.get(block_number, &key).await {
395							Ok(v) => v,
396							Err(e) => {
397								return (
398									Err(ExecutorError::StorageError {
399										key: hex::encode(&key),
400										message: e.to_string(),
401									}),
402									None,
403								);
404							},
405						};
406						let none_placeholder: Option<(Once<[u8; 0]>, TrieEntryVersion)> = None;
407						match value {
408							Some(value) if value.value.is_some() => req.inject_value(Some((
409								iter::once(ArcLocalSharedValue(value)),
410								TrieEntryVersion::V1,
411							))),
412							_ => req.inject_value(none_placeholder),
413						}
414					}
415				},
416
417				RuntimeCall::ClosestDescendantMerkleValue(req) => {
418					// Inject a fake Merkle value instead of recursively computing it.
419					// These requests are only for unchanged subtrees (smoldot guarantees
420					// the diff has no descendant entries). Calling resume_unknown() would
421					// force smoldot to walk the entire subtree via StorageGet/NextKey
422					// calls to the remote storage, which is the most expensive part of
423					// BlockBuilder_finalize_block. Since pop-fork blocks are never
424					// validated by a real node, the resulting fake state root is acceptable.
425					static FAKE_MERKLE: [u8; 32] = [0u8; 32];
426					req.inject_merkle_value(Some(&FAKE_MERKLE))
427				},
428
429				RuntimeCall::NextKey(req) =>
430					if req.branch_nodes() {
431						req.inject_key(None::<Vec<_>>.map(|x| x.into_iter()))
432					} else {
433						let prefix = if let Some(child) = req.child_trie() {
434							prefixed_child_key(
435								child.as_ref().iter().copied(),
436								nibbles_to_bytes_suffix_extend(req.prefix()),
437							)
438						} else {
439							nibbles_to_bytes_suffix_extend(req.prefix()).collect::<Vec<_>>()
440						};
441
442						let key = if let Some(child) = req.child_trie() {
443							prefixed_child_key(
444								child.as_ref().iter().copied(),
445								nibbles_to_bytes_suffix_extend(req.key()),
446							)
447						} else {
448							nibbles_to_bytes_suffix_extend(req.key()).collect::<Vec<_>>()
449						};
450
451						let next = match storage.next_key(&prefix, &key).await {
452							Ok(v) => v,
453							Err(e) => {
454								return (
455									Err(ExecutorError::StorageError {
456										key: hex::encode(&key),
457										message: e.to_string(),
458									}),
459									None,
460								);
461							},
462						};
463
464						req.inject_key(next.map(|k| bytes_to_nibbles(k.into_iter())))
465					},
466
467				RuntimeCall::SignatureVerification(req) => match self.config.signature_mock {
468					SignatureMockMode::MagicSignature => {
469						if is_magic_signature(req.signature().as_ref()) {
470							req.resume_success()
471						} else {
472							req.verify_and_resume()
473						}
474					},
475					SignatureMockMode::AlwaysValid => req.resume_success(),
476					SignatureMockMode::None => req.verify_and_resume(),
477				},
478
479				RuntimeCall::OffchainStorageSet(req) => {
480					offchain_storage_changes.insert(
481						req.key().as_ref().to_vec(),
482						req.value().map(|x| x.as_ref().to_vec()),
483					);
484					req.resume()
485				},
486
487				RuntimeCall::Offchain(ctx) => match ctx {
488					OffchainContext::StorageGet(req) => {
489						let key = req.key().as_ref().to_vec();
490						let value = offchain_storage_changes.get(&key).cloned().flatten();
491						req.inject_value(value)
492					},
493					OffchainContext::StorageSet(req) => {
494						let key = req.key().as_ref().to_vec();
495						let current = offchain_storage_changes.get(&key);
496
497						let replace = match (current, req.old_value()) {
498							(Some(Some(current)), Some(old)) => old.as_ref().eq(current),
499							_ => true,
500						};
501
502						if replace {
503							offchain_storage_changes
504								.insert(key, req.value().map(|x| x.as_ref().to_vec()));
505						}
506
507						req.resume(replace)
508					},
509					OffchainContext::Timestamp(req) => {
510						let timestamp = std::time::SystemTime::now()
511							.duration_since(std::time::UNIX_EPOCH)
512							.map(|d| d.as_millis() as u64)
513							.unwrap_or(0);
514						req.inject_timestamp(timestamp)
515					},
516					OffchainContext::RandomSeed(req) => {
517						let seed = sp_core::blake2_256(
518							&std::time::SystemTime::now()
519								.duration_since(std::time::UNIX_EPOCH)
520								.map(|d| d.as_nanos().to_le_bytes())
521								.unwrap_or([0u8; 16]),
522						);
523						req.inject_random_seed(seed)
524					},
525					OffchainContext::SubmitTransaction(req) => req.resume(false),
526				},
527
528				RuntimeCall::LogEmit(req) => {
529					use smoldot::executor::host::LogEmitInfo;
530
531					let log = match req.info() {
532						LogEmitInfo::Num(v) =>
533							RuntimeLog { message: format!("{}", v), level: None, target: None },
534						LogEmitInfo::Utf8(v) =>
535							RuntimeLog { message: v.to_string(), level: None, target: None },
536						LogEmitInfo::Hex(v) =>
537							RuntimeLog { message: v.to_string(), level: None, target: None },
538						LogEmitInfo::Log { log_level, target, message } => RuntimeLog {
539							message: message.to_string(),
540							level: Some(log_level),
541							target: Some(target.to_string()),
542						},
543					};
544					logs.push(log);
545					req.resume()
546				},
547			}
548		}
549	}
550
551	/// Execute a runtime call.
552	///
553	/// # Arguments
554	///
555	/// * `method` - The runtime method to call (e.g., "Core_version", "Metadata_metadata").
556	/// * `args` - SCALE-encoded arguments for the method.
557	/// * `storage` - Storage layer for reading state from the forked chain.
558	///
559	/// # Returns
560	///
561	/// Returns the call result including output bytes and storage diff.
562	///
563	/// # Common Runtime Methods
564	///
565	/// | Method | Purpose |
566	/// |--------|---------|
567	/// | `Core_version` | Get runtime version |
568	/// | `Core_initialize_block` | Initialize a new block |
569	/// | `BlockBuilder_apply_extrinsic` | Apply a transaction |
570	/// | `BlockBuilder_finalize_block` | Finalize block, get header |
571	/// | `Metadata_metadata` | Get runtime metadata |
572	pub async fn call(
573		&self,
574		method: &str,
575		args: &[u8],
576		storage: &LocalStorageLayer,
577	) -> Result<RuntimeCallResult, ExecutorError> {
578		self.call_with_prototype(None, method, args, storage).await.0
579	}
580
581	/// Get the runtime version from the WASM code.
582	///
583	/// This reads the version from the WASM custom sections without executing any code.
584	pub fn runtime_version(&self) -> Result<RuntimeVersion, ExecutorError> {
585		let prototype = HostVmPrototype::new(HostConfig {
586			module: &self.runtime_code,
587			heap_pages: self.heap_pages,
588			exec_hint: ExecHint::ValidateAndExecuteOnce,
589			allow_unresolved_imports: true,
590		})?;
591
592		let version = prototype.runtime_version().decode();
593
594		Ok(RuntimeVersion {
595			spec_name: version.spec_name.to_string(),
596			impl_name: version.impl_name.to_string(),
597			authoring_version: version.authoring_version,
598			spec_version: version.spec_version,
599			impl_version: version.impl_version,
600			transaction_version: version.transaction_version.unwrap_or(0),
601			state_version: version.state_version.map(|v| v.into()).unwrap_or(0),
602		})
603	}
604}
605
606/// Runtime version information.
607#[derive(Debug, Clone)]
608pub struct RuntimeVersion {
609	/// Spec name (e.g., "polkadot", "kusama").
610	pub spec_name: String,
611	/// Implementation name.
612	pub impl_name: String,
613	/// Authoring version.
614	pub authoring_version: u32,
615	/// Spec version.
616	pub spec_version: u32,
617	/// Implementation version.
618	pub impl_version: u32,
619	/// Transaction version.
620	pub transaction_version: u32,
621	/// State version (0 or 1).
622	pub state_version: u8,
623}
624
625/// Create a prefixed key for child storage access.
626fn prefixed_child_key(child: impl Iterator<Item = u8>, key: impl Iterator<Item = u8>) -> Vec<u8> {
627	[storage_prefixes::DEFAULT_CHILD_STORAGE, &child.collect::<Vec<_>>(), &key.collect::<Vec<_>>()]
628		.concat()
629}
630
631/// Check if a signature is a magic test signature.
632///
633/// Magic signatures start with `0xdeadbeef` and are padded with `0xcd`.
634fn is_magic_signature(signature: &[u8]) -> bool {
635	signature.starts_with(magic_signature::PREFIX) &&
636		signature[magic_signature::PREFIX.len()..]
637			.iter()
638			.all(|&b| b == magic_signature::PADDING)
639}
640
641#[cfg(test)]
642mod tests {
643	use super::*;
644
645	#[test]
646	fn magic_signature_accepts_valid_patterns() {
647		// Valid magic signatures
648		assert!(is_magic_signature(&[0xde, 0xad, 0xbe, 0xef, 0xcd, 0xcd]));
649		assert!(is_magic_signature(&[0xde, 0xad, 0xbe, 0xef, 0xcd, 0xcd, 0xcd, 0xcd]));
650		assert!(is_magic_signature(&[0xde, 0xad, 0xbe, 0xef])); // Just prefix
651
652		// Invalid signatures
653		assert!(!is_magic_signature(&[0xde, 0xad, 0xbe, 0xef, 0xcd, 0xcd, 0xcd, 0x00]));
654		assert!(!is_magic_signature(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]));
655		assert!(!is_magic_signature(&[0xde, 0xad, 0xbe])); // Too short
656	}
657
658	#[test]
659	fn prefixed_child_key_combines_prefix_child_and_key() {
660		let child = b"child1".iter().copied();
661		let key = b"key1".iter().copied();
662		let result = prefixed_child_key(child, key);
663
664		assert!(result.starts_with(storage_prefixes::DEFAULT_CHILD_STORAGE));
665		assert!(result.ends_with(b"key1"));
666	}
667
668	#[test]
669	fn executor_config_has_sensible_defaults() {
670		let config = ExecutorConfig::default();
671		assert_eq!(config.signature_mock, SignatureMockMode::MagicSignature);
672		assert!(!config.allow_unresolved_imports);
673		assert_eq!(config.max_log_level, 3);
674		assert_eq!(config.storage_proof_size, 0);
675	}
676
677	#[test]
678	fn signature_mock_mode_defaults_to_none() {
679		// SignatureMockMode::default() is None (the derive(Default) picks the first variant).
680		// However, ExecutorConfig::default() uses MagicSignature for fork compatibility.
681		let mode = SignatureMockMode::default();
682		assert_eq!(mode, SignatureMockMode::None);
683	}
684}