Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/nexum-runtime/src/host/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/nexum-runtime/src/supervisor/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 2 additions & 5 deletions crates/nexum-runtime/src/supervisor/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,7 @@ async fn run_init<T: RuntimeTypes>(
config: &Config,
deadline: Duration,
) -> Result<Result<(), Fault>> {
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
Expand All @@ -222,6 +219,7 @@ pub(super) async fn instantiate_module<T: RuntimeTypes>(
) -> 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(
Expand Down Expand Up @@ -253,7 +251,6 @@ pub(super) async fn install_provider<T: RuntimeTypes>(
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()))?
}

Expand Down
22 changes: 22 additions & 0 deletions crates/nexum-sdk/src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -29,6 +31,12 @@ pub enum AddressParse {
Empty,
}

impl From<AddressParse> 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`
Expand Down Expand Up @@ -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(_)
));
}
}
22 changes: 18 additions & 4 deletions crates/nexum-sdk/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -36,6 +35,12 @@ pub enum ConfigError {
},
}

impl From<ConfigError> 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)],
Expand Down Expand Up @@ -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")]);
Expand Down
3 changes: 2 additions & 1 deletion modules/examples/balance-tracker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
}
26 changes: 17 additions & 9 deletions modules/examples/balance-tracker/src/logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -111,10 +111,9 @@ fn parse_u256_le(bytes: &[u8]) -> Option<U256> {

/// Parse `module.toml::[config]` into a typed [`Settings`].
pub fn parse_config(entries: &[(String, String)]) -> Result<Settings, Fault> {
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::<U256>()
.map_err(|e| invalid_input(format!("change_threshold: {e}")))?;
Expand All @@ -128,10 +127,6 @@ fn invalid_input(message: impl Into<String>) -> Fault {
Fault::InvalidInput(message.into())
}

fn config_err(e: ConfigError) -> Fault {
invalid_input(e.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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")],
Expand Down
3 changes: 2 additions & 1 deletion modules/examples/http-probe/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
}
10 changes: 3 additions & 7 deletions modules/examples/http-probe/src/logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Settings, Fault> {
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::<u64>()
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions modules/examples/price-alert/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion modules/examples/price-alert/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
}
69 changes: 39 additions & 30 deletions modules/examples/price-alert/src/logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -79,35 +80,25 @@ pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool {

/// Parse `[config]` into a typed [`Settings`].
pub fn parse_config(entries: &[(String, String)]) -> Result<Settings, Fault> {
let oracle_address = config::get_required(entries, "oracle_address")
.map_err(config_err)?
let oracle_address = config::get_required(entries, "oracle_address")?
.parse::<Address>()
.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::<u32>()
.map_err(|e| invalid(format!("decimals: {e}")))?;
if decimals > 38 {
return Err(invalid(format!(
"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::<Direction>().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::<u64>()
Expand All @@ -129,11 +120,6 @@ fn invalid(message: impl Into<String>) -> 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::*;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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![
Expand Down
Loading