From caa725e826c0604ac1e571a2a826780de4bca734 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 05:12:04 +0000 Subject: [PATCH 1/4] sdk: put an alloy provider seam over the chain host Add HostTransport, an alloy Transport dispatching JSON-RPC packets through ChainHost::request, and host.provider(chain) minting a RootProvider over it, driven by a single-poll block_on. Methods outside the typed ChainMethod read surface (mirrored guest-side) fail as -32601 before reaching the host; node errors carry code, message and decoded revert bytes; host faults surface as typed transport errors. Chain and ChainId newtypes replace bare u64 ids at the SDK edge. The hoisted alloy-provider entry drops its native transport features so the wasm guest build stays transport-free; the engine and load-gen re-add theirs at the call site. --- Cargo.lock | 5 + Cargo.toml | 8 +- crates/nexum-runtime/Cargo.toml | 2 +- crates/nexum-sdk/Cargo.toml | 13 +- crates/nexum-sdk/src/chain/id.rs | 125 ++++++++++++ crates/nexum-sdk/src/chain/method.rs | 121 ++++++++++++ crates/nexum-sdk/src/chain/mod.rs | 19 +- crates/nexum-sdk/src/chain/provider.rs | 119 +++++++++++ crates/nexum-sdk/src/chain/transport.rs | 252 ++++++++++++++++++++++++ crates/nexum-sdk/src/lib.rs | 11 +- tools/load-gen/Cargo.toml | 2 +- 11 files changed, 667 insertions(+), 10 deletions(-) create mode 100644 crates/nexum-sdk/src/chain/id.rs create mode 100644 crates/nexum-sdk/src/chain/method.rs create mode 100644 crates/nexum-sdk/src/chain/provider.rs create mode 100644 crates/nexum-sdk/src/chain/transport.rs diff --git a/Cargo.lock b/Cargo.lock index c0d10b14..b9135dee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3645,9 +3645,13 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ + "alloy-json-rpc", "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-sol-types", + "alloy-transport", "http", "nexum-macros", "nexum-sdk-test", @@ -3656,6 +3660,7 @@ dependencies = [ "serde_json", "strum", "thiserror 2.0.18", + "tower", "tracing", "tracing-core", "wstd", diff --git a/Cargo.toml b/Cargo.toml index 6b11a451..d7bf9904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,7 +111,9 @@ clap = { version = "4", features = ["derive"] } # moves every consumer at once. alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +# Featureless here so the guest SDK's wasm build stays transport-free; +# the engine and tooling add ws/ipc/pubsub/reqwest at their call sites. +alloy-provider = { version = "2.1", default-features = false } alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } alloy-transport-ws = { version = "2.1", default-features = false } # Typed EIP-155 chain ids for config keys, provider/orderbook pools, and @@ -173,7 +175,9 @@ toml = "1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } -# alloy JSON-RPC client + transport (engine chain backend). +# alloy JSON-RPC client + transport (engine chain backend and the +# guest SDK's host-backed transport). +alloy-json-rpc = { version = "2.1", default-features = false } alloy-rpc-client = { version = "2.1", default-features = false } alloy-transport = { version = "2.1", default-features = false } diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 63c4be27..cb29b7b9 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -67,7 +67,7 @@ bytes.workspace = true # from a `WsConnect`/`Http` transport so the host's `request` / # `request-batch` impls can hand a raw `(method, params)` pair to # alloy's JSON-RPC layer without reimplementing the codec. -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws", "ipc", "pubsub", "reqwest"] } alloy-rpc-client.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 655e28ed..429aa4b5 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -31,11 +31,22 @@ alloy-primitives.workspace = true # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true +# The provider seam: `HostTransport` speaks alloy's JSON-RPC packet +# vocabulary over `ChainHost::request`, and `provider()` fronts it with +# a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches +# the wasm guest. +alloy-json-rpc.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +alloy-transport.workspace = true +tower.workspace = true # Standard HTTP request/response/method vocabulary; the SDK adds only # the wasi:http-specific `fetch` seam on top. wstd re-exports the same # `http` types, so a request passes through to the client unconverted. http.workspace = true -serde_json.workspace = true +# `raw_value` backs the transport's pass-through of host JSON into +# alloy's `Box` payload slots. +serde_json = { workspace = true, features = ["std", "raw_value"] } strum.workspace = true thiserror.workspace = true # `tracing-core` backs the guest facade's subscriber plumbing; the diff --git a/crates/nexum-sdk/src/chain/id.rs b/crates/nexum-sdk/src/chain/id.rs new file mode 100644 index 00000000..598857c3 --- /dev/null +++ b/crates/nexum-sdk/src/chain/id.rs @@ -0,0 +1,125 @@ +//! Zero-cost chain identity newtypes. + +use core::fmt; + +/// EIP-155 chain id, typed so a bare `u64` never crosses an SDK API. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ChainId(u64); + +impl ChainId { + /// Wrap a raw EIP-155 id. + pub const fn new(id: u64) -> Self { + Self(id) + } + + /// The raw id, for the WIT edge. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl From for ChainId { + fn from(id: u64) -> Self { + Self::new(id) + } +} + +impl From for u64 { + fn from(id: ChainId) -> Self { + id.get() + } +} + +impl fmt::Display for ChainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// A chain a strategy targets, keyed by its [`ChainId`]. The type the +/// provider seam takes; events deliver a raw id, so `ev.chain_id.into()` +/// bridges at the handler edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Chain(ChainId); + +impl Chain { + /// Ethereum mainnet. + pub const MAINNET: Self = Self::from_id(1); + /// Gnosis Chain. + pub const GNOSIS: Self = Self::from_id(100); + /// Base. + pub const BASE: Self = Self::from_id(8_453); + /// Arbitrum One. + pub const ARBITRUM: Self = Self::from_id(42_161); + /// Sepolia testnet. + pub const SEPOLIA: Self = Self::from_id(11_155_111); + + /// Chain with the given raw EIP-155 id. + pub const fn from_id(id: u64) -> Self { + Self(ChainId::new(id)) + } + + /// The chain's id. + pub const fn id(self) -> ChainId { + self.0 + } +} + +impl From for Chain { + fn from(id: u64) -> Self { + Self::from_id(id) + } +} + +impl From for Chain { + fn from(id: ChainId) -> Self { + Self(id) + } +} + +impl From for ChainId { + fn from(chain: Chain) -> Self { + chain.id() + } +} + +impl From for u64 { + fn from(chain: Chain) -> Self { + chain.id().get() + } +} + +impl fmt::Display for Chain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::{Chain, ChainId}; + + #[test] + fn ids_round_trip() { + assert_eq!(u64::from(ChainId::new(100)), 100); + assert_eq!(ChainId::from(7u64).get(), 7); + assert_eq!(u64::from(Chain::from_id(42)), 42); + assert_eq!(Chain::from(ChainId::new(1)), Chain::MAINNET); + assert_eq!(ChainId::from(Chain::SEPOLIA).get(), 11_155_111); + } + + #[test] + fn named_chains_carry_canonical_ids() { + assert_eq!(u64::from(Chain::MAINNET), 1); + assert_eq!(u64::from(Chain::GNOSIS), 100); + assert_eq!(u64::from(Chain::BASE), 8_453); + assert_eq!(u64::from(Chain::ARBITRUM), 42_161); + assert_eq!(u64::from(Chain::SEPOLIA), 11_155_111); + } + + #[test] + fn display_is_the_raw_id() { + assert_eq!(Chain::GNOSIS.to_string(), "100"); + assert_eq!(ChainId::new(1).to_string(), "1"); + } +} diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs new file mode 100644 index 00000000..3f45013b --- /dev/null +++ b/crates/nexum-sdk/src/chain/method.rs @@ -0,0 +1,121 @@ +//! The typed JSON-RPC method surface, guest side. + +use strum::{EnumString, IntoStaticStr}; + +/// The permitted JSON-RPC read surface as a closed type, mirroring the +/// runtime's `ChainMethod` case for case. Signing and mutating methods +/// have no variant, so they cannot be represented and never cross the +/// WIT edge; [`HostTransport`](super::HostTransport) rejects anything +/// outside this set before calling the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +pub enum ChainMethod { + /// `eth_blockNumber`. + #[strum(serialize = "eth_blockNumber")] + EthBlockNumber, + /// `eth_call`. + #[strum(serialize = "eth_call")] + EthCall, + /// `eth_chainId`. + #[strum(serialize = "eth_chainId")] + EthChainId, + /// `eth_estimateGas`. + #[strum(serialize = "eth_estimateGas")] + EthEstimateGas, + /// `eth_feeHistory`. + #[strum(serialize = "eth_feeHistory")] + EthFeeHistory, + /// `eth_gasPrice`. + #[strum(serialize = "eth_gasPrice")] + EthGasPrice, + /// `eth_maxPriorityFeePerGas`. + #[strum(serialize = "eth_maxPriorityFeePerGas")] + EthMaxPriorityFeePerGas, + /// `eth_getBalance`. + #[strum(serialize = "eth_getBalance")] + EthGetBalance, + /// `eth_getBlockByHash`. + #[strum(serialize = "eth_getBlockByHash")] + EthGetBlockByHash, + /// `eth_getBlockByNumber`. + #[strum(serialize = "eth_getBlockByNumber")] + EthGetBlockByNumber, + /// `eth_getBlockReceipts`. + #[strum(serialize = "eth_getBlockReceipts")] + EthGetBlockReceipts, + /// `eth_getCode`. + #[strum(serialize = "eth_getCode")] + EthGetCode, + /// `eth_getLogs`. + #[strum(serialize = "eth_getLogs")] + EthGetLogs, + /// `eth_getProof`. + #[strum(serialize = "eth_getProof")] + EthGetProof, + /// `eth_getStorageAt`. + #[strum(serialize = "eth_getStorageAt")] + EthGetStorageAt, + /// `eth_getTransactionByHash`. + #[strum(serialize = "eth_getTransactionByHash")] + EthGetTransactionByHash, + /// `eth_getTransactionCount`. + #[strum(serialize = "eth_getTransactionCount")] + EthGetTransactionCount, + /// `eth_getTransactionReceipt`. + #[strum(serialize = "eth_getTransactionReceipt")] + EthGetTransactionReceipt, + /// `net_version`. + #[strum(serialize = "net_version")] + NetVersion, +} + +impl ChainMethod { + /// The wire method name. `&'static` because the set is closed. + pub fn as_str(self) -> &'static str { + self.into() + } +} + +#[cfg(test)] +mod tests { + use super::ChainMethod; + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "admin_peers", + "debug_traceCall", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), + Ok(ChainMethod::EthGetBalance), + ); + } +} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index dd60ba0d..e10c8808 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,10 +1,21 @@ -//! `chain::request` JSON plumbing. +//! Chain access for guest strategies. //! -//! Build the `[{to, data}, "latest"]` params array for `eth_call` and -//! parse the `"0x..."` hex result string. Pure-logic helpers so a -//! module can plumb its own `chain::request` shim around them. +//! Typed identity ([`Chain`], [`ChainId`]), the closed JSON-RPC read +//! surface ([`ChainMethod`]), and the alloy provider seam: a +//! [`HostTransport`] over `ChainHost::request` fronted by +//! [`ProviderHost::provider`], driven with [`block_on`]. Plus the +//! `eth_call` JSON plumbing helpers for modules that keep their own +//! `chain::request` shim. pub mod chainlink; pub mod eth_call; +pub mod id; +pub mod method; +pub mod provider; +pub mod transport; pub use eth_call::{eth_call_params, parse_eth_call_result}; +pub use id::{Chain, ChainId}; +pub use method::ChainMethod; +pub use provider::{ProviderHost, block_on}; +pub use transport::HostTransport; diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs new file mode 100644 index 00000000..a46d4f8c --- /dev/null +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -0,0 +1,119 @@ +//! `host.provider(chain)`: an alloy `Provider` over the chain host. + +use std::future::{Future, IntoFuture}; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; + +use super::{Chain, HostTransport}; +use crate::host::ChainHost; + +/// Mints an alloy [`Provider`](alloy_provider::Provider) over +/// [`ChainHost::request`], so a strategy calls typed provider methods +/// instead of hand-building JSON-RPC. Blanket-implemented for every +/// cloneable [`ChainHost`]; drive the returned futures with +/// [`block_on`]. +/// +/// ``` +/// use alloy_provider::Provider; +/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; +/// use nexum_sdk::host::{ChainError, ChainHost}; +/// +/// #[derive(Clone)] +/// struct StubHost; +/// impl ChainHost for StubHost { +/// fn request(&self, _: u64, _: &str, _: &str) -> Result { +/// Ok("\"0x2a\"".into()) +/// } +/// } +/// +/// let provider = StubHost.provider(Chain::MAINNET); +/// let block = block_on(provider.get_block_number()).unwrap(); +/// assert_eq!(block, 42); +/// ``` +pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { + /// Provider for `chain`, routed through the host's RPC stack. + fn provider(&self, chain: Chain) -> RootProvider { + RootProvider::new(RpcClient::new( + HostTransport::new(self.clone(), chain), + false, + )) + } +} + +impl ProviderHost for H {} + +/// Drive a provider future to completion. Host-backed transports +/// resolve synchronously, so this is a poll loop, not a scheduler; a +/// future that awaits anything other than a host call will spin. +pub fn block_on(future: F) -> F::Output { + let mut future = pin!(future.into_future()); + let mut cx = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return output; + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Bytes, address}; + use alloy_provider::Provider; + use alloy_rpc_types_eth::TransactionRequest; + + use super::{ProviderHost, block_on}; + use crate::chain::Chain; + use crate::host::{ChainError, ChainHost}; + + #[derive(Clone)] + struct StubHost; + + impl ChainHost for StubHost { + fn request( + &self, + chain_id: u64, + method: &str, + _params: &str, + ) -> Result { + assert_eq!(chain_id, 100); + match method { + "eth_blockNumber" => Ok("\"0x2a\"".into()), + "eth_call" => Ok("\"0x1234\"".into()), + other => panic!("unexpected method {other}"), + } + } + } + + #[test] + fn provider_reads_typed_values_through_the_host() { + let provider = StubHost.provider(Chain::GNOSIS); + let block = block_on(provider.get_block_number()).expect("block number"); + assert_eq!(block, 42); + } + + #[test] + fn provider_call_decodes_bytes() { + let provider = StubHost.provider(Chain::GNOSIS); + let tx = TransactionRequest::default() + .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); + let out = block_on(provider.call(tx)).expect("eth_call"); + assert_eq!(out, Bytes::from(vec![0x12, 0x34])); + } + + #[test] + fn signing_methods_error_before_the_host() { + let provider = StubHost.provider(Chain::GNOSIS); + let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) + .expect_err("write method is rejected"); + let payload = err.as_error_resp().expect("json-rpc error response"); + assert_eq!(payload.code, -32601); + } + + #[test] + fn block_on_drives_plain_futures() { + assert_eq!(block_on(async { 7 }), 7); + } +} diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs new file mode 100644 index 00000000..10c64974 --- /dev/null +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -0,0 +1,252 @@ +//! [`HostTransport`]: the alloy transport over [`ChainHost::request`]. + +use std::future::ready; +use std::task::{Context, Poll}; + +use alloy_json_rpc::{ + ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, SerializedRequest, +}; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use serde_json::value::RawValue; +use tower::Service; + +use super::{Chain, ChainMethod}; +use crate::host::{ChainError, ChainHost}; + +/// An alloy `Transport` routing JSON-RPC through the host's chain +/// interface. Dispatch is synchronous: the host blocks the guest until +/// the response is available, so every returned future is ready on its +/// first poll and [`block_on`](super::block_on) drives it for free. +/// +/// Methods outside the typed [`ChainMethod`] surface never reach the +/// host; they fail as a JSON-RPC `-32601` error response. A structured +/// node error comes back as the error payload (code, message, revert +/// bytes as `0x` hex); a host [`Fault`](crate::host::Fault) surfaces as +/// a custom transport error carrying the typed fault. +#[derive(Clone, Copy, Debug)] +pub struct HostTransport { + host: H, + chain: Chain, +} + +impl HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + /// Transport dispatching on `chain` through `host`. + pub const fn new(host: H, chain: Chain) -> Self { + Self { host, chain } + } + + fn dispatch(&self, packet: RequestPacket) -> Result { + match packet { + RequestPacket::Single(req) => Ok(ResponsePacket::Single(self.dispatch_single(&req)?)), + RequestPacket::Batch(reqs) => reqs + .iter() + .map(|req| self.dispatch_single(req)) + .collect::, _>>() + .map(ResponsePacket::Batch), + } + } + + fn dispatch_single(&self, req: &SerializedRequest) -> Result { + let Ok(method) = ChainMethod::try_from(req.method()) else { + return Ok(failure( + req, + ErrorPayload { + code: -32601, + message: format!( + "method outside the permitted read surface: {}", + req.method() + ) + .into(), + data: None, + }, + )); + }; + let params = req.params().map_or("[]", RawValue::get); + match self + .host + .request(self.chain.into(), method.as_str(), params) + { + Ok(result) => { + let payload = RawValue::from_string(result) + .map_err(|e| TransportError::deser_err(e, "host chain response"))?; + Ok(Response { + id: req.id().clone(), + payload: ResponsePayload::Success(payload), + }) + } + Err(ChainError::Rpc(rpc)) => Ok(failure( + req, + ErrorPayload { + code: rpc.code.into(), + message: rpc.message.into(), + data: rpc.data.and_then(|bytes| { + serde_json::value::to_raw_value(&alloy_primitives::hex::encode_prefixed( + bytes, + )) + .ok() + }), + }, + )), + Err(ChainError::Fault(fault)) => Err(TransportErrorKind::custom(fault)), + } + } +} + +fn failure(req: &SerializedRequest, payload: ErrorPayload) -> Response { + Response { + id: req.id().clone(), + payload: ResponsePayload::Failure(payload), + } +} + +impl Service for HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, packet: RequestPacket) -> Self::Future { + let result = self.dispatch(packet); + Box::pin(ready(result)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use alloy_json_rpc::{Id, Request, RequestPacket, ResponsePacket, ResponsePayload}; + use alloy_transport::TransportError; + use tower::Service; + + use super::HostTransport; + use crate::chain::{Chain, block_on}; + use crate::host::{ChainError, ChainHost, Fault, RpcError}; + + type StubFn = dyn Fn(u64, &str, &str) -> Result + Send + Sync; + + #[derive(Clone)] + struct Stub(Arc); + + impl Stub { + fn new( + f: impl Fn(u64, &str, &str) -> Result + Send + Sync + 'static, + ) -> Self { + Self(Arc::new(f)) + } + } + + impl ChainHost for Stub { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + (self.0)(chain_id, method, params) + } + } + + fn single(method: &'static str) -> RequestPacket { + let req = Request::new(method, Id::Number(1), ()) + .serialize() + .expect("request serializes"); + RequestPacket::Single(req) + } + + fn call(transport: &mut HostTransport, packet: RequestPacket) -> super::Response { + let ResponsePacket::Single(resp) = + block_on(Service::call(transport, packet)).expect("transport dispatches") + else { + panic!("single request yields a single response"); + }; + resp + } + + #[test] + fn success_passes_host_json_through() { + let stub = Stub::new(|chain_id, method, params| { + assert_eq!(chain_id, 100); + assert_eq!(method, "eth_blockNumber"); + assert_eq!(params, "[]"); + Ok("\"0x2a\"".into()) + }); + let mut transport = HostTransport::new(stub, Chain::GNOSIS); + let resp = call(&mut transport, single("eth_blockNumber")); + let ResponsePayload::Success(payload) = resp.payload else { + panic!("expected success, got {resp:?}"); + }; + assert_eq!(payload.get(), "\"0x2a\""); + } + + #[test] + fn unlisted_method_never_reaches_the_host() { + let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let resp = call(&mut transport, single("eth_sendRawTransaction")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32601); + assert!(err.message.contains("eth_sendRawTransaction")); + } + + #[test] + fn rpc_error_surfaces_code_message_and_revert_hex() { + let stub = Stub::new(|_, _, _| { + Err(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), + })) + }); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let resp = call(&mut transport, single("eth_call")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32000); + assert_eq!(err.message, "execution reverted"); + assert_eq!(err.data.expect("revert data").get(), "\"0x08c379a0\"",); + } + + #[test] + fn fault_becomes_a_typed_transport_error() { + let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let err = block_on(Service::call(&mut transport, single("eth_call"))) + .expect_err("fault propagates"); + let TransportError::Transport(kind) = err else { + panic!("expected transport kind, got {err:?}"); + }; + assert!(kind.to_string().contains("timeout")); + } + + #[test] + fn batches_dispatch_per_request() { + let stub = Stub::new(|_, method, _| match method { + "eth_blockNumber" => Ok("\"0x1\"".into()), + _ => Ok("\"0x64\"".into()), + }); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let reqs = vec![ + Request::new("eth_blockNumber", Id::Number(1), ()) + .serialize() + .expect("request serializes"), + Request::new("eth_chainId", Id::Number(2), ()) + .serialize() + .expect("request serializes"), + ]; + let ResponsePacket::Batch(resps) = + block_on(Service::call(&mut transport, RequestPacket::Batch(reqs))) + .expect("batch dispatches") + else { + panic!("batch request yields a batch response"); + }; + assert_eq!(resps.len(), 2); + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 04e8fffd..291a56f7 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -39,7 +39,10 @@ //! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the //! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! -//! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], +//! - [`chain`] - typed chain access: [`Chain`] / [`ChainId`] newtypes, +//! the closed [`ChainMethod`] read surface, and the alloy provider +//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus +//! `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader //! ([`read_latest_answer`]). //! @@ -93,6 +96,12 @@ //! [`ConditionalSource`]: keeper::ConditionalSource //! [`Retrier`]: keeper::Retrier //! [`RetryAction`]: keeper::RetryAction +//! [`Chain`]: chain::Chain +//! [`ChainId`]: chain::ChainId +//! [`ChainMethod`]: chain::ChainMethod +//! [`HostTransport`]: chain::HostTransport +//! [`provider`]: chain::ProviderHost::provider +//! [`block_on`]: chain::block_on //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer diff --git a/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml index 9652bdc8..4c62cd8a 100644 --- a/tools/load-gen/Cargo.toml +++ b/tools/load-gen/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true alloy-primitives.workspace = true -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws"] } alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true alloy-transport-ws.workspace = true From 242a94914fd933d326fd901023303646b9d223e8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 02:33:28 +0000 Subject: [PATCH 2/4] sdk: adopt alloy_chains::Chain, drop hand-rolled chain/id.rs The guest SDK carried Chain/ChainId newtypes duplicating alloy_chains::Chain, which is already in the guest graph via alloy-provider. Delete chain/id.rs, re-export alloy_chains::Chain, and repoint the named-chain test and doc sites. ChainId was unused outside the module and the WIT edge is a raw u64, so nothing is lost; Display now renders the chain name where alloy knows it. --- Cargo.lock | 1 + crates/nexum-sdk/Cargo.toml | 2 + crates/nexum-sdk/src/chain/id.rs | 125 ------------------------ crates/nexum-sdk/src/chain/mod.rs | 5 +- crates/nexum-sdk/src/chain/provider.rs | 8 +- crates/nexum-sdk/src/chain/transport.rs | 10 +- crates/nexum-sdk/src/lib.rs | 5 +- 7 files changed, 16 insertions(+), 140 deletions(-) delete mode 100644 crates/nexum-sdk/src/chain/id.rs diff --git a/Cargo.lock b/Cargo.lock index b9135dee..d0d8f6e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3645,6 +3645,7 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ + "alloy-chains", "alloy-json-rpc", "alloy-primitives", "alloy-provider", diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 429aa4b5..620883bf 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -27,6 +27,8 @@ nexum-macros = { path = "../nexum-macros" } # re-exported as `nexum_sdk::status_body`. nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true +# Typed EIP-155 chain id; already in the guest graph via alloy-provider. +alloy-chains.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true diff --git a/crates/nexum-sdk/src/chain/id.rs b/crates/nexum-sdk/src/chain/id.rs deleted file mode 100644 index 598857c3..00000000 --- a/crates/nexum-sdk/src/chain/id.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Zero-cost chain identity newtypes. - -use core::fmt; - -/// EIP-155 chain id, typed so a bare `u64` never crosses an SDK API. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ChainId(u64); - -impl ChainId { - /// Wrap a raw EIP-155 id. - pub const fn new(id: u64) -> Self { - Self(id) - } - - /// The raw id, for the WIT edge. - pub const fn get(self) -> u64 { - self.0 - } -} - -impl From for ChainId { - fn from(id: u64) -> Self { - Self::new(id) - } -} - -impl From for u64 { - fn from(id: ChainId) -> Self { - id.get() - } -} - -impl fmt::Display for ChainId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -/// A chain a strategy targets, keyed by its [`ChainId`]. The type the -/// provider seam takes; events deliver a raw id, so `ev.chain_id.into()` -/// bridges at the handler edge. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Chain(ChainId); - -impl Chain { - /// Ethereum mainnet. - pub const MAINNET: Self = Self::from_id(1); - /// Gnosis Chain. - pub const GNOSIS: Self = Self::from_id(100); - /// Base. - pub const BASE: Self = Self::from_id(8_453); - /// Arbitrum One. - pub const ARBITRUM: Self = Self::from_id(42_161); - /// Sepolia testnet. - pub const SEPOLIA: Self = Self::from_id(11_155_111); - - /// Chain with the given raw EIP-155 id. - pub const fn from_id(id: u64) -> Self { - Self(ChainId::new(id)) - } - - /// The chain's id. - pub const fn id(self) -> ChainId { - self.0 - } -} - -impl From for Chain { - fn from(id: u64) -> Self { - Self::from_id(id) - } -} - -impl From for Chain { - fn from(id: ChainId) -> Self { - Self(id) - } -} - -impl From for ChainId { - fn from(chain: Chain) -> Self { - chain.id() - } -} - -impl From for u64 { - fn from(chain: Chain) -> Self { - chain.id().get() - } -} - -impl fmt::Display for Chain { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -#[cfg(test)] -mod tests { - use super::{Chain, ChainId}; - - #[test] - fn ids_round_trip() { - assert_eq!(u64::from(ChainId::new(100)), 100); - assert_eq!(ChainId::from(7u64).get(), 7); - assert_eq!(u64::from(Chain::from_id(42)), 42); - assert_eq!(Chain::from(ChainId::new(1)), Chain::MAINNET); - assert_eq!(ChainId::from(Chain::SEPOLIA).get(), 11_155_111); - } - - #[test] - fn named_chains_carry_canonical_ids() { - assert_eq!(u64::from(Chain::MAINNET), 1); - assert_eq!(u64::from(Chain::GNOSIS), 100); - assert_eq!(u64::from(Chain::BASE), 8_453); - assert_eq!(u64::from(Chain::ARBITRUM), 42_161); - assert_eq!(u64::from(Chain::SEPOLIA), 11_155_111); - } - - #[test] - fn display_is_the_raw_id() { - assert_eq!(Chain::GNOSIS.to_string(), "100"); - assert_eq!(ChainId::new(1).to_string(), "1"); - } -} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index e10c8808..d5cb34f7 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,6 +1,6 @@ //! Chain access for guest strategies. //! -//! Typed identity ([`Chain`], [`ChainId`]), the closed JSON-RPC read +//! Chain identity (alloy [`Chain`]), the closed JSON-RPC read //! surface ([`ChainMethod`]), and the alloy provider seam: a //! [`HostTransport`] over `ChainHost::request` fronted by //! [`ProviderHost::provider`], driven with [`block_on`]. Plus the @@ -9,13 +9,12 @@ pub mod chainlink; pub mod eth_call; -pub mod id; pub mod method; pub mod provider; pub mod transport; pub use eth_call::{eth_call_params, parse_eth_call_result}; -pub use id::{Chain, ChainId}; +pub use alloy_chains::Chain; pub use method::ChainMethod; pub use provider::{ProviderHost, block_on}; pub use transport::HostTransport; diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs index a46d4f8c..f60f2aaf 100644 --- a/crates/nexum-sdk/src/chain/provider.rs +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -29,7 +29,7 @@ use crate::host::ChainHost; /// } /// } /// -/// let provider = StubHost.provider(Chain::MAINNET); +/// let provider = StubHost.provider(Chain::mainnet()); /// let block = block_on(provider.get_block_number()).unwrap(); /// assert_eq!(block, 42); /// ``` @@ -89,14 +89,14 @@ mod tests { #[test] fn provider_reads_typed_values_through_the_host() { - let provider = StubHost.provider(Chain::GNOSIS); + let provider = StubHost.provider(Chain::from_id(100)); let block = block_on(provider.get_block_number()).expect("block number"); assert_eq!(block, 42); } #[test] fn provider_call_decodes_bytes() { - let provider = StubHost.provider(Chain::GNOSIS); + let provider = StubHost.provider(Chain::from_id(100)); let tx = TransactionRequest::default() .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); let out = block_on(provider.call(tx)).expect("eth_call"); @@ -105,7 +105,7 @@ mod tests { #[test] fn signing_methods_error_before_the_host() { - let provider = StubHost.provider(Chain::GNOSIS); + let provider = StubHost.provider(Chain::from_id(100)); let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) .expect_err("write method is rejected"); let payload = err.as_error_resp().expect("json-rpc error response"); diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs index 10c64974..5ae36dcc 100644 --- a/crates/nexum-sdk/src/chain/transport.rs +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -175,7 +175,7 @@ mod tests { assert_eq!(params, "[]"); Ok("\"0x2a\"".into()) }); - let mut transport = HostTransport::new(stub, Chain::GNOSIS); + let mut transport = HostTransport::new(stub, Chain::from_id(100)); let resp = call(&mut transport, single("eth_blockNumber")); let ResponsePayload::Success(payload) = resp.payload else { panic!("expected success, got {resp:?}"); @@ -186,7 +186,7 @@ mod tests { #[test] fn unlisted_method_never_reaches_the_host() { let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); - let mut transport = HostTransport::new(stub, Chain::MAINNET); + let mut transport = HostTransport::new(stub, Chain::mainnet()); let resp = call(&mut transport, single("eth_sendRawTransaction")); let ResponsePayload::Failure(err) = resp.payload else { panic!("expected failure, got {resp:?}"); @@ -204,7 +204,7 @@ mod tests { data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), })) }); - let mut transport = HostTransport::new(stub, Chain::MAINNET); + let mut transport = HostTransport::new(stub, Chain::mainnet()); let resp = call(&mut transport, single("eth_call")); let ResponsePayload::Failure(err) = resp.payload else { panic!("expected failure, got {resp:?}"); @@ -217,7 +217,7 @@ mod tests { #[test] fn fault_becomes_a_typed_transport_error() { let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); - let mut transport = HostTransport::new(stub, Chain::MAINNET); + let mut transport = HostTransport::new(stub, Chain::mainnet()); let err = block_on(Service::call(&mut transport, single("eth_call"))) .expect_err("fault propagates"); let TransportError::Transport(kind) = err else { @@ -232,7 +232,7 @@ mod tests { "eth_blockNumber" => Ok("\"0x1\"".into()), _ => Ok("\"0x64\"".into()), }); - let mut transport = HostTransport::new(stub, Chain::MAINNET); + let mut transport = HostTransport::new(stub, Chain::mainnet()); let reqs = vec![ Request::new("eth_blockNumber", Id::Number(1), ()) .serialize() diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 291a56f7..59794c1b 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -39,7 +39,7 @@ //! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the //! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! -//! - [`chain`] - typed chain access: [`Chain`] / [`ChainId`] newtypes, +//! - [`chain`] - typed chain access: alloy [`Chain`], //! the closed [`ChainMethod`] read surface, and the alloy provider //! seam ([`HostTransport`], [`provider`], [`block_on`]); plus //! `eth_call` JSON plumbing ([`eth_call_params`], @@ -96,8 +96,7 @@ //! [`ConditionalSource`]: keeper::ConditionalSource //! [`Retrier`]: keeper::Retrier //! [`RetryAction`]: keeper::RetryAction -//! [`Chain`]: chain::Chain -//! [`ChainId`]: chain::ChainId +//! [`Chain`]: alloy_chains::Chain //! [`ChainMethod`]: chain::ChainMethod //! [`HostTransport`]: chain::HostTransport //! [`provider`]: chain::ProviderHost::provider From 7797a6d9994f2abdb3046d9552025a269a3331ed Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 03:56:40 +0000 Subject: [PATCH 3/4] sdk: assert chain block_on resolves synchronously The host transport is a synchronous WIT import, so provider futures resolve on the first poll. Replace the unbounded noop-waker loop - which livelocks and burns the guest fuel budget if a future ever returns Pending - with a single poll that panics loudly on Pending. Fail-loud beats a silent spin, and the host-side unit tests (no fuel backstop) now surface a stuck future instead of hanging. Addresses #523 (item 1). --- crates/nexum-sdk/src/chain/provider.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs index f60f2aaf..426b4fd5 100644 --- a/crates/nexum-sdk/src/chain/provider.rs +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -45,16 +45,21 @@ pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { impl ProviderHost for H {} -/// Drive a provider future to completion. Host-backed transports -/// resolve synchronously, so this is a poll loop, not a scheduler; a -/// future that awaits anything other than a host call will spin. +/// Drive a host-backed provider future to completion. The host +/// transport is a synchronous WIT import, so the future resolves on the +/// first poll; a `Pending` means an async alloy layer crept in and the +/// chain SDK must move to a host-driven surface, not a poll loop. pub fn block_on(future: F) -> F::Output { let mut future = pin!(future.into_future()); let mut cx = Context::from_waker(Waker::noop()); - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return output; - } + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => output, + Poll::Pending => panic!( + "chain provider future did not resolve synchronously: the host \ + transport is a synchronous WIT import, so an alloy layer that \ + awaits a reactor or timer was added; the chain SDK must move \ + to a host-driven async surface, not a poll loop" + ), } } @@ -116,4 +121,10 @@ mod tests { fn block_on_drives_plain_futures() { assert_eq!(block_on(async { 7 }), 7); } + + #[test] + #[should_panic(expected = "did not resolve synchronously")] + fn block_on_panics_when_a_future_is_not_synchronously_ready() { + block_on(std::future::pending::<()>()); + } } From 4ca09b6d8c6d76611c80153977ede4645caaf010 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 04:07:31 +0000 Subject: [PATCH 4/4] sdk: sort chain module re-exports alloy_chains sorts before eth_call; keep the pub-use group in rustfmt order after the ChainId substitution. --- crates/nexum-sdk/src/chain/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index d5cb34f7..c547f921 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -13,8 +13,8 @@ pub mod method; pub mod provider; pub mod transport; -pub use eth_call::{eth_call_params, parse_eth_call_result}; pub use alloy_chains::Chain; +pub use eth_call::{eth_call_params, parse_eth_call_result}; pub use method::ChainMethod; pub use provider::{ProviderHost, block_on}; pub use transport::HostTransport;