diff --git a/Cargo.lock b/Cargo.lock index 61223be..4519285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3758,6 +3758,7 @@ dependencies = [ "alloy-sol-types", "nexum-sdk", "nexum-sdk-test", + "strum", "tracing", "wit-bindgen 0.59.0", ] diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index 57e0059..d4b3feb 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -424,7 +424,7 @@ mod tests { #[test] fn fragment_and_query_after_the_host_do_not_influence_the_host_check() { - // Historical bug (see issue #57): a naive host-extractor could + // Historical bug: a naive host-extractor could // be fooled by a `/`-bearing query string or fragment appended // after the real host. `http::Uri::host` is unaffected by // either - the decoy text never becomes part of the host. diff --git a/crates/nexum-runtime/src/supervisor/artifact.rs b/crates/nexum-runtime/src/supervisor/artifact.rs index 4bf7c25..ce7a27d 100644 --- a/crates/nexum-runtime/src/supervisor/artifact.rs +++ b/crates/nexum-runtime/src/supervisor/artifact.rs @@ -51,6 +51,7 @@ pub(super) fn read_verified_component( let component = CodeBuilder::new(engine) .wasm_binary_or_text(&bytes, Some(path)) .and_then(|builder| builder.compile_component()) + // wasmtime::Error is not StdError, so anyhow's with_context needs the bridge. .map_err(Error::from) .with_context(|| format!("compile {}", path.display()))?; Ok((component, actual)) diff --git a/crates/nexum-runtime/src/supervisor/load.rs b/crates/nexum-runtime/src/supervisor/load.rs index 849f016..9cd60e6 100644 --- a/crates/nexum-runtime/src/supervisor/load.rs +++ b/crates/nexum-runtime/src/supervisor/load.rs @@ -205,10 +205,7 @@ async fn run_init( config: &Config, deadline: Duration, ) -> Result> { - with_dispatch_deadline(deadline, bindings.call_init(store, config)) - .await - .map_err(Error::from)? - .map_err(Error::from) + Ok(with_dispatch_deadline(deadline, bindings.call_init(store, config)).await??) } /// Instantiates the cached component on a fresh store and runs `init`; what @@ -222,6 +219,7 @@ pub(super) async fn instantiate_module( ) -> Result<(EventModule, Result<(), Fault>)> { let bindings = EventModule::instantiate_async(&mut *store, &seed.artifact.component, linker) .await + // wasmtime::Error is not StdError, so anyhow's with_context needs the bridge. .map_err(Error::from) .with_context(|| format!("instantiate {name}"))?; let init = run_init( @@ -253,7 +251,6 @@ pub(super) async fn install_provider( kind.install(seed.instance(&linker, sections, store, liveness), service), ) .await - .map_err(Error::from) .with_context(|| format!("provider kind {} did not install in time", kind.kind()))? } diff --git a/crates/nexum-sdk/src/address.rs b/crates/nexum-sdk/src/address.rs index 2e8e8e4..d964950 100644 --- a/crates/nexum-sdk/src/address.rs +++ b/crates/nexum-sdk/src/address.rs @@ -7,6 +7,8 @@ use alloy_primitives::Address; +use crate::host::Fault; + /// Typed errors from [`parse_address_list`] and [`parse_address`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -29,6 +31,12 @@ pub enum AddressParse { Empty, } +impl From for Fault { + fn from(e: AddressParse) -> Self { + Fault::InvalidInput(e.to_string()) + } +} + /// Parse a comma-separated address list, trimming whitespace and /// skipping empty segments. [`AddressParse::Empty`] on no segment, /// [`AddressParse::InvalidAddress`] on the first bad entry (`index` @@ -128,4 +136,18 @@ mod tests { other => panic!("expected InvalidAddress, got {other:?}"), } } + + #[test] + fn address_parse_folds_into_an_invalid_input_fault() { + let fault = Fault::from(parse_address("0xdeadbeef").unwrap_err()); + let Fault::InvalidInput(message) = fault else { + panic!("expected invalid-input fault, got {fault:?}"); + }; + assert!(message.contains("0xdeadbeef")); + + assert!(matches!( + Fault::from(parse_address_list("").unwrap_err()), + Fault::InvalidInput(_) + )); + } } diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index b1dba85..5ae4c08 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -5,10 +5,9 @@ use alloy_primitives::{I256, U256}; use thiserror::Error; -/// Why a config lookup or parse failed. Modules wrap it into a -/// [`Fault::InvalidInput`] at the boundary. -/// -/// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput +use crate::host::Fault; + +/// Why a config lookup or parse failed. #[derive(Debug, Error)] #[non_exhaustive] pub enum ConfigError { @@ -36,6 +35,12 @@ pub enum ConfigError { }, } +impl From for Fault { + fn from(e: ConfigError) -> Self { + Fault::InvalidInput(e.to_string()) + } +} + /// Look up a required entry; `Err(MissingKey)` if absent. pub fn get_required<'a>( entries: &'a [(String, String)], @@ -137,6 +142,15 @@ mod tests { assert!(matches!(err, ConfigError::MissingKey { ref key } if key == "b")); } + #[test] + fn config_error_folds_into_an_invalid_input_fault() { + let fault = Fault::from(get_required(&entries(&[]), "threshold").unwrap_err()); + let Fault::InvalidInput(message) = fault else { + panic!("expected invalid-input fault, got {fault:?}"); + }; + assert!(message.contains("threshold")); + } + #[test] fn get_optional_returns_none_for_missing() { let cfg = entries(&[("a", "1")]); diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 766d52f..3c6d66c 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -40,6 +40,7 @@ impl BalanceTracker { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - logic::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) + logic::on_block(&WitBindgenHost, block.chain_id, cfg)?; + Ok(()) } } diff --git a/modules/examples/balance-tracker/src/logic.rs b/modules/examples/balance-tracker/src/logic.rs index c475eec..1000fb2 100644 --- a/modules/examples/balance-tracker/src/logic.rs +++ b/modules/examples/balance-tracker/src/logic.rs @@ -5,7 +5,7 @@ //! `nexum_sdk_test::MockHost`. use nexum_sdk::address::parse_address_list; -use nexum_sdk::config::{self, ConfigError}; +use nexum_sdk::config; use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; use nexum_sdk::prelude::{Address, U256}; @@ -111,10 +111,9 @@ fn parse_u256_le(bytes: &[u8]) -> Option { /// Parse `module.toml::[config]` into a typed [`Settings`]. pub fn parse_config(entries: &[(String, String)]) -> Result { - let addresses_raw = config::get_required(entries, "addresses").map_err(config_err)?; - let change_threshold_raw = - config::get_required(entries, "change_threshold").map_err(config_err)?; - let addresses = parse_address_list(addresses_raw).map_err(|e| invalid_input(e.to_string()))?; + let addresses_raw = config::get_required(entries, "addresses")?; + let change_threshold_raw = config::get_required(entries, "change_threshold")?; + let addresses = parse_address_list(addresses_raw)?; let change_threshold = change_threshold_raw .parse::() .map_err(|e| invalid_input(format!("change_threshold: {e}")))?; @@ -128,10 +127,6 @@ fn invalid_input(message: impl Into) -> Fault { Fault::InvalidInput(message.into()) } -fn config_err(e: ConfigError) -> Fault { - invalid_input(e.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -225,6 +220,19 @@ mod tests { assert!(message.contains("change_threshold")); } + #[test] + fn parse_config_rejects_a_malformed_address() { + let err = parse_config(&[ + ("addresses".into(), "0xdeadbeef".into()), + ("change_threshold".into(), "1".into()), + ]) + .unwrap_err(); + let Fault::InvalidInput(message) = err else { + panic!("expected invalid-input fault, got {err:?}"); + }; + assert!(message.contains("0xdeadbeef")); + } + fn one_addr_settings(threshold_wei: u128) -> Settings { Settings { addresses: vec![address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")], diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index 7290114..2031606 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -44,6 +44,7 @@ impl HttpProbe { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - logic::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) + logic::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number)?; + Ok(()) } } diff --git a/modules/examples/http-probe/src/logic.rs b/modules/examples/http-probe/src/logic.rs index 374c017..a195ff7 100644 --- a/modules/examples/http-probe/src/logic.rs +++ b/modules/examples/http-probe/src/logic.rs @@ -3,7 +3,7 @@ //! HTTP flows through the [`Fetch`] seam; `lib.rs` hands [`on_block`] //! `nexum_sdk::http::WasiFetch`, tests hand it a stub fetcher. -use nexum_sdk::config::{self, ConfigError}; +use nexum_sdk::config; use nexum_sdk::host::Fault; use nexum_sdk::http::{Fetch, FetchError}; @@ -90,8 +90,8 @@ fn internal(message: String) -> Fault { /// Parse `module.toml::[config]` into a typed [`Settings`]. pub fn parse_config(entries: &[(String, String)]) -> Result { - let probe_url = config::get_required(entries, "probe_url").map_err(config_err)?; - let denied_url = config::get_required(entries, "denied_url").map_err(config_err)?; + let probe_url = config::get_required(entries, "probe_url")?; + let denied_url = config::get_required(entries, "denied_url")?; let every_n_blocks = match config::get_optional(entries, "every_n_blocks") { Some(raw) => raw .parse::() @@ -112,10 +112,6 @@ fn invalid_input(message: String) -> Fault { Fault::InvalidInput(message) } -fn config_err(e: ConfigError) -> Fault { - invalid_input(e.to_string()) -} - #[cfg(test)] mod tests { use std::cell::RefCell; diff --git a/modules/examples/price-alert/Cargo.toml b/modules/examples/price-alert/Cargo.toml index c31f938..8bdea93 100644 --- a/modules/examples/price-alert/Cargo.toml +++ b/modules/examples/price-alert/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib"] nexum-sdk = { path = "../../../crates/nexum-sdk" } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false } +strum = { version = "0.28", default-features = false, features = ["derive"] } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index a9d16b3..63023d4 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -44,6 +44,7 @@ impl PriceAlert { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) + logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number)?; + Ok(()) } } diff --git a/modules/examples/price-alert/src/logic.rs b/modules/examples/price-alert/src/logic.rs index 3c5c0bc..a51cdd2 100644 --- a/modules/examples/price-alert/src/logic.rs +++ b/modules/examples/price-alert/src/logic.rs @@ -6,7 +6,7 @@ use alloy_primitives::I256; use nexum_sdk::chain::chainlink::read_latest_answer; -use nexum_sdk::config::{self, ConfigError}; +use nexum_sdk::config; use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; use nexum_sdk::prelude::Address; @@ -25,7 +25,8 @@ pub struct Settings { } /// Which side of the threshold the alert fires on. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, strum::EnumString)] +#[strum(serialize_all = "snake_case", ascii_case_insensitive)] pub enum Direction { /// Fire when `answer >= threshold`. Above, @@ -79,12 +80,10 @@ pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { /// Parse `[config]` into a typed [`Settings`]. pub fn parse_config(entries: &[(String, String)]) -> Result { - let oracle_address = config::get_required(entries, "oracle_address") - .map_err(config_err)? + let oracle_address = config::get_required(entries, "oracle_address")? .parse::
() .map_err(|e| invalid(format!("oracle_address: {e}")))?; - let decimals = config::get_required(entries, "decimals") - .map_err(config_err)? + let decimals = config::get_required(entries, "decimals")? .parse::() .map_err(|e| invalid(format!("decimals: {e}")))?; if decimals > 38 { @@ -92,22 +91,14 @@ pub fn parse_config(entries: &[(String, String)]) -> Result { "decimals={decimals} exceeds the I256 power-of-ten budget" ))); } - let threshold_decimal = config::get_required(entries, "threshold").map_err(config_err)?; - let threshold_scaled = - config::scale_decimal(threshold_decimal, decimals, "threshold").map_err(config_err)?; - let direction = match config::get_required(entries, "direction") - .map_err(config_err)? - .to_ascii_lowercase() - .as_str() - { - "above" => Direction::Above, - "below" => Direction::Below, - other => { - return Err(invalid(format!( - "direction: expected 'above'|'below', got {other:?}" - ))); - } - }; + let threshold_decimal = config::get_required(entries, "threshold")?; + let threshold_scaled = config::scale_decimal(threshold_decimal, decimals, "threshold")?; + let raw_direction = config::get_required(entries, "direction")?; + let direction = raw_direction.parse::().map_err(|_| { + invalid(format!( + "direction: expected 'above'|'below', got {raw_direction:?}" + )) + })?; let every_n_blocks = config::get_optional(entries, "every_n_blocks") .map(|s| { s.parse::() @@ -129,11 +120,6 @@ fn invalid(message: impl Into) -> Fault { Fault::InvalidInput(message.into()) } -/// Project a [`ConfigError`] into a [`Fault::InvalidInput`]. -fn config_err(e: ConfigError) -> Fault { - invalid(e.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -219,9 +205,8 @@ mod tests { } // Decimal-parsing tests for the shared scaler live in - // `nexum-sdk::config::tests` now (lifted out of this module per - // PR #55 review). The integration-level parse_config tests below - // still exercise the wiring end-to-end with the SDK helper. + // `nexum-sdk::config::tests`; the parse_config tests below exercise + // the wiring end-to-end with the SDK helper. #[test] fn parse_config_happy_path() { @@ -260,6 +245,30 @@ mod tests { assert_eq!(cfg.direction, Direction::Above); } + #[test] + fn parse_config_reads_direction_case_insensitively_and_rejects_the_rest() { + let entries = |direction: &str| { + vec![ + ( + "oracle_address".into(), + "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), + ), + ("decimals".into(), "8".into()), + ("threshold".into(), "1".into()), + ("direction".into(), direction.to_owned()), + ] + }; + assert_eq!( + parse_config(&entries("ABOVE")).unwrap().direction, + Direction::Above + ); + let err = parse_config(&entries("sideways")).unwrap_err(); + let Fault::InvalidInput(message) = err else { + panic!("expected invalid-input fault, got {err:?}"); + }; + assert!(message.contains("sideways")); + } + #[test] fn parse_config_rejects_missing_key() { let entries = vec![