From 79f6b5bdc0107d9328863c55997d27cd516833e6 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 03:01:10 +0000 Subject: [PATCH] feat(runtime): back remote-store with the Swarm network via a Bee node Replace the four unsupported remote-store stubs with a Bee-backed backend behind the component seam: a shared RemoteStore handle built from the new [remote_store] engine table (API URL, postage batch, feed-signing key), threaded through Components and every module store. Uploads and feed updates are stamped with the configured batch; feed updates are signed host-side. Faults classify by failure: missing configuration is unsupported, malformed guest input invalid-input, a lookup miss unavailable, HTTP 429 rate-limited, 402/403 denied, and a transport timeout timeout. An absent table leaves every call unsupported so guests can probe-then-skip. --- Cargo.lock | 206 ++++++++- Cargo.toml | 4 + crates/nexum-runtime/Cargo.toml | 3 + crates/nexum-runtime/src/engine_config.rs | 55 +++ .../src/host/component/builder.rs | 54 ++- .../nexum-runtime/src/host/component/mod.rs | 6 +- crates/nexum-runtime/src/host/error.rs | 28 ++ .../src/host/impls/remote_store.rs | 28 +- crates/nexum-runtime/src/host/mod.rs | 7 +- .../src/host/remote_store_bee.rs | 407 ++++++++++++++++++ crates/nexum-runtime/src/host/state.rs | 3 + crates/nexum-runtime/src/supervisor.rs | 1 + crates/nexum-runtime/src/supervisor/tests.rs | 7 + crates/nexum-runtime/src/test_utils/mod.rs | 1 + 14 files changed, 776 insertions(+), 34 deletions(-) create mode 100644 crates/nexum-runtime/src/host/remote_store_bee.rs diff --git a/Cargo.lock b/Cargo.lock index 9935776e..3dae4517 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,7 +245,7 @@ dependencies = [ "rustc-hash", "secp256k1 0.31.1", "serde", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -280,7 +280,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "thiserror 2.0.18", @@ -349,7 +349,7 @@ dependencies = [ "alloy-transport-ws", "futures", "pin-project", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -464,7 +464,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.11.0", "syn 2.0.118", "syn-solidity", ] @@ -539,7 +539,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest", + "reqwest 0.13.4", "serde_json", "tower", "tracing", @@ -579,7 +579,7 @@ dependencies = [ "rustls", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "url", "ws_stream_wasm", @@ -1092,6 +1092,34 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bee-rs" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549d4dd83a83a5ccbb180606cc7206d887ba1ea064a53d186018202cdd2b5cf2" +dependencies = [ + "base64", + "bytes", + "futures-util", + "hex", + "num-bigint", + "num-traits", + "rand_core 0.6.4", + "reqwest 0.12.28", + "secp256k1 0.30.0", + "serde", + "serde_json", + "sha3 0.10.9", + "subtle", + "tar", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "url", + "zeroize", +] + [[package]] name = "bimap" version = "0.6.3" @@ -2376,6 +2404,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2887,6 +2925,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -3283,6 +3322,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keccak" version = "0.2.0" @@ -3566,6 +3614,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.1" @@ -3643,6 +3701,7 @@ dependencies = [ "alloy-transport-ws", "anyhow", "async-trait", + "bee-rs", "bytes", "futures", "http", @@ -3754,6 +3813,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -3864,7 +3924,7 @@ dependencies = [ "axum", "clap", "rand 0.10.2", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -4542,6 +4602,48 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -5113,6 +5215,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -5120,7 +5232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.0", ] [[package]] @@ -5385,6 +5497,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -5576,6 +5699,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.24.0", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -5588,7 +5727,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -5807,6 +5946,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -5873,6 +6032,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6197,6 +6362,19 @@ dependencies = [ "wasmparser 0.253.0", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.251.0" @@ -7179,6 +7357,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index e40e0558..b0055bee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,6 +134,10 @@ http-body = "1" http-body-util = "0.1" bytes = "1" +# Bee HTTP API client (lib name `bee`) behind the engine's Swarm +# `remote-store` backend. +bee-rs = "1.7" + # Proc-macro toolkit backing `nexum-module-macros` and `videre-macros`. # Host-side only: a proc-macro crate always builds for the host, even # when the module consuming it targets wasm. diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index c867cbd3..affc1a01 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -85,6 +85,9 @@ futures.workspace = true # host-side via a `[len:u8][module_name][raw_key]` prefix. redb.workspace = true +# `remote-store` backend: the Swarm network over a Bee node's HTTP API. +bee-rs.workspace = true + # Misc. url.workspace = true diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 5a2ea0a1..a9ac368f 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -144,6 +144,11 @@ pub struct EngineConfig { /// composition root. #[serde(default)] pub extensions: HashMap, + /// `[remote_store]`: the Swarm backend behind + /// `nexum:host/remote-store`. Absent leaves the interface + /// unconfigured; every call then reports `unsupported`. + #[serde(default)] + pub remote_store: Option, /// Modules the supervisor should boot. Each entry resolves a /// `(component.wasm, module.toml)` pair on the local filesystem /// for 0.2 - content-addressed resolution (Swarm / OCI / @@ -282,6 +287,33 @@ fn default_chain_request_timeout_secs() -> u64 { 30 } +/// The `[remote_store]` table: a Bee node servicing the Swarm +/// remote-store. +#[derive(Deserialize)] +pub struct RemoteStoreSection { + /// Bee HTTP API base URL. + pub api: String, + /// Postage batch id (32-byte hex) stamping uploads and feed + /// updates. Absent leaves the write paths unsupported. + #[serde(default)] + pub postage_batch: Option, + /// Feed-signing private key (32-byte hex); `${VAR}` substitution + /// applies before parse. Absent leaves `write-feed` unsupported. + #[serde(default)] + pub feed_key: Option, +} + +// Manual impl: the feed key must never reach logs. +impl std::fmt::Debug for RemoteStoreSection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteStoreSection") + .field("api", &self.api) + .field("postage_batch", &self.postage_batch) + .field("feed_key", &self.feed_key.as_ref().map(|_| "")) + .finish() + } +} + /// Default fuel budget per `on_event` invocation (~1 billion WASM /// instructions). const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; @@ -943,6 +975,29 @@ rpc_url = "wss://example.test/sepolia" ); } + #[test] + fn remote_store_section_parses_and_redacts_the_feed_key() { + let cfg: EngineConfig = toml::from_str( + r#" +[remote_store] +api = "http://localhost:1633" +postage_batch = "aa" +feed_key = "bb" +"#, + ) + .expect("remote_store table parses"); + let section = cfg.remote_store.expect("section present"); + assert_eq!(section.api, "http://localhost:1633"); + assert_eq!(section.postage_batch.as_deref(), Some("aa")); + assert_eq!(section.feed_key.as_deref(), Some("bb")); + let debug = format!("{section:?}"); + assert!(debug.contains(""), "{debug}"); + assert!(!debug.contains("bb"), "{debug}"); + + let absent: EngineConfig = toml::from_str("").expect("empty config parses"); + assert!(absent.remote_store.is_none()); + } + #[test] fn invalid_chain_key_surfaces_a_toml_error() { // A key that is neither a numeric id nor a known chain name must diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 6b4111cc..84b4f321 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -16,6 +16,7 @@ use crate::host::component::{Components, RuntimeTypes}; use crate::host::local_store_redb::LocalStore; use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; +use crate::host::remote_store_bee::RemoteStore; /// Shared inputs every component builder reads: the loaded engine config, /// the resolved data directory backends open their files under, and the @@ -82,6 +83,18 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// Builds the [`RemoteStore`] from `[remote_store]`; an absent table +/// yields a disabled handle. +pub struct RemoteStoreBuilder; + +impl ComponentBuilder for RemoteStoreBuilder { + type Output = RemoteStore; + + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + RemoteStore::from_config(ctx.config.remote_store.as_ref()).map_err(Into::into) + } +} + /// Builds the default [`LogPipeline`]: the byte-bounded in-memory backend /// sized from `[limits.logs]`. pub struct LogPipelineBuilder; @@ -112,6 +125,9 @@ pub enum BuildError { /// The log pipeline builder failed. #[error("build the log pipeline: {0}")] Logs(anyhow::Error), + /// The remote-store builder failed. + #[error("build the remote-store backend: {0}")] + Remote(anyhow::Error), } /// The empty extension payload: a no-op builder for a core-only lattice @@ -126,9 +142,10 @@ impl ComponentBuilder for () { /// Assembles the core backend builders, the lattice `Ext` builder, and the /// log pipeline builder into a [`Components`] bundle. The logs slot defaults -/// to [`LogPipelineBuilder`]; the embedder retains the read handle by -/// cloning [`Components::logs`] after the build. -pub struct ComponentsBuilder { +/// to [`LogPipelineBuilder`] and the remote slot to [`RemoteStoreBuilder`]; +/// the embedder retains the read handle by cloning [`Components::logs`] +/// after the build. +pub struct ComponentsBuilder { /// Builds the chain backend ([`RuntimeTypes::Chain`]). pub chain: C, /// Builds the store backend ([`RuntimeTypes::Store`]). @@ -137,37 +154,53 @@ pub struct ComponentsBuilder { pub ext: E, /// Builds the shared [`LogPipeline`]. pub logs: L, + /// Builds the shared [`RemoteStore`]. + pub remote: R, } impl ComponentsBuilder { - /// Create a new [`ComponentsBuilder`] with the default log pipeline. + /// Create a new [`ComponentsBuilder`] with the default log pipeline + /// and remote-store builders. pub fn new(chain: C, store: S, ext: E) -> Self { Self { chain, store, ext, logs: LogPipelineBuilder, + remote: RemoteStoreBuilder, } } } -impl ComponentsBuilder { +impl ComponentsBuilder { /// Replace the log pipeline builder. - pub fn with_logs(self, logs: L2) -> ComponentsBuilder { + pub fn with_logs(self, logs: L2) -> ComponentsBuilder { ComponentsBuilder { chain: self.chain, store: self.store, ext: self.ext, logs, + remote: self.remote, + } + } + + /// Replace the remote-store builder. + pub fn with_remote(self, remote: R2) -> ComponentsBuilder { + ComponentsBuilder { + chain: self.chain, + store: self.store, + ext: self.ext, + logs: self.logs, + remote, } } /// Drive each builder against `ctx` and bundle the backends. The /// builder outputs must match the lattice seams: chain to /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to - /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`]. A - /// failing sub-build returns the [`BuildError`] variant naming that - /// slot. + /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`] and + /// remote a [`RemoteStore`]. A failing sub-build returns the + /// [`BuildError`] variant naming that slot. pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, @@ -175,16 +208,19 @@ impl ComponentsBuilder { S: ComponentBuilder, E: ComponentBuilder, L: ComponentBuilder, + R: ComponentBuilder, { let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; let store = self.store.build(ctx).await.map_err(BuildError::Store)?; let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; let logs = self.logs.build(ctx).await.map_err(BuildError::Logs)?; + let remote = self.remote.build(ctx).await.map_err(BuildError::Remote)?; Ok(Components { chain, store, ext, logs, + remote, }) } } diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index fd9d24e1..013b208a 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -11,7 +11,7 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - LogPipelineBuilder, ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, RemoteStoreBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; @@ -28,6 +28,9 @@ pub struct Components { /// Shared log pipeline: capture points route through its router, and /// the embedder reads runs and logs back off the same handle. pub logs: crate::host::logs::LogPipeline, + /// Shared `remote-store` handle over the configured Bee node; + /// disabled when `[remote_store]` is absent. + pub remote: crate::host::remote_store_bee::RemoteStore, } impl Clone for Components { @@ -37,6 +40,7 @@ impl Clone for Components { store: self.store.clone(), ext: self.ext.clone(), logs: self.logs.clone(), + remote: self.remote.clone(), } } } diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 65fbe9d7..9ebd3c89 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -7,6 +7,7 @@ use crate::bindings::nexum::host::chain::{ChainError, RpcError}; use crate::bindings::nexum::host::types::{Fault, RateLimit}; use crate::host::local_store_redb::StorageError; use crate::host::provider_pool::ProviderError; +use crate::host::remote_store_bee::RemoteStoreError; /// `Denied` chain fault for a request the host policy refused to /// forward, such as a method outside the permitted read surface. @@ -146,3 +147,30 @@ impl From for Fault { } } } + +/// The `remote-store` interface is the failure domain. Missing +/// configuration is `unsupported` so a guest can probe-then-skip; a Bee +/// API failure classifies by HTTP status, and a lookup miss is +/// `unavailable` because Swarm retrievability is transient. +impl From for Fault { + fn from(err: RemoteStoreError) -> Self { + match &err { + RemoteStoreError::NotConfigured + | RemoteStoreError::NoPostageBatch + | RemoteStoreError::NoFeedKey => Fault::Unsupported(err.to_string()), + RemoteStoreError::Input { .. } => Fault::InvalidInput(err.to_string()), + RemoteStoreError::NotFound(_) => Fault::Unavailable(err.to_string()), + RemoteStoreError::MalformedFeed(_) => Fault::Internal(err.to_string()), + RemoteStoreError::Api(api) => match api { + bee::Error::Response { status: 429, .. } => Fault::RateLimited(RateLimit { + retry_after_ms: None, + }), + bee::Error::Response { + status: 402 | 403, .. + } => Fault::Denied(err.to_string()), + bee::Error::Transport(t) if t.is_timeout() => Fault::Timeout, + _ => Fault::Unavailable(err.to_string()), + }, + } + } +} diff --git a/crates/nexum-runtime/src/host/impls/remote_store.rs b/crates/nexum-runtime/src/host/impls/remote_store.rs index f2c8661f..7fb0f569 100644 --- a/crates/nexum-runtime/src/host/impls/remote_store.rs +++ b/crates/nexum-runtime/src/host/impls/remote_store.rs @@ -1,30 +1,34 @@ -//! `nexum:host/remote-store`: deferred to 0.3 (Swarm backend). +//! `nexum:host/remote-store`: Swarm backend over a Bee node's HTTP API. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -const DEFERRED: &str = "Swarm backend deferred to 0.3"; - impl nexum::host::remote_store::Host for HostState { - async fn upload(&mut self, _data: Vec) -> Result, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + async fn upload(&mut self, data: Vec) -> Result, Fault> { + self.remote.upload(data).await.map_err(Fault::from) } - async fn download(&mut self, _reference: Vec) -> Result, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + async fn download(&mut self, reference: Vec) -> Result, Fault> { + self.remote.download(reference).await.map_err(Fault::from) } async fn read_feed( &mut self, - _owner: Vec, - _topic: Vec, + owner: Vec, + topic: Vec, ) -> Result>, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + self.remote + .read_feed(owner, topic) + .await + .map_err(Fault::from) } - async fn write_feed(&mut self, _topic: Vec, _data: Vec) -> Result, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + async fn write_feed(&mut self, topic: Vec, data: Vec) -> Result, Fault> { + self.remote + .write_feed(topic, data) + .await + .map_err(Fault::from) } } diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index a2842435..57103d25 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -9,9 +9,9 @@ //! - [`error`]: From conversions that project backend errors into the //! WIT `chain-error` / `Fault` shapes, plus the `Fault` label and //! message projections the supervisor records. -//! - [`provider_pool`], [`local_store_redb`]: capability backends. Pure -//! code with no bindgen types, so each can be unit-tested without -//! spinning up a wasmtime store. +//! - [`provider_pool`], [`local_store_redb`], [`remote_store_bee`]: +//! capability backends. Pure code with no bindgen types, so each can +//! be unit-tested without spinning up a wasmtime store. //! - `impls` (private): the bindgen-side trait impls, one file per core //! WIT interface, that dispatch to the backends above. //! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. @@ -36,4 +36,5 @@ mod impls; pub mod local_store_redb; pub mod logs; pub mod provider_pool; +pub mod remote_store_bee; pub mod state; diff --git a/crates/nexum-runtime/src/host/remote_store_bee.rs b/crates/nexum-runtime/src/host/remote_store_bee.rs new file mode 100644 index 00000000..6fcbddeb --- /dev/null +++ b/crates/nexum-runtime/src/host/remote_store_bee.rs @@ -0,0 +1,407 @@ +//! `remote-store` backend: the Swarm network over a Bee node's HTTP +//! API. Uploads and feed updates are stamped with the configured +//! postage batch; feed updates are signed host-side with the +//! configured feed key. + +use std::sync::Arc; + +use bee::swarm::{BatchId, EthAddress, PrivateKey, Reference, Topic}; + +use crate::engine_config::RemoteStoreSection; + +/// Canonical feed-update payload prefix: a big-endian unix timestamp. +const FEED_TIMESTAMP_LEN: usize = 8; + +/// Boot-time `[remote_store]` validation failures. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RemoteStoreConfigError { + /// The Bee API base URL failed to parse. + #[error("remote-store api url: {0}")] + Api(bee::Error), + /// The postage batch id is not 32-byte hex. + #[error("remote-store postage_batch: {0}")] + PostageBatch(bee::Error), + /// The feed key is not a 32-byte hex private key. + #[error("remote-store feed_key: {0}")] + FeedKey(bee::Error), +} + +/// Runtime failures surfaced by [`RemoteStore`] operations. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RemoteStoreError { + /// No `[remote_store]` table is configured. + #[error("remote-store is not configured")] + NotConfigured, + /// The operation stamps chunks but no postage batch is configured. + #[error("remote-store has no postage batch configured")] + NoPostageBatch, + /// `write-feed` needs a signing key and none is configured. + #[error("remote-store has no feed key configured")] + NoFeedKey, + /// A guest-supplied value has the wrong shape. + #[error("invalid {what}: {source}")] + Input { + /// Which argument was rejected. + what: &'static str, + /// The typed-byte constructor failure. + source: bee::Error, + }, + /// The referenced content did not resolve on the network. + #[error("reference {0} not found")] + NotFound(String), + /// A feed update shorter than the timestamp prefix. + #[error("malformed feed payload: {0} bytes")] + MalformedFeed(usize), + /// The Bee API refused or failed the request. + #[error("bee api: {0}")] + Api(bee::Error), +} + +/// The configured Bee endpoint plus its write credentials. +struct Backend { + client: bee::Client, + batch: Option, + feed_key: Option, +} + +/// Shared remote-store handle threaded into every module store; cheap +/// to clone. Unconfigured handles report [`RemoteStoreError::NotConfigured`] +/// on every operation. +#[derive(Clone)] +pub struct RemoteStore(Option>); + +impl std::fmt::Debug for RemoteStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("RemoteStore") + .field(&self.0.as_ref().map(|_| "bee")) + .finish() + } +} + +impl RemoteStore { + /// A handle with no backend: every operation reports + /// [`RemoteStoreError::NotConfigured`]. + pub fn disabled() -> Self { + Self(None) + } + + /// Open from the `[remote_store]` table; `None` yields a disabled + /// handle. + pub fn from_config( + section: Option<&RemoteStoreSection>, + ) -> Result { + let Some(section) = section else { + return Ok(Self::disabled()); + }; + let client = bee::Client::new(§ion.api).map_err(RemoteStoreConfigError::Api)?; + let batch = section + .postage_batch + .as_deref() + .map(BatchId::from_hex) + .transpose() + .map_err(RemoteStoreConfigError::PostageBatch)?; + let feed_key = section + .feed_key + .as_deref() + .map(PrivateKey::from_hex) + .transpose() + .map_err(RemoteStoreConfigError::FeedKey)?; + Ok(Self(Some(Arc::new(Backend { + client, + batch, + feed_key, + })))) + } + + fn backend(&self) -> Result<&Backend, RemoteStoreError> { + self.0.as_deref().ok_or(RemoteStoreError::NotConfigured) + } + + /// Upload raw data; returns the 32-byte content reference. + pub async fn upload(&self, data: Vec) -> Result, RemoteStoreError> { + let backend = self.backend()?; + let batch = backend + .batch + .as_ref() + .ok_or(RemoteStoreError::NoPostageBatch)?; + let result = backend + .client + .file() + .upload_data(batch, data, None) + .await + .map_err(RemoteStoreError::Api)?; + Ok(result.reference.to_vec()) + } + + /// Download raw data by content reference. + pub async fn download(&self, reference: Vec) -> Result, RemoteStoreError> { + let backend = self.backend()?; + let reference = Reference::new(&reference).map_err(|source| RemoteStoreError::Input { + what: "reference", + source, + })?; + match backend.client.file().download_data(&reference, None).await { + Ok(bytes) => Ok(bytes.to_vec()), + Err(e) if e.status() == Some(404) => { + Err(RemoteStoreError::NotFound(reference.to_hex())) + } + Err(e) => Err(RemoteStoreError::Api(e)), + } + } + + /// Latest value of the `(owner, topic)` feed, with the canonical + /// timestamp prefix stripped. `Ok(None)` on a lookup miss (Bee + /// reports a miss as 404 or 500). + pub async fn read_feed( + &self, + owner: Vec, + topic: Vec, + ) -> Result>, RemoteStoreError> { + let backend = self.backend()?; + let owner = EthAddress::new(&owner).map_err(|source| RemoteStoreError::Input { + what: "owner", + source, + })?; + let topic = Topic::new(&topic).map_err(|source| RemoteStoreError::Input { + what: "topic", + source, + })?; + match backend + .client + .file() + .fetch_latest_feed_update(&owner, &topic) + .await + { + Ok(update) => match update.payload.get(FEED_TIMESTAMP_LEN..) { + Some(data) => Ok(Some(data.to_vec())), + None => Err(RemoteStoreError::MalformedFeed(update.payload.len())), + }, + Err(e) if matches!(e.status(), Some(404 | 500)) => Ok(None), + Err(e) => Err(RemoteStoreError::Api(e)), + } + } + + /// Publish `data` as the next update of the configured identity's + /// `topic` feed; returns the update's chunk reference. + pub async fn write_feed( + &self, + topic: Vec, + data: Vec, + ) -> Result, RemoteStoreError> { + let backend = self.backend()?; + let batch = backend + .batch + .as_ref() + .ok_or(RemoteStoreError::NoPostageBatch)?; + let key = backend + .feed_key + .as_ref() + .ok_or(RemoteStoreError::NoFeedKey)?; + let topic = Topic::new(&topic).map_err(|source| RemoteStoreError::Input { + what: "topic", + source, + })?; + let result = backend + .client + .file() + .update_feed(batch, key, &topic, &data) + .await + .map_err(RemoteStoreError::Api)?; + Ok(result.reference.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wiremock::matchers::{header, method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::*; + + const BATCH_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const REF_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const KEY_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + + fn section(api: &str, batch: bool, key: bool) -> RemoteStoreSection { + RemoteStoreSection { + api: api.to_owned(), + postage_batch: batch.then(|| BATCH_HEX.to_owned()), + feed_key: key.then(|| KEY_HEX.to_owned()), + } + } + + fn store(api: &str, batch: bool, key: bool) -> RemoteStore { + RemoteStore::from_config(Some(§ion(api, batch, key))).expect("valid config") + } + + #[tokio::test] + async fn upload_stamps_and_returns_the_reference() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/bytes")) + .and(header("swarm-postage-batch-id", BATCH_HEX)) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "reference": REF_HEX }))) + .mount(&server) + .await; + + let reference = store(&server.uri(), true, false) + .upload(b"payload".to_vec()) + .await + .expect("upload"); + assert_eq!(reference, [0xaa; 32]); + } + + #[tokio::test] + async fn upload_without_a_batch_is_refused() { + let server = MockServer::start().await; + let err = store(&server.uri(), false, false) + .upload(b"payload".to_vec()) + .await + .expect_err("no batch"); + assert!(matches!(err, RemoteStoreError::NoPostageBatch), "{err}"); + } + + #[tokio::test] + async fn download_returns_the_bytes() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/bytes/{REF_HEX}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"payload".to_vec())) + .mount(&server) + .await; + + let data = store(&server.uri(), false, false) + .download(vec![0xaa; 32]) + .await + .expect("download"); + assert_eq!(data, b"payload"); + } + + #[tokio::test] + async fn download_miss_is_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let err = store(&server.uri(), false, false) + .download(vec![0xaa; 32]) + .await + .expect_err("missing reference"); + assert!(matches!(err, RemoteStoreError::NotFound(_)), "{err}"); + } + + #[tokio::test] + async fn download_rejects_a_malformed_reference() { + let server = MockServer::start().await; + let err = store(&server.uri(), false, false) + .download(vec![0xaa; 3]) + .await + .expect_err("short reference"); + assert!( + matches!( + err, + RemoteStoreError::Input { + what: "reference", + .. + } + ), + "{err}" + ); + } + + #[tokio::test] + async fn read_feed_strips_the_timestamp_prefix() { + let server = MockServer::start().await; + let mut payload = 7_u64.to_be_bytes().to_vec(); + payload.extend_from_slice(b"latest"); + Mock::given(method("GET")) + .and(path_regex("^/feeds/[0-9a-f]{40}/[0-9a-f]{64}$")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("swarm-feed-index", "0000000000000005") + .insert_header("swarm-feed-index-next", "0000000000000006") + .set_body_bytes(payload), + ) + .mount(&server) + .await; + + let value = store(&server.uri(), false, false) + .read_feed(vec![0x11; 20], vec![0x22; 32]) + .await + .expect("read feed"); + assert_eq!(value.as_deref(), Some(b"latest".as_slice())); + } + + #[tokio::test] + async fn read_feed_miss_is_none() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let value = store(&server.uri(), false, false) + .read_feed(vec![0x11; 20], vec![0x22; 32]) + .await + .expect("read feed"); + assert_eq!(value, None); + } + + #[tokio::test] + async fn write_feed_signs_the_next_update() { + let server = MockServer::start().await; + // No prior update: the writer starts at index 0. + Mock::given(method("GET")) + .and(path_regex("^/feeds/")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex("^/soc/[0-9a-f]{40}/[0-9a-f]{64}$")) + .and(header("swarm-postage-batch-id", BATCH_HEX)) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "reference": REF_HEX }))) + .mount(&server) + .await; + + let reference = store(&server.uri(), true, true) + .write_feed(vec![0x22; 32], b"value".to_vec()) + .await + .expect("write feed"); + assert_eq!(reference, [0xaa; 32]); + } + + #[tokio::test] + async fn write_feed_without_a_key_is_refused() { + let server = MockServer::start().await; + let err = store(&server.uri(), true, false) + .write_feed(vec![0x22; 32], b"value".to_vec()) + .await + .expect_err("no key"); + assert!(matches!(err, RemoteStoreError::NoFeedKey), "{err}"); + } + + #[tokio::test] + async fn disabled_handle_reports_not_configured() { + let err = RemoteStore::disabled() + .upload(b"payload".to_vec()) + .await + .expect_err("disabled"); + assert!(matches!(err, RemoteStoreError::NotConfigured), "{err}"); + } + + #[test] + fn bad_config_fails_at_boot() { + let mut bad = section("http://localhost:1633", true, false); + bad.postage_batch = Some("nothex".to_owned()); + let err = RemoteStore::from_config(Some(&bad)).expect_err("bad batch"); + assert!( + matches!(err, RemoteStoreConfigError::PostageBatch(_)), + "{err}" + ); + } +} diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 2023baf4..f6410504 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -14,6 +14,7 @@ use super::component::{Handle, RuntimeTypes}; use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; +use super::remote_store_bee::RemoteStore; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -51,6 +52,8 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, + /// `remote-store` backend - shared Swarm handle over a Bee node. + pub remote: RemoteStore, /// Extension-owned host services, keyed by extension namespace and /// downcast at the call site. One shared map across every module store; /// a provider store carries an empty map. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index fd369eeb..e96f02fd 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -602,6 +602,7 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, + remote: components.remote.clone(), services, }, ); diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 2617869f..f954ded9 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -308,6 +308,7 @@ fn test_components(store: crate::host::local_store_redb::LocalStore) -> Componen store, ext: (), logs: crate::test_utils::in_memory_logs(), + remote: crate::host::remote_store_bee::RemoteStore::disabled(), } } @@ -931,6 +932,7 @@ chain_id = 1 limits: crate::engine_config::ModuleLimits::default(), chains: std::collections::HashMap::new(), extensions: std::collections::HashMap::new(), + remote_store: None, modules: vec![ crate::engine_config::ModuleEntry { path: price_alert_wasm, @@ -1270,6 +1272,7 @@ chain_id = 1 limits: crate::engine_config::ModuleLimits::default(), chains: std::collections::HashMap::new(), extensions: std::collections::HashMap::new(), + remote_store: None, modules: vec![ crate::engine_config::ModuleEntry { path: bomb_wasm.clone(), @@ -1566,6 +1569,7 @@ fn components_with_logs( store, ext: (), logs: logs.clone(), + remote: crate::host::remote_store_bee::RemoteStore::disabled(), }; (components, logs) } @@ -1817,6 +1821,7 @@ chain_id = 100 limits: crate::engine_config::ModuleLimits::default(), chains: std::collections::HashMap::new(), extensions: std::collections::HashMap::new(), + remote_store: None, modules: vec![ crate::engine_config::ModuleEntry { path: wasm.clone(), @@ -1934,6 +1939,7 @@ chain_id = 100 }, chains: std::collections::HashMap::new(), extensions: std::collections::HashMap::new(), + remote_store: None, modules: vec![ crate::engine_config::ModuleEntry { path: wasm.clone(), @@ -2063,6 +2069,7 @@ chain_id = 100 }, chains: std::collections::HashMap::new(), extensions: std::collections::HashMap::new(), + remote_store: None, modules: vec![ crate::engine_config::ModuleEntry { path: bomb_wasm, diff --git a/crates/nexum-runtime/src/test_utils/mod.rs b/crates/nexum-runtime/src/test_utils/mod.rs index dc2ffeea..3f075b54 100644 --- a/crates/nexum-runtime/src/test_utils/mod.rs +++ b/crates/nexum-runtime/src/test_utils/mod.rs @@ -73,6 +73,7 @@ pub fn mock_components_from( store, ext: (), logs: in_memory_logs(), + remote: crate::host::remote_store_bee::RemoteStore::disabled(), } }