From 778da6ad7cdfe422284ea7363bb8d12cd4524d13 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Fri, 13 Feb 2026 13:27:20 -0800 Subject: [PATCH 1/5] runtime: Enable Cranelift OptLevel::Speed for WASM compilation Switch the default Cranelift optimization level from `None` to `Speed`, improving WASM handler execution performance. The optimization level is configurable via the `GRAPH_WASM_OPT_LEVEL` environment variable (valid values: `none`, `speed`, `speed_and_size`; default: `speed`). This is safe because Cranelift adheres to the Wasm spec and NaN canonicalization is already enabled. The increased compilation time is a one-time cost per module, amortized over thousands of triggers. --- graph/src/env/mappings.rs | 45 +++++++++++++++++++++++++++++++++++++ graph/src/env/mod.rs | 2 ++ runtime/wasm/src/mapping.rs | 13 +++++++---- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/graph/src/env/mappings.rs b/graph/src/env/mappings.rs index 66a01d6cb6f..c56bea2bac4 100644 --- a/graph/src/env/mappings.rs +++ b/graph/src/env/mappings.rs @@ -82,6 +82,22 @@ pub struct EnvVarsMapping { /// Maximum backoff time for FDS requests. Set by /// `GRAPH_FDS_MAX_BACKOFF` in seconds, defaults to 600. pub fds_max_backoff: Duration, + + /// Cranelift optimization level for WASM compilation. + /// + /// Set by the environment variable `GRAPH_WASM_OPT_LEVEL`. Valid values + /// are `none`, `speed`, and `speed_and_size`. The default value is + /// `speed`. + pub wasm_opt_level: WasmOptLevel, +} + +/// Cranelift optimization level for WASM compilation. Maps to +/// `wasmtime::OptLevel` without introducing a dependency on wasmtime. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum WasmOptLevel { + None, + Speed, + SpeedAndSize, } // This does not print any values avoid accidentally leaking any sensitive env vars @@ -91,6 +107,32 @@ impl fmt::Debug for EnvVarsMapping { } } +impl fmt::Display for WasmOptLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + WasmOptLevel::None => write!(f, "none"), + WasmOptLevel::Speed => write!(f, "speed"), + WasmOptLevel::SpeedAndSize => write!(f, "speed_and_size"), + } + } +} + +impl FromStr for WasmOptLevel { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "none" => Ok(WasmOptLevel::None), + "speed" => Ok(WasmOptLevel::Speed), + "speed_and_size" => Ok(WasmOptLevel::SpeedAndSize), + _ => Err(format!( + "invalid GRAPH_WASM_OPT_LEVEL '{}', expected 'none', 'speed', or 'speed_and_size'", + s + )), + } + } +} + impl TryFrom for EnvVarsMapping { type Error = anyhow::Error; @@ -121,6 +163,7 @@ impl TryFrom for EnvVarsMapping { disable_declared_calls: x.disable_declared_calls.0, store_errors_are_nondeterministic: x.store_errors_are_nondeterministic.0, fds_max_backoff: Duration::from_secs(x.fds_max_backoff), + wasm_opt_level: x.wasm_opt_level, }; Ok(vars) } @@ -164,6 +207,8 @@ pub struct InnerMappingHandlers { store_errors_are_nondeterministic: EnvVarBoolean, #[envconfig(from = "GRAPH_FDS_MAX_BACKOFF", default = "600")] fds_max_backoff: u64, + #[envconfig(from = "GRAPH_WASM_OPT_LEVEL", default = "speed")] + wasm_opt_level: WasmOptLevel, } fn validate_ipfs_cache_location(path: PathBuf) -> Result { diff --git a/graph/src/env/mod.rs b/graph/src/env/mod.rs index 76e2c612565..23a6eaff579 100644 --- a/graph/src/env/mod.rs +++ b/graph/src/env/mod.rs @@ -11,6 +11,8 @@ use semver::Version; use self::graphql::*; use self::mappings::*; + +pub use self::mappings::WasmOptLevel; use self::store::*; use crate::{ components::{store::BlockNumber, subgraph::SubgraphVersionSwitchingMode}, diff --git a/runtime/wasm/src/mapping.rs b/runtime/wasm/src/mapping.rs index 0a73832e9dd..ae9e075b85e 100644 --- a/runtime/wasm/src/mapping.rs +++ b/runtime/wasm/src/mapping.rs @@ -291,14 +291,19 @@ impl ValidModule { .map_err(|_| anyhow!("Failed to inject gas counter"))?; let raw_module = parity_module.into_bytes()?; - // We currently use Cranelift as a compilation engine. Cranelift is an optimizing compiler, - // but that should not cause determinism issues since it adheres to the Wasm spec. Still we - // turn off optional optimizations to be conservative. + // We use Cranelift as a compilation engine. Cranelift is an optimizing compiler, but that + // should not cause determinism issues since it adheres to the Wasm spec and NaN + // canonicalization is enabled below. The optimization level is configurable via + // GRAPH_WASM_OPT_LEVEL (default: speed). let mut config = wasmtime::Config::new(); config.strategy(wasmtime::Strategy::Cranelift); config.epoch_interruption(true); config.cranelift_nan_canonicalization(true); // For NaN determinism. - config.cranelift_opt_level(wasmtime::OptLevel::None); + config.cranelift_opt_level(match ENV_VARS.mappings.wasm_opt_level { + graph::env::WasmOptLevel::None => wasmtime::OptLevel::None, + graph::env::WasmOptLevel::Speed => wasmtime::OptLevel::Speed, + graph::env::WasmOptLevel::SpeedAndSize => wasmtime::OptLevel::SpeedAndSize, + }); config.max_wasm_stack(ENV_VARS.mappings.max_stack_size); config.async_support(true); From 0b2d7511c03ebbcc725503cec3cfd5c3e91bc5e9 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Fri, 13 Feb 2026 13:36:22 -0800 Subject: [PATCH 2/5] runtime: Move gas and deterministic_host_trap into WasmInstanceData Move the per-trigger GasCounter and deterministic_host_trap flag from standalone variables captured by linker closures into the Store data (WasmInstanceData). This is a pure refactoring with no behavior change. Previously, GasCounter and an Arc for deterministic_host_trap were created as separate variables and captured by each linker closure. Now they live on WasmInstanceData and are accessed through caller.data()/caller.data_mut() inside the closures. This decouples the linker closures from per-trigger state, which is a prerequisite for caching InstancePre on ValidModule (reusing the linker across triggers). --- runtime/wasm/src/module/context.rs | 5 ++++ runtime/wasm/src/module/instance.rs | 38 ++++++++++++++--------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/runtime/wasm/src/module/context.rs b/runtime/wasm/src/module/context.rs index 6230a9a1596..3e4f26cfd58 100644 --- a/runtime/wasm/src/module/context.rs +++ b/runtime/wasm/src/module/context.rs @@ -82,6 +82,9 @@ pub struct WasmInstanceData { pub valid_module: Arc, pub host_metrics: Arc, + // Per-trigger gas counter, shared via Arc so clones refer to the same counter. + pub gas: GasCounter, + // A trap ocurred due to a possible reorg detection. pub possible_reorg: bool, @@ -100,6 +103,7 @@ impl WasmInstanceData { ctx: MappingContext, valid_module: Arc, host_metrics: Arc, + gas: GasCounter, experimental_features: ExperimentalFeatures, ) -> Self { WasmInstanceData { @@ -107,6 +111,7 @@ impl WasmInstanceData { ctx, valid_module, host_metrics, + gas, possible_reorg: false, deterministic_host_trap: false, experimental_features, diff --git a/runtime/wasm/src/module/instance.rs b/runtime/wasm/src/module/instance.rs index d35730f9bfe..0c09a03d3ae 100644 --- a/runtime/wasm/src/module/instance.rs +++ b/runtime/wasm/src/module/instance.rs @@ -1,4 +1,3 @@ -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use anyhow::Error; @@ -262,10 +261,13 @@ impl WasmInstance { let host_fns = ctx.host_fns.cheap_clone(); let api_version = ctx.host_exports.data_source.api_version.clone(); + let gas = GasCounter::new(host_metrics.gas_metrics.clone()); + let wasm_ctx = WasmInstanceData::from_instance( ctx, valid_module.cheap_clone(), host_metrics.cheap_clone(), + gas.cheap_clone(), experimental_features, ); let mut store = Store::new(engine, wasm_ctx); @@ -280,11 +282,6 @@ impl WasmInstance { // See also: runtime-timeouts store.set_epoch_deadline(2); - // Because `gas` and `deterministic_host_trap` need to be accessed from the gas - // host fn, they need to be separate from the rest of the context. - let gas = GasCounter::new(host_metrics.gas_metrics.clone()); - let deterministic_host_trap = Arc::new(AtomicBool::new(false)); - // Helper to turn a parameter name into 'u32' for a tuple type // (param1, parma2, ..) : (u32, u32, ..) macro_rules! param_u32 { @@ -313,14 +310,13 @@ impl WasmInstance { // link an import with all the modules that require it. for module in modules { - let gas = gas.cheap_clone(); linker.func_wrap_async( module, $wasm_name, move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, ($($param),*,) : ($(param_u32!($param)),*,)| { - let gas = gas.cheap_clone(); Box::new(async move { + let gas = caller.data().gas.cheap_clone(); let host_metrics = caller.data().host_metrics.cheap_clone(); let _section = host_metrics.stopwatch.start_section($section); @@ -362,14 +358,13 @@ impl WasmInstance { // link an import with all the modules that require it. for module in modules { - let gas = gas.cheap_clone(); linker.func_wrap_async( module, $wasm_name, move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, _ : ()| { - let gas = gas.cheap_clone(); Box::new(async move { + let gas = caller.data().gas.cheap_clone(); let host_metrics = caller.data().host_metrics.cheap_clone(); let _section = host_metrics.stopwatch.start_section($section); @@ -409,17 +404,16 @@ impl WasmInstance { for module in modules { let host_fn = host_fn.cheap_clone(); - let gas = gas.cheap_clone(); linker.func_wrap_async( module, host_fn.name, move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, (call_ptr,): (u32,)| { let host_fn = host_fn.cheap_clone(); - let gas = gas.cheap_clone(); Box::new(async move { let start = Instant::now(); + let gas = caller.data().gas.cheap_clone(); let name_for_metrics = host_fn.name.replace('.', "_"); let host_metrics = caller.data().host_metrics.cheap_clone(); let stopwatch = host_metrics.stopwatch.cheap_clone(); @@ -576,22 +570,28 @@ impl WasmInstance { // link the `gas` function // See also e3f03e62-40e4-4f8c-b4a1-d0375cca0b76 - { - let gas = gas.cheap_clone(); - linker.func_wrap("gas", "gas", move |gas_used: u32| -> anyhow::Result<()> { + linker.func_wrap( + "gas", + "gas", + |mut caller: wasmtime::Caller<'_, WasmInstanceData>, + gas_used: u32| + -> anyhow::Result<()> { // Gas metering has a relevant execution cost cost, being called tens of thousands // of times per handler, but it's not worth having a stopwatch section here because // the cost of measuring would be greater than the cost of `consume_host_fn`. Last // time this was benchmarked it took < 100ns to run. - if let Err(e) = gas.consume_host_fn_with_metrics(gas_used.saturating_into(), "gas") + if let Err(e) = caller + .data() + .gas + .consume_host_fn_with_metrics(gas_used.saturating_into(), "gas") { - deterministic_host_trap.store(true, Ordering::SeqCst); + caller.data_mut().deterministic_host_trap = true; return Err(e.into()); } Ok(()) - })?; - } + }, + )?; let instance = linker .instantiate_async(store.as_context_mut(), &valid_module.module) From 0ba018d7844a10eef90773b89788c6253a1df9f4 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Fri, 13 Feb 2026 13:58:26 -0800 Subject: [PATCH 3/5] runtime: Cache InstancePre on ValidModule for faster trigger instantiation Extract linker construction from per-trigger `from_valid_module_with_ctx` into a standalone `build_linker()` function called once at module validation time. The linker is pre-linked via `linker.instantiate_pre()` and the resulting `InstancePre` is stored on `ValidModule`. This eliminates the per-trigger cost of: - Rebuilding a Linker with ~60 func_wrap_async registrations - Resolving imports against the module Chain-specific host functions (e.g. ethereum.call) are now dispatched generically: the linker registers them by import name and looks up the actual HostFn from caller.data().ctx.host_fns at call time. Conditional functions (ipfs.getBlock, arweave.transactionData, box.profile) are now linked unconditionally since they already check feature flags or return errors internally. --- runtime/wasm/src/mapping.rs | 11 +- runtime/wasm/src/module/instance.rs | 710 ++++++++++++++++------------ 2 files changed, 405 insertions(+), 316 deletions(-) diff --git a/runtime/wasm/src/mapping.rs b/runtime/wasm/src/mapping.rs index ae9e075b85e..5bfe2859fcd 100644 --- a/runtime/wasm/src/mapping.rs +++ b/runtime/wasm/src/mapping.rs @@ -1,5 +1,5 @@ use crate::gas_rules::GasRules; -use crate::module::{ExperimentalFeatures, ToAscPtr, WasmInstance}; +use crate::module::{ExperimentalFeatures, ToAscPtr, WasmInstance, WasmInstanceData}; use graph::blockchain::{BlockTime, Blockchain, HostFn}; use graph::components::store::SubgraphFork; use graph::components::subgraph::{MappingError, SharedProofOfIndexing}; @@ -222,6 +222,11 @@ const GN_START_FUNCTION_NAME: &str = "gn::start"; pub struct ValidModule { pub module: wasmtime::Module, + /// Pre-linked instance template. Created once at module validation time and reused for every + /// trigger instantiation, avoiding the cost of rebuilding the linker (~60 host function + /// registrations) and resolving imports on each trigger. + pub instance_pre: wasmtime::InstancePre, + // Due to our internal architecture we don't want to run the start function at instantiation time, // so we track it separately so that we can run it at an appropriate time. // Since the start function is not an export, we will also create an export for it. @@ -340,8 +345,12 @@ impl ValidModule { epoch_counter_abort_handle = Some(graph::spawn(epoch_counter).abort_handle()); } + let linker = crate::module::build_linker(engine, &import_name_to_modules)?; + let instance_pre = linker.instantiate_pre(&module)?; + Ok(ValidModule { module, + instance_pre, import_name_to_modules, start_function, timeout, diff --git a/runtime/wasm/src/module/instance.rs b/runtime/wasm/src/module/instance.rs index 0c09a03d3ae..dd7c8123e4c 100644 --- a/runtime/wasm/src/module/instance.rs +++ b/runtime/wasm/src/module/instance.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::time::Instant; use anyhow::Error; @@ -248,6 +249,397 @@ impl WasmInstance { } } +/// Import names that are handled by builtin host functions. Any import name +/// in `import_name_to_modules` that is not in this set and is not `gas` is +/// assumed to be a chain-specific host function. +const BUILTIN_IMPORT_NAMES: &[&str] = &[ + "ethereum.encode", + "ethereum.decode", + "abort", + "store.get", + "store.loadRelated", + "store.get_in_block", + "store.set", + "store.remove", + "ipfs.cat", + "ipfs.map", + "ipfs.getBlock", + "typeConversion.bytesToString", + "typeConversion.bytesToHex", + "typeConversion.bigIntToString", + "typeConversion.bigIntToHex", + "typeConversion.stringToH160", + "typeConversion.bytesToBase58", + "json.fromBytes", + "json.try_fromBytes", + "json.toI64", + "json.toU64", + "json.toF64", + "json.toBigInt", + "yaml.fromBytes", + "yaml.try_fromBytes", + "crypto.keccak256", + "bigInt.plus", + "bigInt.minus", + "bigInt.times", + "bigInt.dividedBy", + "bigInt.dividedByDecimal", + "bigInt.mod", + "bigInt.pow", + "bigInt.fromString", + "bigInt.bitOr", + "bigInt.bitAnd", + "bigInt.leftShift", + "bigInt.rightShift", + "bigDecimal.toString", + "bigDecimal.fromString", + "bigDecimal.plus", + "bigDecimal.minus", + "bigDecimal.times", + "bigDecimal.dividedBy", + "bigDecimal.equals", + "dataSource.create", + "dataSource.createWithContext", + "dataSource.address", + "dataSource.network", + "dataSource.context", + "ens.nameByHash", + "log.log", + "arweave.transactionData", + "box.profile", +]; + +/// Build a pre-linked `Linker` for a WASM module. This linker can be reused across triggers by +/// calling `linker.instantiate_pre()` once and then `instance_pre.instantiate_async()` per trigger. +/// +/// All host functions (builtins + chain-specific) are registered here. Chain-specific host functions +/// are dispatched generically by looking up the `HostFn` by name from `caller.data().ctx.host_fns` +/// at call time rather than capturing concrete closures at link time. +pub(crate) fn build_linker( + engine: &wasmtime::Engine, + import_name_to_modules: &BTreeMap>, +) -> Result, anyhow::Error> { + let mut linker: Linker = wasmtime::Linker::new(engine); + + // Helper to turn a parameter name into 'u32' for a tuple type + // (param1, parma2, ..) : (u32, u32, ..) + macro_rules! param_u32 { + ($param:ident) => { + u32 + }; + } + + // The difficulty with this macro is that it needs to turn a list of + // parameter names into a tuple declaration (param1, parma2, ..) : + // (u32, u32, ..), but also for an empty parameter list, it needs to + // produce '(): ()'. In the first case we need a trailing comma, in + // the second case we don't. That's why there are two separate + // expansions, one with and one without params + macro_rules! link { + ($wasm_name:expr, $rust_name:ident, $($param:ident),*) => { + link!($wasm_name, $rust_name, "host_export_other",$($param),*) + }; + + ($wasm_name:expr, $rust_name:ident, $section:expr, $($param:ident),+) => { + let modules = import_name_to_modules + .get($wasm_name) + .into_iter() + .flatten(); + + // link an import with all the modules that require it. + for module in modules { + linker.func_wrap_async( + module, + $wasm_name, + move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, + ($($param),*,) : ($(param_u32!($param)),*,)| { + Box::new(async move { + let gas = caller.data().gas.cheap_clone(); + let host_metrics = caller.data().host_metrics.cheap_clone(); + let _section = host_metrics.stopwatch.start_section($section); + + #[allow(unused_mut)] + let mut ctx = std::pin::pin!(WasmInstanceContext::new(&mut caller)); + let result = ctx.$rust_name( + &gas, + $($param.into()),* + ).await; + let ctx = ctx.get_mut(); + match result { + Ok(result) => Ok(result.into_wasm_ret()), + Err(e) => { + match IntoTrap::determinism_level(&e) { + DeterminismLevel::Deterministic => { + ctx.as_mut().deterministic_host_trap = true; + } + DeterminismLevel::PossibleReorg => { + ctx.as_mut().possible_reorg = true; + } + DeterminismLevel::Unimplemented + | DeterminismLevel::NonDeterministic => {} + } + + Err(e.into()) + } + } + }) }, + )?; + } + }; + + ($wasm_name:expr, $rust_name:ident, $section:expr,) => { + let modules = import_name_to_modules + .get($wasm_name) + .into_iter() + .flatten(); + + // link an import with all the modules that require it. + for module in modules { + linker.func_wrap_async( + module, + $wasm_name, + move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, + _ : ()| { + Box::new(async move { + let gas = caller.data().gas.cheap_clone(); + let host_metrics = caller.data().host_metrics.cheap_clone(); + let _section = host_metrics.stopwatch.start_section($section); + + #[allow(unused_mut)] + let mut ctx = WasmInstanceContext::new(&mut caller); + let result = ctx.$rust_name(&gas).await; + match result { + Ok(result) => Ok(result.into_wasm_ret()), + Err(e) => { + match IntoTrap::determinism_level(&e) { + DeterminismLevel::Deterministic => { + ctx.as_mut().deterministic_host_trap = true; + } + DeterminismLevel::PossibleReorg => { + ctx.as_mut().possible_reorg = true; + } + DeterminismLevel::Unimplemented + | DeterminismLevel::NonDeterministic => {} + } + + Err(e.into()) + } + } + }) }, + )?; + } + }; + } + + // Link chain-specific host fns. Any import name not in BUILTIN_IMPORT_NAMES and not "gas" + // is assumed to be a chain-specific host function. We register a generic dispatcher that + // looks up the actual HostFn by name from caller.data().ctx.host_fns at call time. + for (import_name, modules) in import_name_to_modules { + if import_name == "gas" || BUILTIN_IMPORT_NAMES.contains(&import_name.as_str()) { + continue; + } + + // Leak the name so we get a &'static str for metrics. These are a small, fixed set of + // chain host_fn names (e.g. "ethereum.call") so the leak is bounded. + let name: &'static str = Box::leak(import_name.clone().into_boxed_str()); + + for module in modules { + linker.func_wrap_async( + module, + name, + move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, (call_ptr,): (u32,)| { + Box::new(async move { + let host_fn = caller + .data() + .ctx + .host_fns + .iter() + .find(|hf| hf.name == name) + .expect("chain host_fn not found") + .cheap_clone(); + + let start = Instant::now(); + + let gas = caller.data().gas.cheap_clone(); + let name_for_metrics = name.replace('.', "_"); + let host_metrics = caller.data().host_metrics.cheap_clone(); + let stopwatch = host_metrics.stopwatch.cheap_clone(); + let _section = + stopwatch.start_section(&format!("host_export_{}", name_for_metrics)); + + let ctx = HostFnCtx { + logger: caller.data().ctx.logger.cheap_clone(), + block_ptr: caller.data().ctx.block_ptr.cheap_clone(), + gas: gas.cheap_clone(), + metrics: host_metrics.cheap_clone(), + heap: &mut WasmInstanceContext::new(&mut caller), + }; + let ret = (host_fn.func)(ctx, call_ptr).await.map_err(|e| match e { + HostExportError::Deterministic(e) => { + caller.data_mut().deterministic_host_trap = true; + e + } + HostExportError::PossibleReorg(e) => { + caller.data_mut().possible_reorg = true; + e + } + HostExportError::Unknown(e) => e, + })?; + host_metrics.observe_host_fn_execution_time( + start.elapsed().as_secs_f64(), + &name_for_metrics, + ); + Ok(ret) + }) + }, + )?; + } + } + + link!("ethereum.encode", ethereum_encode, params_ptr); + link!("ethereum.decode", ethereum_decode, params_ptr, data_ptr); + + link!("abort", abort, message_ptr, file_name_ptr, line, column); + + link!("store.get", store_get, "host_export_store_get", entity, id); + link!( + "store.loadRelated", + store_load_related, + "host_export_store_load_related", + entity, + id, + field + ); + link!( + "store.get_in_block", + store_get_in_block, + "host_export_store_get_in_block", + entity, + id + ); + link!( + "store.set", + store_set, + "host_export_store_set", + entity, + id, + data + ); + + // All IPFS-related functions exported by the host WASM runtime should be listed in the + // graph::data::subgraph::features::IPFS_ON_ETHEREUM_CONTRACTS_FUNCTION_NAMES array for + // automatic feature detection to work. + // + // For reference, search this codebase for: ff652476-e6ad-40e4-85b8-e815d6c6e5e2 + link!("ipfs.cat", ipfs_cat, "host_export_ipfs_cat", hash_ptr); + link!( + "ipfs.map", + ipfs_map, + "host_export_ipfs_map", + link_ptr, + callback, + user_data, + flags + ); + // ipfs.getBlock checks the experimental_features flag internally via + // caller.data().experimental_features, so it can be linked unconditionally. + link!( + "ipfs.getBlock", + ipfs_get_block, + "host_export_ipfs_get_block", + hash_ptr + ); + + link!("store.remove", store_remove, entity_ptr, id_ptr); + + link!("typeConversion.bytesToString", bytes_to_string, ptr); + link!("typeConversion.bytesToHex", bytes_to_hex, ptr); + link!("typeConversion.bigIntToString", big_int_to_string, ptr); + link!("typeConversion.bigIntToHex", big_int_to_hex, ptr); + link!("typeConversion.stringToH160", string_to_h160, ptr); + link!("typeConversion.bytesToBase58", bytes_to_base58, ptr); + + link!("json.fromBytes", json_from_bytes, ptr); + link!("json.try_fromBytes", json_try_from_bytes, ptr); + link!("json.toI64", json_to_i64, ptr); + link!("json.toU64", json_to_u64, ptr); + link!("json.toF64", json_to_f64, ptr); + link!("json.toBigInt", json_to_big_int, ptr); + + link!("yaml.fromBytes", yaml_from_bytes, ptr); + link!("yaml.try_fromBytes", yaml_try_from_bytes, ptr); + + link!("crypto.keccak256", crypto_keccak_256, ptr); + + link!("bigInt.plus", big_int_plus, x_ptr, y_ptr); + link!("bigInt.minus", big_int_minus, x_ptr, y_ptr); + link!("bigInt.times", big_int_times, x_ptr, y_ptr); + link!("bigInt.dividedBy", big_int_divided_by, x_ptr, y_ptr); + link!("bigInt.dividedByDecimal", big_int_divided_by_decimal, x, y); + link!("bigInt.mod", big_int_mod, x_ptr, y_ptr); + link!("bigInt.pow", big_int_pow, x_ptr, exp); + link!("bigInt.fromString", big_int_from_string, ptr); + link!("bigInt.bitOr", big_int_bit_or, x_ptr, y_ptr); + link!("bigInt.bitAnd", big_int_bit_and, x_ptr, y_ptr); + link!("bigInt.leftShift", big_int_left_shift, x_ptr, bits); + link!("bigInt.rightShift", big_int_right_shift, x_ptr, bits); + + link!("bigDecimal.toString", big_decimal_to_string, ptr); + link!("bigDecimal.fromString", big_decimal_from_string, ptr); + link!("bigDecimal.plus", big_decimal_plus, x_ptr, y_ptr); + link!("bigDecimal.minus", big_decimal_minus, x_ptr, y_ptr); + link!("bigDecimal.times", big_decimal_times, x_ptr, y_ptr); + link!("bigDecimal.dividedBy", big_decimal_divided_by, x, y); + link!("bigDecimal.equals", big_decimal_equals, x_ptr, y_ptr); + + link!("dataSource.create", data_source_create, name, params); + link!( + "dataSource.createWithContext", + data_source_create_with_context, + name, + params, + context + ); + link!("dataSource.address", data_source_address,); + link!("dataSource.network", data_source_network,); + link!("dataSource.context", data_source_context,); + + link!("ens.nameByHash", ens_name_by_hash, ptr); + + link!("log.log", log_log, level, msg_ptr); + + // `arweave` and `box` functionality was removed. The implementations return deterministic + // errors. Linked unconditionally since import_name_to_modules ensures they're only + // registered if the module actually imports them. + link!("arweave.transactionData", arweave_transaction_data, ptr); + link!("box.profile", box_profile, ptr); + + // link the `gas` function + // See also e3f03e62-40e4-4f8c-b4a1-d0375cca0b76 + linker.func_wrap( + "gas", + "gas", + |mut caller: wasmtime::Caller<'_, WasmInstanceData>, gas_used: u32| -> anyhow::Result<()> { + // Gas metering has a relevant execution cost cost, being called tens of thousands + // of times per handler, but it's not worth having a stopwatch section here because + // the cost of measuring would be greater than the cost of `consume_host_fn`. Last + // time this was benchmarked it took < 100ns to run. + if let Err(e) = caller + .data() + .gas + .consume_host_fn_with_metrics(gas_used.saturating_into(), "gas") + { + caller.data_mut().deterministic_host_trap = true; + return Err(e.into()); + } + + Ok(()) + }, + )?; + + Ok(linker) +} + impl WasmInstance { /// Instantiates the module and sets it to be interrupted after `timeout`. pub async fn from_valid_module_with_ctx( @@ -257,8 +649,6 @@ impl WasmInstance { experimental_features: ExperimentalFeatures, ) -> Result { let engine = valid_module.module.engine(); - let mut linker: Linker = wasmtime::Linker::new(engine); - let host_fns = ctx.host_fns.cheap_clone(); let api_version = ctx.host_exports.data_source.api_version.clone(); let gas = GasCounter::new(host_metrics.gas_metrics.clone()); @@ -282,319 +672,9 @@ impl WasmInstance { // See also: runtime-timeouts store.set_epoch_deadline(2); - // Helper to turn a parameter name into 'u32' for a tuple type - // (param1, parma2, ..) : (u32, u32, ..) - macro_rules! param_u32 { - ($param:ident) => { - u32 - }; - } - - // The difficulty with this macro is that it needs to turn a list of - // parameter names into a tuple declaration (param1, parma2, ..) : - // (u32, u32, ..), but also for an empty parameter list, it needs to - // produce '(): ()'. In the first case we need a trailing comma, in - // the second case we don't. That's why there are two separate - // expansions, one with and one without params - macro_rules! link { - ($wasm_name:expr, $rust_name:ident, $($param:ident),*) => { - link!($wasm_name, $rust_name, "host_export_other",$($param),*) - }; - - ($wasm_name:expr, $rust_name:ident, $section:expr, $($param:ident),+) => { - let modules = valid_module - .import_name_to_modules - .get($wasm_name) - .into_iter() - .flatten(); - - // link an import with all the modules that require it. - for module in modules { - linker.func_wrap_async( - module, - $wasm_name, - move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, - ($($param),*,) : ($(param_u32!($param)),*,)| { - Box::new(async move { - let gas = caller.data().gas.cheap_clone(); - let host_metrics = caller.data().host_metrics.cheap_clone(); - let _section = host_metrics.stopwatch.start_section($section); - - #[allow(unused_mut)] - let mut ctx = std::pin::pin!(WasmInstanceContext::new(&mut caller)); - let result = ctx.$rust_name( - &gas, - $($param.into()),* - ).await; - let ctx = ctx.get_mut(); - match result { - Ok(result) => Ok(result.into_wasm_ret()), - Err(e) => { - match IntoTrap::determinism_level(&e) { - DeterminismLevel::Deterministic => { - ctx.as_mut().deterministic_host_trap = true; - } - DeterminismLevel::PossibleReorg => { - ctx.as_mut().possible_reorg = true; - } - DeterminismLevel::Unimplemented - | DeterminismLevel::NonDeterministic => {} - } - - Err(e.into()) - } - } - }) }, - )?; - } - }; - - ($wasm_name:expr, $rust_name:ident, $section:expr,) => { - let modules = valid_module - .import_name_to_modules - .get($wasm_name) - .into_iter() - .flatten(); - - // link an import with all the modules that require it. - for module in modules { - linker.func_wrap_async( - module, - $wasm_name, - move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, - _ : ()| { - Box::new(async move { - let gas = caller.data().gas.cheap_clone(); - let host_metrics = caller.data().host_metrics.cheap_clone(); - let _section = host_metrics.stopwatch.start_section($section); - - #[allow(unused_mut)] - let mut ctx = WasmInstanceContext::new(&mut caller); - let result = ctx.$rust_name(&gas).await; - match result { - Ok(result) => Ok(result.into_wasm_ret()), - Err(e) => { - match IntoTrap::determinism_level(&e) { - DeterminismLevel::Deterministic => { - ctx.as_mut().deterministic_host_trap = true; - } - DeterminismLevel::PossibleReorg => { - ctx.as_mut().possible_reorg = true; - } - DeterminismLevel::Unimplemented - | DeterminismLevel::NonDeterministic => {} - } - - Err(e.into()) - } - } - }) }, - )?; - } - }; - } - - // Link chain-specifc host fns. - for host_fn in host_fns.iter() { - let modules = valid_module - .import_name_to_modules - .get(host_fn.name) - .into_iter() - .flatten(); - - for module in modules { - let host_fn = host_fn.cheap_clone(); - linker.func_wrap_async( - module, - host_fn.name, - move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, - (call_ptr,): (u32,)| { - let host_fn = host_fn.cheap_clone(); - Box::new(async move { - let start = Instant::now(); - - let gas = caller.data().gas.cheap_clone(); - let name_for_metrics = host_fn.name.replace('.', "_"); - let host_metrics = caller.data().host_metrics.cheap_clone(); - let stopwatch = host_metrics.stopwatch.cheap_clone(); - let _section = stopwatch - .start_section(&format!("host_export_{}", name_for_metrics)); - - let ctx = HostFnCtx { - logger: caller.data().ctx.logger.cheap_clone(), - block_ptr: caller.data().ctx.block_ptr.cheap_clone(), - gas: gas.cheap_clone(), - metrics: host_metrics.cheap_clone(), - heap: &mut WasmInstanceContext::new(&mut caller), - }; - let ret = (host_fn.func)(ctx, call_ptr).await.map_err(|e| match e { - HostExportError::Deterministic(e) => { - caller.data_mut().deterministic_host_trap = true; - e - } - HostExportError::PossibleReorg(e) => { - caller.data_mut().possible_reorg = true; - e - } - HostExportError::Unknown(e) => e, - })?; - host_metrics.observe_host_fn_execution_time( - start.elapsed().as_secs_f64(), - &name_for_metrics, - ); - Ok(ret) - }) - }, - )?; - } - } - - link!("ethereum.encode", ethereum_encode, params_ptr); - link!("ethereum.decode", ethereum_decode, params_ptr, data_ptr); - - link!("abort", abort, message_ptr, file_name_ptr, line, column); - - link!("store.get", store_get, "host_export_store_get", entity, id); - link!( - "store.loadRelated", - store_load_related, - "host_export_store_load_related", - entity, - id, - field - ); - link!( - "store.get_in_block", - store_get_in_block, - "host_export_store_get_in_block", - entity, - id - ); - link!( - "store.set", - store_set, - "host_export_store_set", - entity, - id, - data - ); - - // All IPFS-related functions exported by the host WASM runtime should be listed in the - // graph::data::subgraph::features::IPFS_ON_ETHEREUM_CONTRACTS_FUNCTION_NAMES array for - // automatic feature detection to work. - // - // For reference, search this codebase for: ff652476-e6ad-40e4-85b8-e815d6c6e5e2 - link!("ipfs.cat", ipfs_cat, "host_export_ipfs_cat", hash_ptr); - link!( - "ipfs.map", - ipfs_map, - "host_export_ipfs_map", - link_ptr, - callback, - user_data, - flags - ); - // The previous ipfs-related functions are unconditionally linked for backward compatibility - if experimental_features.allow_non_deterministic_ipfs { - link!( - "ipfs.getBlock", - ipfs_get_block, - "host_export_ipfs_get_block", - hash_ptr - ); - } - - link!("store.remove", store_remove, entity_ptr, id_ptr); - - link!("typeConversion.bytesToString", bytes_to_string, ptr); - link!("typeConversion.bytesToHex", bytes_to_hex, ptr); - link!("typeConversion.bigIntToString", big_int_to_string, ptr); - link!("typeConversion.bigIntToHex", big_int_to_hex, ptr); - link!("typeConversion.stringToH160", string_to_h160, ptr); - link!("typeConversion.bytesToBase58", bytes_to_base58, ptr); - - link!("json.fromBytes", json_from_bytes, ptr); - link!("json.try_fromBytes", json_try_from_bytes, ptr); - link!("json.toI64", json_to_i64, ptr); - link!("json.toU64", json_to_u64, ptr); - link!("json.toF64", json_to_f64, ptr); - link!("json.toBigInt", json_to_big_int, ptr); - - link!("yaml.fromBytes", yaml_from_bytes, ptr); - link!("yaml.try_fromBytes", yaml_try_from_bytes, ptr); - - link!("crypto.keccak256", crypto_keccak_256, ptr); - - link!("bigInt.plus", big_int_plus, x_ptr, y_ptr); - link!("bigInt.minus", big_int_minus, x_ptr, y_ptr); - link!("bigInt.times", big_int_times, x_ptr, y_ptr); - link!("bigInt.dividedBy", big_int_divided_by, x_ptr, y_ptr); - link!("bigInt.dividedByDecimal", big_int_divided_by_decimal, x, y); - link!("bigInt.mod", big_int_mod, x_ptr, y_ptr); - link!("bigInt.pow", big_int_pow, x_ptr, exp); - link!("bigInt.fromString", big_int_from_string, ptr); - link!("bigInt.bitOr", big_int_bit_or, x_ptr, y_ptr); - link!("bigInt.bitAnd", big_int_bit_and, x_ptr, y_ptr); - link!("bigInt.leftShift", big_int_left_shift, x_ptr, bits); - link!("bigInt.rightShift", big_int_right_shift, x_ptr, bits); - - link!("bigDecimal.toString", big_decimal_to_string, ptr); - link!("bigDecimal.fromString", big_decimal_from_string, ptr); - link!("bigDecimal.plus", big_decimal_plus, x_ptr, y_ptr); - link!("bigDecimal.minus", big_decimal_minus, x_ptr, y_ptr); - link!("bigDecimal.times", big_decimal_times, x_ptr, y_ptr); - link!("bigDecimal.dividedBy", big_decimal_divided_by, x, y); - link!("bigDecimal.equals", big_decimal_equals, x_ptr, y_ptr); - - link!("dataSource.create", data_source_create, name, params); - link!( - "dataSource.createWithContext", - data_source_create_with_context, - name, - params, - context - ); - link!("dataSource.address", data_source_address,); - link!("dataSource.network", data_source_network,); - link!("dataSource.context", data_source_context,); - - link!("ens.nameByHash", ens_name_by_hash, ptr); - - link!("log.log", log_log, level, msg_ptr); - - // `arweave and `box` functionality was removed, but apiVersion <= 0.0.4 must link it. - if api_version <= Version::new(0, 0, 4) { - link!("arweave.transactionData", arweave_transaction_data, ptr); - link!("box.profile", box_profile, ptr); - } - - // link the `gas` function - // See also e3f03e62-40e4-4f8c-b4a1-d0375cca0b76 - linker.func_wrap( - "gas", - "gas", - |mut caller: wasmtime::Caller<'_, WasmInstanceData>, - gas_used: u32| - -> anyhow::Result<()> { - // Gas metering has a relevant execution cost cost, being called tens of thousands - // of times per handler, but it's not worth having a stopwatch section here because - // the cost of measuring would be greater than the cost of `consume_host_fn`. Last - // time this was benchmarked it took < 100ns to run. - if let Err(e) = caller - .data() - .gas - .consume_host_fn_with_metrics(gas_used.saturating_into(), "gas") - { - caller.data_mut().deterministic_host_trap = true; - return Err(e.into()); - } - - Ok(()) - }, - )?; - - let instance = linker - .instantiate_async(store.as_context_mut(), &valid_module.module) + let instance = valid_module + .instance_pre + .instantiate_async(store.as_context_mut()) .await?; let asc_heap = AscHeapCtx::new( From 845a53657b9f802d7b0a69ee3f016a0246234191 Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Fri, 13 Feb 2026 14:50:24 -0800 Subject: [PATCH 4/5] runtime: Cache asc_type_id results on ValidModule Every WASM heap allocation (API version > 0.0.4) called asc_type_id(), which did a call_async into the WASM module for a trivial idof() switch. The result is deterministic per compiled module, yet every allocation paid ~200-500ns of call_async overhead (fiber creation, epoch checking, context switching). Cache the results on ValidModule using a parking_lot::RwLock, so after the first trigger warms the cache, all subsequent lookups are cheap read-lock hits. Lock guards never span .await points. --- graph/src/runtime/mod.rs | 2 +- runtime/test/src/test.rs | 10 +++++----- runtime/test/src/test/abi.rs | 2 +- runtime/wasm/src/mapping.rs | 17 ++++++++++++++++- runtime/wasm/src/module/mod.rs | 20 ++++++++++++++++---- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/graph/src/runtime/mod.rs b/graph/src/runtime/mod.rs index ac94d33c32f..c91a5548b08 100644 --- a/graph/src/runtime/mod.rs +++ b/graph/src/runtime/mod.rs @@ -157,7 +157,7 @@ impl_asc_type!(u8, u16, u32, u64, i8, i32, i64, f32, f64); /// 3. Once defined, items and their discriminants cannot be changed, as this would break running /// subgraphs compiled in previous versions of this representation. #[repr(u32)] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum IndexForAscTypeId { // Ethereum type IDs String = 0, diff --git a/runtime/test/src/test.rs b/runtime/test/src/test.rs index 2a367a25e69..c656a2703f1 100644 --- a/runtime/test/src/test.rs +++ b/runtime/test/src/test.rs @@ -401,7 +401,7 @@ async fn json_conversions_v0_0_4() { #[graph::test] async fn json_conversions_v0_0_5() { - test_json_conversions(API_VERSION_0_0_5, 2289897).await; + test_json_conversions(API_VERSION_0_0_5, 2214813).await; } async fn test_json_parsing(api_version: Version, gas_used: u64) { @@ -766,7 +766,7 @@ async fn big_int_to_hex_v0_0_4() { #[graph::test] async fn big_int_to_hex_v0_0_5() { - test_big_int_to_hex(API_VERSION_0_0_5, 2858580).await; + test_big_int_to_hex(API_VERSION_0_0_5, 2565990).await; } async fn test_big_int_arithmetic(api_version: Version, gas_used: u64) { @@ -832,7 +832,7 @@ async fn big_int_arithmetic_v0_0_4() { #[graph::test] async fn big_int_arithmetic_v0_0_5() { - test_big_int_arithmetic(API_VERSION_0_0_5, 7318364).await; + test_big_int_arithmetic(API_VERSION_0_0_5, 5256825).await; } async fn test_abort(api_version: Version, error_msg: &str) { @@ -970,7 +970,7 @@ async fn data_source_create_v0_0_4() { #[graph::test] async fn data_source_create_v0_0_5() { - test_data_source_create(API_VERSION_0_0_5, 101450079).await; + test_data_source_create(API_VERSION_0_0_5, 101425051).await; } async fn test_ens_name_by_hash(api_version: Version) { @@ -1827,5 +1827,5 @@ async fn yaml_parsing_v0_0_4() { #[graph::test] async fn yaml_parsing_v0_0_5() { - test_yaml_parsing(API_VERSION_0_0_5, 1053955992265).await; + test_yaml_parsing(API_VERSION_0_0_5, 1053946160531).await; } diff --git a/runtime/test/src/test/abi.rs b/runtime/test/src/test/abi.rs index 1d50ccc8f0a..8b81b014027 100644 --- a/runtime/test/src/test/abi.rs +++ b/runtime/test/src/test/abi.rs @@ -117,7 +117,7 @@ async fn abi_array_v0_0_4() { #[graph::test] async fn abi_array_v0_0_5() { - test_abi_array(API_VERSION_0_0_5, 1636130).await; + test_abi_array(API_VERSION_0_0_5, 1561046).await; } async fn test_abi_subarray(api_version: Version) { diff --git a/runtime/wasm/src/mapping.rs b/runtime/wasm/src/mapping.rs index 5bfe2859fcd..e2613d5a9fb 100644 --- a/runtime/wasm/src/mapping.rs +++ b/runtime/wasm/src/mapping.rs @@ -7,10 +7,12 @@ use graph::data_source::{MappingTrigger, TriggerWithHandler}; use graph::futures01::sync::mpsc; use graph::futures01::{Future as _, Stream as _}; use graph::futures03::channel::oneshot::Sender; +use graph::parking_lot::RwLock; use graph::prelude::*; use graph::runtime::gas::Gas; +use graph::runtime::IndexForAscTypeId; use parity_wasm::elements::ExportEntry; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::panic::AssertUnwindSafe; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -248,6 +250,10 @@ pub struct ValidModule { // Used as a guard to terminate this task dependency. epoch_counter_abort_handle: Option, + + /// Cache for asc_type_id results. Maps IndexForAscTypeId to their WASM runtime + /// type IDs. Populated lazily on first use; deterministic per compiled module. + asc_type_id_cache: RwLock>, } impl ValidModule { @@ -355,8 +361,17 @@ impl ValidModule { start_function, timeout, epoch_counter_abort_handle, + asc_type_id_cache: RwLock::new(HashMap::new()), }) } + + pub fn get_cached_type_id(&self, idx: IndexForAscTypeId) -> Option { + self.asc_type_id_cache.read().get(&idx).copied() + } + + pub fn cache_type_id(&self, idx: IndexForAscTypeId, type_id: u32) { + self.asc_type_id_cache.write().insert(idx, type_id); + } } impl Drop for ValidModule { diff --git a/runtime/wasm/src/module/mod.rs b/runtime/wasm/src/module/mod.rs index 02914abc519..fddfe4b8c01 100644 --- a/runtime/wasm/src/module/mod.rs +++ b/runtime/wasm/src/module/mod.rs @@ -368,17 +368,29 @@ impl AscHeap for WasmInstanceContext<'_> { &mut self, type_id_index: IndexForAscTypeId, ) -> Result { + // Check the module-level cache. Lives on ValidModule (Arc-shared, persists + // across all triggers for this subgraph module). + if let Some(type_id) = self.as_ref().valid_module.get_cached_type_id(type_id_index) { + return Ok(type_id); + } + + // Cache miss: call into WASM. let asc_heap = self.asc_heap().cheap_clone(); let func = asc_heap.id_of_type.as_ref().unwrap(); - - // Unwrap ok because it's only called on correct apiVersion, look for AscPtr::generate_header - func.call_async(self.as_context_mut(), type_id_index as u32) + let type_id = func + .call_async(self.as_context_mut(), type_id_index as u32) .await .map_err(|trap| { host_export_error_from_trap( trap, format!("Failed to call 'asc_type_id' with '{:?}'", type_id_index), ) - }) + })?; + + // Store for all future triggers. + self.as_ref() + .valid_module + .cache_type_id(type_id_index, type_id); + Ok(type_id) } } From b28a33704cbba1f9da5b2167931ca43b4f29bd0d Mon Sep 17 00:00:00 2001 From: David Lutterkort Date: Fri, 13 Feb 2026 15:57:20 -0800 Subject: [PATCH 5/5] runtime: Register chain host functions explicitly instead of by dynamic discovery Replace the dynamic loop that discovered chain-specific host functions by excluding BUILTIN_IMPORT_NAMES with explicit registration calls for each chain function (ethereum.call, ethereum.getBalance, ethereum.hasCode). Extract the dispatcher into a `link_chain_host_fn` helper that: - Takes a `&'static str` name, eliminating the `Box::leak` for name strings - Precomputes metrics strings once rather than on every call - Returns a proper error instead of panicking when a chain host function is not available for the current chain Delete the BUILTIN_IMPORT_NAMES constant (55 entries) which is no longer needed. --- runtime/wasm/src/module/instance.rs | 204 +++++++++++----------------- 1 file changed, 81 insertions(+), 123 deletions(-) diff --git a/runtime/wasm/src/module/instance.rs b/runtime/wasm/src/module/instance.rs index dd7c8123e4c..a4bf1f34d81 100644 --- a/runtime/wasm/src/module/instance.rs +++ b/runtime/wasm/src/module/instance.rs @@ -249,65 +249,82 @@ impl WasmInstance { } } -/// Import names that are handled by builtin host functions. Any import name -/// in `import_name_to_modules` that is not in this set and is not `gas` is -/// assumed to be a chain-specific host function. -const BUILTIN_IMPORT_NAMES: &[&str] = &[ - "ethereum.encode", - "ethereum.decode", - "abort", - "store.get", - "store.loadRelated", - "store.get_in_block", - "store.set", - "store.remove", - "ipfs.cat", - "ipfs.map", - "ipfs.getBlock", - "typeConversion.bytesToString", - "typeConversion.bytesToHex", - "typeConversion.bigIntToString", - "typeConversion.bigIntToHex", - "typeConversion.stringToH160", - "typeConversion.bytesToBase58", - "json.fromBytes", - "json.try_fromBytes", - "json.toI64", - "json.toU64", - "json.toF64", - "json.toBigInt", - "yaml.fromBytes", - "yaml.try_fromBytes", - "crypto.keccak256", - "bigInt.plus", - "bigInt.minus", - "bigInt.times", - "bigInt.dividedBy", - "bigInt.dividedByDecimal", - "bigInt.mod", - "bigInt.pow", - "bigInt.fromString", - "bigInt.bitOr", - "bigInt.bitAnd", - "bigInt.leftShift", - "bigInt.rightShift", - "bigDecimal.toString", - "bigDecimal.fromString", - "bigDecimal.plus", - "bigDecimal.minus", - "bigDecimal.times", - "bigDecimal.dividedBy", - "bigDecimal.equals", - "dataSource.create", - "dataSource.createWithContext", - "dataSource.address", - "dataSource.network", - "dataSource.context", - "ens.nameByHash", - "log.log", - "arweave.transactionData", - "box.profile", -]; +/// Register a chain-specific host function dispatcher for the given import name. +/// The registered closure looks up the `HostFn` by name from `caller.data().ctx.host_fns` +/// at call time. If the module doesn't import this function, this is a no-op. +fn link_chain_host_fn( + linker: &mut Linker, + import_name_to_modules: &BTreeMap>, + name: &'static str, +) -> Result<(), anyhow::Error> { + let modules = match import_name_to_modules.get(name) { + Some(m) => m, + None => return Ok(()), + }; + + let name_for_metrics = name.replace('.', "_"); + let section_name = format!("host_export_{}", name_for_metrics); + + for module in modules { + let name_for_metrics = name_for_metrics.clone(); + let section_name = section_name.clone(); + linker.func_wrap_async( + module, + name, + move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, (call_ptr,): (u32,)| { + let name_for_metrics = name_for_metrics.clone(); + let section_name = section_name.clone(); + Box::new(async move { + let host_fn = caller + .data() + .ctx + .host_fns + .iter() + .find(|hf| hf.name == name) + .ok_or_else(|| { + anyhow::anyhow!( + "chain host function '{}' is not available for this chain", + name + ) + })? + .cheap_clone(); + + let start = Instant::now(); + + let gas = caller.data().gas.cheap_clone(); + let host_metrics = caller.data().host_metrics.cheap_clone(); + let stopwatch = host_metrics.stopwatch.cheap_clone(); + let _section = stopwatch.start_section(§ion_name); + + let ctx = HostFnCtx { + logger: caller.data().ctx.logger.cheap_clone(), + block_ptr: caller.data().ctx.block_ptr.cheap_clone(), + gas: gas.cheap_clone(), + metrics: host_metrics.cheap_clone(), + heap: &mut WasmInstanceContext::new(&mut caller), + }; + let ret = (host_fn.func)(ctx, call_ptr).await.map_err(|e| match e { + HostExportError::Deterministic(e) => { + caller.data_mut().deterministic_host_trap = true; + e + } + HostExportError::PossibleReorg(e) => { + caller.data_mut().possible_reorg = true; + e + } + HostExportError::Unknown(e) => e, + })?; + host_metrics.observe_host_fn_execution_time( + start.elapsed().as_secs_f64(), + &name_for_metrics, + ); + Ok(ret) + }) + }, + )?; + } + Ok(()) +} /// Build a pre-linked `Linker` for a WASM module. This linker can be reused across triggers by /// calling `linker.instantiate_pre()` once and then `instance_pre.instantiate_async()` per trigger. @@ -431,70 +448,11 @@ pub(crate) fn build_linker( }; } - // Link chain-specific host fns. Any import name not in BUILTIN_IMPORT_NAMES and not "gas" - // is assumed to be a chain-specific host function. We register a generic dispatcher that - // looks up the actual HostFn by name from caller.data().ctx.host_fns at call time. - for (import_name, modules) in import_name_to_modules { - if import_name == "gas" || BUILTIN_IMPORT_NAMES.contains(&import_name.as_str()) { - continue; - } - - // Leak the name so we get a &'static str for metrics. These are a small, fixed set of - // chain host_fn names (e.g. "ethereum.call") so the leak is bounded. - let name: &'static str = Box::leak(import_name.clone().into_boxed_str()); - - for module in modules { - linker.func_wrap_async( - module, - name, - move |mut caller: wasmtime::Caller<'_, WasmInstanceData>, (call_ptr,): (u32,)| { - Box::new(async move { - let host_fn = caller - .data() - .ctx - .host_fns - .iter() - .find(|hf| hf.name == name) - .expect("chain host_fn not found") - .cheap_clone(); - - let start = Instant::now(); - - let gas = caller.data().gas.cheap_clone(); - let name_for_metrics = name.replace('.', "_"); - let host_metrics = caller.data().host_metrics.cheap_clone(); - let stopwatch = host_metrics.stopwatch.cheap_clone(); - let _section = - stopwatch.start_section(&format!("host_export_{}", name_for_metrics)); - - let ctx = HostFnCtx { - logger: caller.data().ctx.logger.cheap_clone(), - block_ptr: caller.data().ctx.block_ptr.cheap_clone(), - gas: gas.cheap_clone(), - metrics: host_metrics.cheap_clone(), - heap: &mut WasmInstanceContext::new(&mut caller), - }; - let ret = (host_fn.func)(ctx, call_ptr).await.map_err(|e| match e { - HostExportError::Deterministic(e) => { - caller.data_mut().deterministic_host_trap = true; - e - } - HostExportError::PossibleReorg(e) => { - caller.data_mut().possible_reorg = true; - e - } - HostExportError::Unknown(e) => e, - })?; - host_metrics.observe_host_fn_execution_time( - start.elapsed().as_secs_f64(), - &name_for_metrics, - ); - Ok(ret) - }) - }, - )?; - } - } + // Chain-specific host functions. Each is registered explicitly rather than + // discovered dynamically from imports. + link_chain_host_fn(&mut linker, import_name_to_modules, "ethereum.call")?; + link_chain_host_fn(&mut linker, import_name_to_modules, "ethereum.getBalance")?; + link_chain_host_fn(&mut linker, import_name_to_modules, "ethereum.hasCode")?; link!("ethereum.encode", ethereum_encode, params_ptr); link!("ethereum.decode", ethereum_decode, params_ptr, data_ptr);