-
Notifications
You must be signed in to change notification settings - Fork 2
sdk: add an alloy provider seam over the chain host #453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
caa725e
242a949
7797a6d
4ca09b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 { | ||
| /// `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), | ||
| ); | ||
| } | ||
| } | ||
| 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; |
| 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()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed still valid at HEAD -
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
This is #523 item 1. Item 2 (single source of truth for |
||
| 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::<()>()); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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'sChainMethod(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 extractingChainMethodinto 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.There was a problem hiding this comment.
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 -
ChainMethodis still two hand-duplicated enums (nexum-sdk/src/chain/method.rsandnexum-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).