Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Cargo.lock

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

8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Expand Down
2 changes: 1 addition & 1 deletion crates/nexum-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion crates/nexum-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,28 @@ 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
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<RawValue>` 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
Expand Down
121 changes: 121 additions & 0 deletions crates/nexum-sdk/src/chain/method.rs
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enum is hand-duplicated byte-for-byte against crates/nexum-runtime/src/host/component/chain.rs's ChainMethod (same 18 variants, same #[strum(serialize=...)] strings) — confirmed identical today, but there's no shared source of truth or CI check enforcing that. Since the guest-side security property ("closed enum = allowlist") depends entirely on this list matching the host's dispatch table exactly, silent drift in either direction is a real risk: the runtime adding a method without updating this SDK copy just breaks DX (unlisted -32601), but this SDK copy diverging from the runtime's actual dispatch surface in the other direction could reopen exactly the gap this PR is designed to close, with nothing catching it until someone notices at runtime. Worth extracting ChainMethod into one shared crate/definition both sides depend on, or at minimum a cross-crate test that diffs both enums' variant strings so drift fails CI loudly instead of silently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed still valid at HEAD - ChainMethod is still two hand-duplicated enums (nexum-sdk/src/chain/method.rs and nexum-runtime/src/host/component/chain.rs), no shared source and no drift test. You are right that the allowlist security property rides on the exact match, so SDK-diverges-from-host drift is the dangerous direction. Tracked in #523 (single source of truth, or a cross-crate variant-string diff test that fails CI).

/// `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),
);
}
}
18 changes: 14 additions & 4 deletions crates/nexum-sdk/src/chain/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
//! `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.
//! 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
//! `eth_call` JSON plumbing helpers for modules that keep their own
//! `chain::request` shim.

pub mod chainlink;
pub mod eth_call;
pub mod method;
pub mod provider;
pub mod transport;

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;
130 changes: 130 additions & 0 deletions crates/nexum-sdk/src/chain/provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! `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<String, ChainError> {
/// 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<H: ChainHost + Clone + Send + Sync + 'static> ProviderHost for H {}

/// 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<F: IntoFuture>(future: F) -> F::Output {
let mut future = pin!(future.into_future());
let mut cx = Context::from_waker(Waker::noop());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

block_on's doc already flags that a future awaiting anything other than a host call will spin — but that's currently a silent infinite busy-spin with no diagnostic, not an error. HostTransport::call always returns Box::pin(ready(result)) today, so this holds for now, but any future alloy version chaining a retry/backoff layer, a tower::Buffer, or a pubsub/multi-await path would turn this into an undiagnosable hang burning the guest's whole gas/metering budget instead of a clear panic. Worth capping the poll loop with a diagnostic ("future did not resolve synchronously after N polls") rather than looping unbounded — a fail-loud break of the "always resolves in one poll" invariant is strictly better than a silent hang.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed still valid at HEAD - block_on (chain/provider.rs:54) still loops unbounded on the one-poll invariant, no cap or diagnostic. Agreed a fail-loud break beats a silent gas-burning hang if a future alloy layer ever breaks the invariant. Tracked in #523.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7797a6d (this PR). Replaced the unbounded noop-waker loop with a single poll that panics loudly on Pending, backed by a research pass on the idiomatic pattern:

  • The host transport is a synchronous WIT import (poll_ready always ready, call returns ready(_)), and alloy's RpcCall collapses poll_ready->call into one outer poll, so the future genuinely resolves on the first poll. Re-polling under a noop waker can never advance a reactor-dependent Pending, so the loop was a hand-rolled now_or_never with the None arm replaced by "spin forever". A single poll + named panic is the honest, dependency-free form and stays byte-identical host and guest.
  • On the fuel point: in production the spin is not actually an unbounded hang (the builder sets consume_fuel(true) + per-event set_fuel + a dispatch deadline, so it traps as OutOfFuel), but that trap is opaque and fires only after a whole event budget burns. Host-side (unit tests, no wasmtime/fuel) a real Pending was a true 100% CPU hang. The panic fixes both: a clear diagnostic at the call site, loud on both targets. A #[should_panic] test covers it.

This is #523 item 1. Item 2 (single source of truth for ChainMethod) is still tracked there; the decided approach is a shared nexum-chain-method leaf crate.

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"
),
}
}

#[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<String, ChainError> {
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::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::from_id(100));
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::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");
assert_eq!(payload.code, -32601);
}

#[test]
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::<()>());
}
}
Loading
Loading