diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 022c61bd..2605ced4 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -68,7 +68,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature, keccak256}; use tracing::field::{Field, Visit}; use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; @@ -80,8 +84,14 @@ use tracing::{Event, Metadata, Subscriber}; pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, /// `nexum:host/local-store` mock. pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -114,6 +124,49 @@ impl LocalStoreHost for MockHost { } } +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + impl LoggingHost for MockHost { fn log(&self, level: Level, message: &str) { self.logging.log(level, message); @@ -190,6 +243,315 @@ impl ChainHost for MockChain { } } +// ---------------------------------------------------------------- identity + +/// One recorded [`MockIdentity`] signing invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignCall { + /// Account the guest asked to sign with. + pub account: Address, + /// What was signed. + pub payload: SignPayload, +} + +/// The payload of a [`SignCall`], per signing entry point. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignPayload { + /// A `sign` call: raw message bytes (`personal_sign` semantics). + Message(Vec), + /// A `sign_typed_data` call: the JSON-encoded EIP-712 payload. + TypedData(String), +} + +/// In-memory [`IdentityHost`]. Holds a programmable account roster and +/// one programmed signing outcome; records every signing call. With no +/// outcome programmed, signing fails as [`Fault::Unsupported`], the +/// stub-backend posture; an account outside the roster fails as +/// [`Fault::Denied`] before the programmed outcome applies. +#[derive(Default)] +pub struct MockIdentity { + accounts: RefCell>, + response: RefCell>>, + calls: RefCell>, +} + +impl MockIdentity { + /// Add an account to the roster [`accounts`](IdentityHost::accounts) + /// reports and signing admits. + pub fn add_account(&self, account: Address) { + self.accounts.borrow_mut().push(account); + } + + /// Program the outcome every subsequent signing call returns. + pub fn respond(&self, result: Result) { + *self.response.borrow_mut() = Some(result); + } + + /// All signing calls received, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last signing call received, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Total signing call count. + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + fn dispatch(&self, account: Address, payload: SignPayload) -> Result { + self.calls.borrow_mut().push(SignCall { account, payload }); + if !self.accounts.borrow().contains(&account) { + return Err(Fault::Denied(format!( + "MockIdentity: account {account} is not held" + ))); + } + self.response.borrow().clone().unwrap_or_else(|| { + Err(Fault::Unsupported( + "MockIdentity: no signing outcome programmed".to_string(), + )) + }) + } +} + +impl IdentityHost for MockIdentity { + fn accounts(&self) -> Result, Fault> { + Ok(self.accounts.borrow().clone()) + } + + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.dispatch(account, SignPayload::Message(message.to_vec())) + } + + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.dispatch(account, SignPayload::TypedData(typed_data.to_owned())) + } +} + +// ---------------------------------------------------------------- messaging + +/// One recorded [`MessagingHost::publish`] invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishRecord { + /// Content topic published to. + pub content_topic: String, + /// Payload bytes, verbatim. + pub payload: Vec, +} + +/// In-memory [`MessagingHost`]. Seeded messages answer queries, +/// publishes are recorded for assertion, and an optional topic scope +/// mirrors the host's `messaging_topics` grant. Seeded history and +/// published records are deliberately separate stores: a query answers +/// from what the test seeded, never from what the guest published. +#[derive(Default)] +pub struct MockMessaging { + history: RefCell>, + published: RefCell>, + scope: RefCell>>, + faults: RefCell>, +} + +impl MockMessaging { + /// Seed one message into the queryable history. + pub fn seed(&self, message: Message) { + self.history.borrow_mut().push(message); + } + + /// Seed a payload on `content_topic` at `timestamp` (ms since the + /// Unix epoch, UTC), with no sender. + pub fn seed_payload( + &self, + content_topic: impl Into, + payload: impl Into>, + timestamp: u64, + ) { + self.seed(Message { + content_topic: content_topic.into(), + payload: payload.into(), + timestamp, + sender: None, + }); + } + + /// Confine the mock to `topics`, mirroring the component's + /// `messaging_topics` grant: any other topic fails as + /// [`Fault::Denied`]. Untouched, every topic is allowed. + pub fn scope_topics(&self, topics: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); + } + + /// Inject a fault for any operation on a topic starting with + /// `prefix`. Multiple patterns can be registered; the first + /// matching one fires. + pub fn fail_on(&self, prefix: impl Into, fault: Fault) { + self.faults.borrow_mut().push((prefix.into(), fault)); + } + + /// All publishes received, in arrival order. + pub fn published(&self) -> Vec { + self.published.borrow().clone() + } + + /// Last publish received, if any. + pub fn last_published(&self) -> Option { + self.published.borrow().last().cloned() + } + + /// Total publish count. + pub fn publish_count(&self) -> usize { + self.published.borrow().len() + } + + fn admit(&self, content_topic: &str) -> Result<(), Fault> { + for (prefix, fault) in self.faults.borrow().iter() { + if content_topic.starts_with(prefix.as_str()) { + return Err(fault.clone()); + } + } + if let Some(scope) = self.scope.borrow().as_ref() + && !scope.iter().any(|topic| topic == content_topic) + { + return Err(Fault::Denied(format!( + "MockMessaging: {content_topic} is outside the scoped topics" + ))); + } + Ok(()) + } +} + +impl MessagingHost for MockMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.admit(content_topic)?; + self.published.borrow_mut().push(PublishRecord { + content_topic: content_topic.to_owned(), + payload: payload.to_vec(), + }); + Ok(()) + } + + /// Answer from the seeded history: exact-topic matches whose + /// timestamp lies within the inclusive `start_time..=end_time` + /// window, in seed order. Seed order is delivery order, so a + /// `limit` keeps the newest matches: the tail. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.admit(content_topic)?; + let mut matches: Vec = self + .history + .borrow() + .iter() + .filter(|message| { + message.content_topic == content_topic + && start_time.is_none_or(|start| message.timestamp >= start) + && end_time.is_none_or(|end| message.timestamp <= end) + }) + .cloned() + .collect(); + if let Some(limit) = limit { + let keep = usize::try_from(limit).unwrap_or(usize::MAX); + if matches.len() > keep { + matches.drain(..matches.len() - keep); + } + } + Ok(matches) + } +} + +// ---------------------------------------------------------------- remote-store + +/// In-memory [`RemoteStoreHost`]: content-addressed blobs plus mutable +/// `(owner, topic)` feeds. The mock addresses blobs by `keccak256` of +/// their content, so uploads are deterministic for assertion; the real +/// store's addressing scheme is the host's concern. Feed writes land +/// under the mock's own owner ([`set_owner`](Self::set_owner), +/// zero-address by default), mirroring the host signing feed updates +/// with its configured identity. +#[derive(Default)] +pub struct MockRemoteStore { + blobs: RefCell>>, + feeds: RefCell>>, + owner: Cell
, + fault: RefCell>, +} + +impl MockRemoteStore { + /// Set the owner feed writes land under. + pub fn set_owner(&self, owner: Address) { + self.owner.set(owner); + } + + /// Seed a blob without going through the trait; returns its + /// reference. + pub fn seed_blob(&self, data: impl Into>) -> B256 { + let data = data.into(); + let reference = keccak256(&data); + self.blobs.borrow_mut().insert(reference, data); + reference + } + + /// Seed another owner's feed for [`read_feed`](RemoteStoreHost::read_feed) + /// tests. + pub fn seed_feed(&self, owner: Address, topic: B256, data: impl Into>) { + self.feeds.borrow_mut().insert((owner, topic), data.into()); + } + + /// Inject a fault every subsequent operation returns. + pub fn fail_with(&self, fault: Fault) { + *self.fault.borrow_mut() = Some(fault); + } + + /// Number of stored blobs. + pub fn blob_count(&self) -> usize { + self.blobs.borrow().len() + } + + fn check_injected_fault(&self) -> Result<(), Fault> { + match self.fault.borrow().as_ref() { + Some(fault) => Err(fault.clone()), + None => Ok(()), + } + } +} + +impl RemoteStoreHost for MockRemoteStore { + fn upload(&self, data: &[u8]) -> Result { + self.check_injected_fault()?; + Ok(self.seed_blob(data)) + } + + fn download(&self, reference: B256) -> Result, Fault> { + self.check_injected_fault()?; + self.blobs + .borrow() + .get(&reference) + .cloned() + .ok_or_else(|| Fault::Unavailable(format!("MockRemoteStore: no blob at {reference}"))) + } + + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.check_injected_fault()?; + Ok(self.feeds.borrow().get(&(owner, topic)).cloned()) + } + + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.check_injected_fault()?; + let reference = self.seed_blob(data); + self.feeds + .borrow_mut() + .insert((self.owner.get(), topic), data.to_vec()); + Ok(reference) + } +} + // ---------------------------------------------------------------- local-store /// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: @@ -679,6 +1041,8 @@ pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { #[cfg(test)] mod tests { + use nexum_sdk::prelude::U256; + use super::*; #[test] @@ -838,22 +1202,190 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn identity_roster_and_programmed_outcome() { + let identity = MockIdentity::default(); + let account = Address::from([0xAA; 20]); + assert!(identity.accounts().unwrap().is_empty()); + identity.add_account(account); + assert_eq!(identity.accounts().unwrap(), vec![account]); + + // No outcome programmed: signing is unsupported, the stub posture. + let err = identity.sign(account, b"hello").unwrap_err(); + assert!(matches!(err, Fault::Unsupported(ref m) if m.contains("MockIdentity"))); + + let signature = Signature::new(U256::from(1), U256::from(2), false); + identity.respond(Ok(signature)); + assert_eq!(identity.sign(account, b"hello").unwrap(), signature); + assert_eq!(identity.sign_typed_data(account, "{}").unwrap(), signature); + + assert_eq!(identity.call_count(), 3); + assert_eq!( + identity.last_call().unwrap(), + SignCall { + account, + payload: SignPayload::TypedData("{}".to_owned()), + }, + ); + } + + #[test] + fn identity_denies_off_roster_accounts() { + let identity = MockIdentity::default(); + identity.respond(Ok(Signature::new(U256::from(1), U256::from(2), true))); + let err = identity.sign(Address::from([0xBB; 20]), b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused call is still recorded. + assert_eq!(identity.call_count(), 1); + } + + #[test] + fn messaging_records_publishes_and_answers_from_seeds() { + let messaging = MockMessaging::default(); + messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); + messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); + messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); + + messaging.publish("/acme/1/orders/proto", b"out").unwrap(); + assert_eq!(messaging.publish_count(), 1); + assert_eq!( + messaging.last_published().unwrap(), + PublishRecord { + content_topic: "/acme/1/orders/proto".to_owned(), + payload: b"out".to_vec(), + }, + ); + + // Publishes never leak into query results. + let all = messaging + .query("/acme/1/orders/proto", None, None, None) + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].payload, b"one"); + assert_eq!(all[1].payload, b"two"); + } + + #[test] + fn messaging_query_applies_bounds_and_limit() { + let messaging = MockMessaging::default(); + for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { + messaging.seed_payload("/t", payload.to_vec(), ts); + } + + let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); + assert_eq!(window.len(), 2); + assert_eq!(window[0].payload, b"b"); + + // A limit keeps the newest matches: the tail of the window. + let limited = messaging.query("/t", None, None, Some(2)).unwrap(); + assert_eq!(limited.len(), 2); + assert_eq!(limited[0].payload, b"c"); + assert_eq!(limited[1].payload, b"d"); + } + + #[test] + fn messaging_scope_denies_off_grant_topics() { + let messaging = MockMessaging::default(); + messaging.scope_topics(["/acme/1/orders/proto"]); + + messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); + let err = messaging.publish("/other", b"no").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + let err = messaging.query("/other", None, None, None).unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused publish was never recorded. + assert_eq!(messaging.publish_count(), 1); + } + + #[test] + fn messaging_fault_injection_fires_by_prefix() { + let messaging = MockMessaging::default(); + messaging.fail_on("/flaky", Fault::Timeout); + assert!(matches!( + messaging.publish("/flaky/topic", b"x").unwrap_err(), + Fault::Timeout, + )); + messaging.publish("/steady", b"x").unwrap(); + } + + #[test] + fn remote_store_round_trips_content_addressed_blobs() { + let store = MockRemoteStore::default(); + let reference = store.upload(b"chunk").unwrap(); + assert_eq!(reference, keccak256(b"chunk")); + assert_eq!(store.download(reference).unwrap(), b"chunk"); + assert_eq!(store.blob_count(), 1); + + let missing = store.download(B256::from([0xCC; 32])).unwrap_err(); + assert!(matches!(missing, Fault::Unavailable(ref m) if m.contains("MockRemoteStore"))); + } + + #[test] + fn remote_store_feeds_are_owner_scoped() { + let store = MockRemoteStore::default(); + let owner = Address::from([0xAA; 20]); + let topic = B256::from([0x11; 32]); + + // Writes land under the mock's own owner and stay downloadable. + store.set_owner(owner); + let reference = store.write_feed(topic, b"v1").unwrap(); + assert_eq!(store.download(reference).unwrap(), b"v1"); + assert_eq!( + store.read_feed(owner, topic).unwrap().as_deref(), + Some(&b"v1"[..]) + ); + + // Another owner's feed is a distinct slot. + let other = Address::from([0xBB; 20]); + assert_eq!(store.read_feed(other, topic).unwrap(), None); + store.seed_feed(other, topic, b"theirs"); + assert_eq!( + store.read_feed(other, topic).unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + } + + #[test] + fn remote_store_fault_injection_covers_every_operation() { + let store = MockRemoteStore::default(); + store.fail_with(Fault::Timeout); + assert!(matches!(store.upload(b"x").unwrap_err(), Fault::Timeout)); + assert!(matches!( + store.download(B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.read_feed(Address::ZERO, B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.write_feed(B256::ZERO, b"x").unwrap_err(), + Fault::Timeout, + )); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); host.chain .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + host.messaging.seed_payload("/t", b"m".to_vec(), 1); - // Through the `Host` supertrait. + // Through the `Host` supertrait: all six seams on one value. let _: &dyn nexum_sdk::host::Host = &host; host.set("key", b"val").unwrap(); assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); + assert!(host.accounts().unwrap().is_empty()); + assert_eq!(host.query("/t", None, None, None).unwrap().len(), 1); + let reference = host.upload(b"blob").unwrap(); + assert_eq!(host.download(reference).unwrap(), b"blob"); host.log(Level::INFO, "happy path"); assert_eq!(host.chain.call_count(), 1); assert_eq!(host.logging.lines().len(), 1); assert_eq!(host.store.len(), 1); + assert_eq!(host.remote_store.blob_count(), 1); } #[test] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 6e0e71d5..949c5eec 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,13 +1,14 @@ //! Host traits - the seam between strategy logic and the wit-bindgen //! shims a module generates per-cdylib. //! -//! Each trait mirrors one nexum host interface ([`ChainHost`] for -//! `nexum:host/chain`, [`LocalStoreHost`] for `nexum:host/local-store`, -//! [`LoggingHost`] for `nexum:host/logging`). A module that wants +//! Each trait mirrors one nexum host interface: [`ChainHost`], +//! [`IdentityHost`], [`LocalStoreHost`], [`RemoteStoreHost`], +//! [`MessagingHost`], and [`LoggingHost`]. A module that wants //! host-free unit tests writes its strategy logic against the -//! [`Host`] supertrait and lets `nexum-sdk-test` slot in the -//! in-memory mocks. Domain SDKs bound extra host interfaces on top -//! with their own traits over the same [`Fault`]. +//! [`Host`] supertrait (all six) or the exact traits it exercises, +//! and lets `nexum-sdk-test` slot in the in-memory mocks. Domain SDKs +//! bound extra host interfaces on top with their own traits over the +//! same [`Fault`]. //! //! ## Why a separate `Fault` //! @@ -18,7 +19,7 @@ //! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s //! crate docs for the adapter pattern. -use alloy_primitives::Bytes; +use alloy_primitives::{Address, B256, Bytes, Signature}; use strum::IntoStaticStr; use tracing_core::Level; @@ -202,14 +203,108 @@ pub trait LoggingHost { fn log(&self, level: Level, message: &str); } -/// Supertrait that bundles the core host interfaces a typical -/// strategy module exercises. Modules that want full host-free -/// integration tests take `&impl Host` (or a generic ``) in -/// their strategy function; `nexum-sdk-test::MockHost` is the -/// in-memory implementation. Strategies that reach a domain extension -/// bound its host trait as well (the CoW SDK's `CowHost`, say). +/// `nexum:host/identity` - host-held accounts and signing. +pub trait IdentityHost { + /// Accounts the host is willing to sign for. Empty means no + /// signing capability. + fn accounts(&self) -> Result, Fault>; + /// Sign `message` with `personal_sign` semantics (the host + /// prepends the `"\x19Ethereum Signed Message:\n"` prefix). + fn sign(&self, account: Address, message: &[u8]) -> Result; + /// Sign a JSON-encoded EIP-712 payload. + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result; +} + +/// One delivered message, mirrored from `nexum:host/types.message` so +/// the [`MessagingHost`] seam stays mockable without naming bindgen +/// types. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + /// Content topic the message arrived on. + pub content_topic: String, + /// Opaque payload bytes. + pub payload: Vec, + /// Delivery timestamp, ms since the Unix epoch, UTC. + pub timestamp: u64, + /// Optional sender identity (protocol-dependent). + pub sender: Option>, +} + +/// `nexum:host/messaging` - publish to and query content topics. The +/// host confines both to the component's `messaging_topics` grant; an +/// off-scope topic fails as [`Fault::Denied`]. +pub trait MessagingHost { + /// Publish a payload to a content topic + /// (`////`). + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; + /// Query historical messages on a topic, window bounded by the + /// optional `start_time` / `end_time` (ms since the Unix epoch, + /// UTC) and `limit`. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault>; +} + +/// `nexum:host/remote-store` - content-addressed blobs and mutable +/// feeds on the decentralized store. +pub trait RemoteStoreHost { + /// Upload raw data; returns its 32-byte content reference. + fn upload(&self, data: &[u8]) -> Result; + /// Download the data behind a content reference. + fn download(&self, reference: B256) -> Result, Fault>; + /// Latest value of the `(owner, topic)` mutable feed, when set. + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault>; + /// Update the host-owned feed at `topic` (the host signs with its + /// configured identity); returns the new chunk's reference. + fn write_feed(&self, topic: B256, data: &[u8]) -> Result; +} + +/// Lift a host-returned account into an [`Address`]. The WIT edge +/// carries it as bytes; any length but 20 is a host-side bug, folded +/// to [`Fault::Internal`]. +pub fn account_from_wire(raw: &[u8]) -> Result { + Address::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "identity returned a {}-byte account, expected 20", + raw.len() + )) + }) +} + +/// Lift a host-returned 65-byte `r || s || v` signature into a +/// [`Signature`]. A malformed buffer is a host-side bug, folded to +/// [`Fault::Internal`]. +pub fn signature_from_wire(raw: &[u8]) -> Result { + Signature::from_raw(raw) + .map_err(|e| Fault::Internal(format!("identity returned a malformed signature: {e}"))) +} + +/// Lift a host-returned content reference into a [`B256`]. Any length +/// but 32 is a host-side bug, folded to [`Fault::Internal`]. +pub fn reference_from_wire(raw: &[u8]) -> Result { + B256::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "remote-store returned a {}-byte reference, expected 32", + raw.len() + )) + }) +} + +/// Supertrait that bundles all six core host interfaces. Modules that +/// want full host-free integration tests take `&impl Host` (or a +/// generic ``) in their strategy function; +/// `nexum-sdk-test::MockHost` is the in-memory implementation. +/// Strategies that exercise fewer interfaces bound exactly those +/// (`H: ChainHost + LoggingHost`, say) so their production adapter +/// only needs the capabilities the module declares; a domain +/// extension's host trait is bounded the same way (the CoW SDK's +/// `CowHost`). /// -/// A blanket impl is provided for any type that implements all three +/// A blanket impl is provided for any type that implements all six /// component traits, so callers do not have to add a redundant /// `impl Host for MyHost {}`. /// @@ -222,8 +317,10 @@ pub trait LoggingHost { /// ``` /// use nexum_sdk::Level; /// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, LocalStoreHost, LoggingHost, +/// ChainError, ChainHost, Fault, Host, IdentityHost, LocalStoreHost, LoggingHost, +/// Message, MessagingHost, RemoteStoreHost, /// }; +/// # use nexum_sdk::prelude::{Address, B256, Signature}; /// /// /// Pure strategy logic - no wit-bindgen calls in here. /// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { @@ -241,23 +338,93 @@ pub trait LoggingHost { /// # Ok("\"0x0\"".into()) /// # } /// # } +/// # impl IdentityHost for StubHost { +/// # fn accounts(&self) -> Result, Fault> { Ok(vec![]) } +/// # fn sign(&self, _: Address, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn sign_typed_data(&self, _: Address, _: &str) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } /// # impl LocalStoreHost for StubHost { /// # fn get(&self, _: &str) -> Result>, Fault> { Ok(None) } /// # fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } /// # fn delete(&self, _: &str) -> Result<(), Fault> { Ok(()) } /// # fn list_keys(&self, _: &str) -> Result, Fault> { Ok(vec![]) } /// # } +/// # impl RemoteStoreHost for StubHost { +/// # fn upload(&self, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn download(&self, _: B256) -> Result, Fault> { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn read_feed(&self, _: Address, _: B256) -> Result>, Fault> { Ok(None) } +/// # fn write_feed(&self, _: B256, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } +/// # impl MessagingHost for StubHost { +/// # fn publish(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } +/// # fn query( +/// # &self, +/// # _: &str, +/// # _: Option, +/// # _: Option, +/// # _: Option, +/// # ) -> Result, Fault> { +/// # Ok(vec![]) +/// # } +/// # } /// # impl LoggingHost for StubHost { /// # fn log(&self, _: Level, _: &str) {} /// # } /// record_block(&StubHost, 1, "block:42").unwrap(); /// ``` -pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} -impl Host for T {} +pub trait Host: + ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} +impl Host for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} #[cfg(test)] mod tests { - use super::{ChainError, Fault, HostFault, RateLimit, RpcError}; + use alloy_primitives::{Address, B256, U256}; + + use super::{ + ChainError, Fault, HostFault, RateLimit, RpcError, account_from_wire, reference_from_wire, + signature_from_wire, + }; + + #[test] + fn wire_lifts_accept_exact_lengths() { + let account = account_from_wire(&[0x11; 20]).unwrap(); + assert_eq!(account, Address::from([0x11; 20])); + + let reference = reference_from_wire(&[0x22; 32]).unwrap(); + assert_eq!(reference, B256::from([0x22; 32])); + + let raw = alloy_primitives::Signature::new(U256::from(1), U256::from(2), true).as_bytes(); + let signature = signature_from_wire(&raw).unwrap(); + assert_eq!(signature.r(), U256::from(1)); + assert_eq!(signature.s(), U256::from(2)); + assert!(signature.v()); + } + + #[test] + fn wire_lifts_fold_malformed_buffers_to_internal() { + for fault in [ + account_from_wire(&[0u8; 19]).unwrap_err(), + signature_from_wire(&[0u8; 64]).unwrap_err(), + reference_from_wire(&[0u8; 31]).unwrap_err(), + ] { + assert!(matches!(fault, Fault::Internal(_)), "got {fault:?}"); + } + } #[test] fn fault_labels_are_stable_snake_case() { diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 59794c1b..fd487c02 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -17,12 +17,13 @@ //! primitives ([`Address`], [`B256`], [`Bytes`], [`U256`], //! [`keccak256`]). //! -//! - [`host`] - host trait seam ([`Host`] / [`ChainHost`] / -//! [`LocalStoreHost`] / [`LoggingHost`]) plus the host-neutral -//! [`Fault`] vocabulary. Modules that want host-free tests structure -//! their strategy logic against these traits and slot in the -//! `nexum-sdk-test` mocks. See the host module docs for the -//! wit-bindgen adapter pattern. +//! - [`host`] - host trait seam: [`Host`] bundling all six core +//! interfaces ([`ChainHost`] / [`IdentityHost`] / [`LocalStoreHost`] +//! / [`RemoteStoreHost`] / [`MessagingHost`] / [`LoggingHost`]) plus +//! the host-neutral [`Fault`] vocabulary. Modules that want +//! host-free tests structure their strategy logic against these +//! traits and slot in the `nexum-sdk-test` mocks. See the host +//! module docs for the wit-bindgen adapter pattern. //! //! - [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) - //! generates the per-module `WitBindgenHost` adapter over the @@ -87,7 +88,10 @@ //! [`keccak256`]: alloy_primitives::keccak256 //! [`Host`]: host::Host //! [`ChainHost`]: host::ChainHost +//! [`IdentityHost`]: host::IdentityHost //! [`LocalStoreHost`]: host::LocalStoreHost +//! [`RemoteStoreHost`]: host::RemoteStoreHost +//! [`MessagingHost`]: host::MessagingHost //! [`LoggingHost`]: host::LoggingHost //! [`Fault`]: host::Fault //! [`WatchSet`]: keeper::WatchSet diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs index ef4bcc1f..b72a6b04 100644 --- a/crates/nexum-sdk/src/prelude.rs +++ b/crates/nexum-sdk/src/prelude.rs @@ -7,4 +7,4 @@ //! crate (one `wit_bindgen::generate!` call per cdylib). Domain SDKs //! ship their own prelude for their protocol surface. -pub use alloy_primitives::{Address, B256, Bytes, U256, address, b256, hex, keccak256}; +pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 6ac615e4..81f1c141 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -3,16 +3,16 @@ //! //! Before this macro existed, each module hand-rolled ~80 lines of //! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus the fault, -//! chain-error, and level conversions. The code differed across modules -//! in zero places that were not bugs. +//! impls plus the fault, chain-error, and level conversions. The code +//! differed across modules in zero places that were not bugs. //! //! The adapter is capability-selected: the `caps: [...]` form emits //! only the pieces backed by the module's declared capabilities //! (`#[nexum_sdk::module]` invokes it this way, matching the //! per-module world it generates), while the zero-argument form emits -//! the full `chain, local_store, logging` set for modules that -//! compile against a blanket world with every core import present. +//! the full six-interface set (`chain, identity, local_store, +//! remote_store, messaging, logging`) for modules that compile +//! against a blanket world with every core import present. //! Either way the call site must already have the wit-bindgen output //! for its world in scope (`wit_bindgen::generate!({ ..., //! generate_all })`): each selected piece resolves its @@ -33,14 +33,16 @@ //! //! // `WitBindgenHost` and the `Fault` `From` impls (both directions) //! // are now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `LocalStoreHost` impl (local_store), and the -//! // `Level` `From` impl, `HostLogSink`, and `install_tracing` -//! // (logging), with the wit-bindgen and SDK types tied together -//! // through identifier resolution. Call `install_tracing()` once at -//! // the top of `Guest::init` to route `tracing::info!(...)` to the -//! // host. A `From for nexum_sdk::events::Log` is also -//! // emitted so `on_event` maps a chain-logs batch straight to -//! // `Vec`. +//! // (chain), the `IdentityHost`, `LocalStoreHost`, `RemoteStoreHost`, +//! // and `MessagingHost` impls (identity / local_store / remote_store / +//! // messaging), and the `Level` `From` impl, `HostLogSink`, and +//! // `install_tracing` (logging), with the wit-bindgen and SDK types +//! // tied together through identifier resolution. Call +//! // `install_tracing()` once at the top of `Guest::init` to route +//! // `tracing::info!(...)` to the host. `From` impls for +//! // `nexum_sdk::events::Log` and `nexum_sdk::host::Message` are also +//! // emitted so `on_event` maps chain-log batches and messages +//! // straight to the SDK types. //! ``` /// Generate `WitBindgenHost` + the `*Host` trait impls + the error / @@ -58,7 +60,9 @@ macro_rules! bind_host_via_wit_bindgen { // Blanket-world form: every core interface is in scope, emit the // full adapter. () => { - $crate::bind_host_via_wit_bindgen!(caps: [chain, local_store, logging]); + $crate::bind_host_via_wit_bindgen!( + caps: [chain, identity, local_store, remote_store, messaging, logging] + ); }; // Capability-selected form: the base pieces (which need only the // always-present `nexum:host/types`) plus one block per listed @@ -143,6 +147,20 @@ macro_rules! bind_host_via_wit_bindgen { } } + /// Rebuild the SDK message from the per-cdylib wit-bindgen + /// `message` record, so `on_event` maps a delivery straight to + /// `nexum_sdk::host::Message`. + impl ::core::convert::From for $crate::host::Message { + fn from(message: nexum::host::types::Message) -> Self { + Self { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } + } + $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* }; } @@ -182,6 +200,40 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } }; + (identity) => { + impl $crate::host::IdentityHost for WitBindgenHost { + fn accounts( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec<$crate::prelude::Address>, + $crate::host::Fault, + > { + nexum::host::identity::accounts() + .map_err($crate::host::Fault::from)? + .iter() + .map(|account| $crate::host::account_from_wire(account)) + .collect() + } + fn sign( + &self, + account: $crate::prelude::Address, + message: &[u8], + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign(account.as_slice(), message) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + fn sign_typed_data( + &self, + account: $crate::prelude::Address, + typed_data: &str, + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign_typed_data(account.as_slice(), typed_data) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + } + }; (local_store) => { impl $crate::host::LocalStoreHost for WitBindgenHost { fn get( @@ -212,6 +264,75 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } }; + (remote_store) => { + impl $crate::host::RemoteStoreHost for WitBindgenHost { + fn upload( + &self, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = + nexum::host::remote_store::upload(data).map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + fn download( + &self, + reference: $crate::prelude::B256, + ) -> ::core::result::Result<::std::vec::Vec, $crate::host::Fault> { + nexum::host::remote_store::download(reference.as_slice()) + .map_err($crate::host::Fault::from) + } + fn read_feed( + &self, + owner: $crate::prelude::Address, + topic: $crate::prelude::B256, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::remote_store::read_feed(owner.as_slice(), topic.as_slice()) + .map_err($crate::host::Fault::from) + } + fn write_feed( + &self, + topic: $crate::prelude::B256, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = nexum::host::remote_store::write_feed(topic.as_slice(), data) + .map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + } + }; + (messaging) => { + impl $crate::host::MessagingHost for WitBindgenHost { + fn publish( + &self, + content_topic: &str, + payload: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::messaging::publish(content_topic, payload) + .map_err($crate::host::Fault::from) + } + fn query( + &self, + content_topic: &str, + start_time: ::core::option::Option, + end_time: ::core::option::Option, + limit: ::core::option::Option, + ) -> ::core::result::Result<::std::vec::Vec<$crate::host::Message>, $crate::host::Fault> + { + let messages = + nexum::host::messaging::query(content_topic, start_time, end_time, limit) + .map_err($crate::host::Fault::from)?; + ::core::result::Result::Ok( + messages + .into_iter() + .map(::core::convert::Into::into) + .collect(), + ) + } + } + }; (logging) => { impl $crate::host::LoggingHost for WitBindgenHost { fn log(&self, level: $crate::Level, message: &str) { diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/nexum-venue-test/src/transport.rs index e90e4b24..37f9e7c1 100644 --- a/crates/nexum-venue-test/src/transport.rs +++ b/crates/nexum-venue-test/src/transport.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use nexum_sdk::host::{ChainError, ChainHost, Fault}; use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; -pub use nexum_sdk_test::{ChainCall, MockChain}; +pub use nexum_sdk_test::{ChainCall, MockChain, MockMessaging, PublishRecord}; pub use videre_sdk::transport::{Message, MessagingHost}; /// Composed in-memory transport. Each field exposes the per-seam mock @@ -68,141 +68,6 @@ impl Fetch for MockTransport { } } -// ------------------------------------------------------------ messaging - -/// One recorded [`MessagingHost::publish`] invocation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PublishRecord { - /// Content topic the adapter published to. - pub content_topic: String, - /// Payload bytes, verbatim. - pub payload: Vec, -} - -/// In-memory [`MessagingHost`]. Seeded messages answer queries, -/// publishes are recorded for assertion, and an optional topic scope -/// mirrors the host's `messaging_topics` grant. Seeded history and -/// published records are deliberately separate stores: a query answers -/// from what the test seeded, never from what the adapter published. -#[derive(Default)] -pub struct MockMessaging { - history: RefCell>, - published: RefCell>, - scope: RefCell>>, - faults: RefCell>, -} - -impl MockMessaging { - /// Seed one message into the queryable history. - pub fn seed(&self, message: Message) { - self.history.borrow_mut().push(message); - } - - /// Seed a payload on `content_topic` at `timestamp` (ms since the - /// Unix epoch, UTC), with no sender. - pub fn seed_payload( - &self, - content_topic: impl Into, - payload: impl Into>, - timestamp: u64, - ) { - self.seed(Message { - content_topic: content_topic.into(), - payload: payload.into(), - timestamp, - sender: None, - }); - } - - /// Confine the mock to `topics`, mirroring the adapter's - /// `messaging_topics` grant: any other topic fails as - /// [`Fault::Denied`]. Untouched, every topic is allowed. - pub fn scope_topics(&self, topics: impl IntoIterator>) { - *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); - } - - /// Inject a fault for any operation on a topic starting with - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. - pub fn fail_on(&self, prefix: impl Into, fault: Fault) { - self.faults.borrow_mut().push((prefix.into(), fault)); - } - - /// All publishes received, in arrival order. - pub fn published(&self) -> Vec { - self.published.borrow().clone() - } - - /// Last publish received, if any. - pub fn last_published(&self) -> Option { - self.published.borrow().last().cloned() - } - - /// Total publish count. - pub fn publish_count(&self) -> usize { - self.published.borrow().len() - } - - fn admit(&self, content_topic: &str) -> Result<(), Fault> { - for (prefix, fault) in self.faults.borrow().iter() { - if content_topic.starts_with(prefix.as_str()) { - return Err(fault.clone()); - } - } - if let Some(scope) = self.scope.borrow().as_ref() - && !scope.iter().any(|topic| topic == content_topic) - { - return Err(Fault::Denied(format!( - "MockMessaging: {content_topic} is outside the scoped topics" - ))); - } - Ok(()) - } -} - -impl MessagingHost for MockMessaging { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.admit(content_topic)?; - self.published.borrow_mut().push(PublishRecord { - content_topic: content_topic.to_owned(), - payload: payload.to_vec(), - }); - Ok(()) - } - - /// Answer from the seeded history: exact-topic matches whose - /// timestamp lies within the inclusive `start_time..=end_time` - /// window, in seed order. Seed order is delivery order, so a - /// `limit` keeps the newest matches: the tail. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.admit(content_topic)?; - let mut matches: Vec = self - .history - .borrow() - .iter() - .filter(|message| { - message.content_topic == content_topic - && start_time.is_none_or(|start| message.timestamp >= start) - && end_time.is_none_or(|end| message.timestamp <= end) - }) - .cloned() - .collect(); - if let Some(limit) = limit { - let keep = usize::try_from(limit).unwrap_or(usize::MAX); - if matches.len() > keep { - matches.drain(..matches.len() - keep); - } - } - Ok(matches) - } -} - // ------------------------------------------------------------ http /// One recorded [`Fetch::fetch_with`] invocation. @@ -316,75 +181,6 @@ impl Fetch for MockFetch { mod tests { use super::*; - #[test] - fn messaging_records_publishes_and_answers_from_seeds() { - let messaging = MockMessaging::default(); - messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); - messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); - messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); - - messaging.publish("/acme/1/orders/proto", b"out").unwrap(); - assert_eq!(messaging.publish_count(), 1); - assert_eq!( - messaging.last_published().unwrap(), - PublishRecord { - content_topic: "/acme/1/orders/proto".to_owned(), - payload: b"out".to_vec(), - }, - ); - - // Publishes never leak into query results. - let all = messaging - .query("/acme/1/orders/proto", None, None, None) - .unwrap(); - assert_eq!(all.len(), 2); - assert_eq!(all[0].payload, b"one"); - assert_eq!(all[1].payload, b"two"); - } - - #[test] - fn messaging_query_applies_bounds_and_limit() { - let messaging = MockMessaging::default(); - for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { - messaging.seed_payload("/t", payload.to_vec(), ts); - } - - let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); - assert_eq!(window.len(), 2); - assert_eq!(window[0].payload, b"b"); - - // A limit keeps the newest matches: the tail of the window. - let limited = messaging.query("/t", None, None, Some(2)).unwrap(); - assert_eq!(limited.len(), 2); - assert_eq!(limited[0].payload, b"c"); - assert_eq!(limited[1].payload, b"d"); - } - - #[test] - fn messaging_scope_denies_off_grant_topics() { - let messaging = MockMessaging::default(); - messaging.scope_topics(["/acme/1/orders/proto"]); - - messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); - let err = messaging.publish("/other", b"no").unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - let err = messaging.query("/other", None, None, None).unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - // The refused publish was never recorded. - assert_eq!(messaging.publish_count(), 1); - } - - #[test] - fn messaging_fault_injection_fires_by_prefix() { - let messaging = MockMessaging::default(); - messaging.fail_on("/flaky", Fault::Timeout); - assert!(matches!( - messaging.publish("/flaky/topic", b"x").unwrap_err(), - Fault::Timeout, - )); - messaging.publish("/steady", b"x").unwrap(); - } - #[test] fn fetch_returns_programmed_response_and_records_the_request() { let fetch = MockFetch::default(); diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 2b39f860..01971b2d 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -47,7 +47,7 @@ pub const CORE: &[Capability] = &[ name: "identity", import: Some("nexum:host/identity@0.1.0"), packages: &[], - adapter: None, + adapter: Some("identity"), }, Capability { name: "local-store", @@ -59,13 +59,13 @@ pub const CORE: &[Capability] = &[ name: "remote-store", import: Some("nexum:host/remote-store@0.1.0"), packages: &[], - adapter: None, + adapter: Some("remote_store"), }, Capability { name: "messaging", import: Some("nexum:host/messaging@0.1.0"), packages: &[], - adapter: None, + adapter: Some("messaging"), }, Capability { name: "logging", @@ -405,6 +405,32 @@ mod tests { assert!(CORE.iter().all(|c| c.packages.is_empty())); } + #[test] + fn every_import_bearing_core_row_carries_an_adapter() { + // `http` has no world import (SDK wasi:http client) and no + // adapter; every other core row has both. + for cap in CORE { + assert_eq!(cap.import.is_some(), cap.adapter.is_some(), "{}", cap.name); + } + } + + #[test] + fn full_declaration_emits_the_six_adapters_in_core_order() { + let declared: Vec = CORE.iter().map(|c| c.name.to_string()).collect(); + let world = synthesize(&declared, &[]).unwrap(); + assert_eq!( + world.adapters, + vec![ + "chain", + "identity", + "local_store", + "remote_store", + "messaging", + "logging", + ], + ); + } + #[test] fn http_declares_no_world_import() { let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index 2e08b74f..e6a8468b 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -50,8 +50,14 @@ use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; -use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature}; +use nexum_sdk_test::{ + MockChain, MockIdentity, MockLocalStore, MockLogging, MockMessaging, MockRemoteStore, +}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait @@ -63,8 +69,14 @@ use shepherd_sdk::cow::{CowApiError, CowApiHost}; pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, /// `nexum:host/local-store` mock. pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, /// `shepherd:cow/cow-api` mock. pub cow_api: V, /// `nexum:host/logging` mock. @@ -106,6 +118,49 @@ impl LocalStoreHost for MockHost { } } +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + impl CowApiHost for MockHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { self.cow_api.submit_order(chain_id, body) diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs index fa7060d2..ff4b7e5d 100644 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ b/crates/shepherd-sdk/src/wit_bindgen_macro.rs @@ -2,11 +2,10 @@ //! CoW modules: the generic adapter plus the `CowApiHost` impl. //! //! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the -//! core adapter (`WitBindgenHost`, the `ChainHost` / `LocalStoreHost` -//! / `LoggingHost` impls, the fault and level `From` impls, and the -//! tracing wiring). This macro invokes it and adds the -//! [`CowApiHost`](crate::cow::CowApiHost) impl over the -//! `shepherd:cow/cow-api` import shims. +//! core adapter (`WitBindgenHost`, the six core host trait impls, the +//! fault and level `From` impls, and the tracing wiring). This macro +//! invokes it and adds the [`CowApiHost`](crate::cow::CowApiHost) +//! impl over the `shepherd:cow/cow-api` import shims. //! //! The macro assumes the module compiles against `shepherd:cow/shepherd` //! with `wit_bindgen::generate!({ ..., generate_all })`, so both the diff --git a/crates/videre-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs index a6b8f905..a5d62bd1 100644 --- a/crates/videre-sdk/src/transport.rs +++ b/crates/videre-sdk/src/transport.rs @@ -87,26 +87,9 @@ fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { } } -/// `nexum:host/messaging` - publish to and query the venue's content -/// topics. The seam between adapter logic and the messaging transport; -/// [`HostMessaging`] is the bound impl. -pub trait MessagingHost { - /// Publish a payload to a content topic - /// (`////`). A topic outside the - /// adapter's `messaging_topics` scope fails as [`Fault::Denied`]. - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; - - /// Query historical messages from the store protocol, newest window - /// bounded by the optional `start_time` / `end_time` (ms since the - /// Unix epoch, UTC) and `limit`. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault>; -} +/// The messaging seam and its message mirror, canonical in the module +/// SDK; [`HostMessaging`] is this crate's bound impl. +pub use nexum_sdk::host::{Message, MessagingHost}; /// The adapter's `nexum:host/messaging` import behind the /// [`MessagingHost`] seam. @@ -131,21 +114,6 @@ impl MessagingHost for HostMessaging { } } -/// One delivered message, mirrored from `nexum:host/types.message` so -/// the [`MessagingHost`] seam stays mockable without naming bindgen -/// types. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Message { - /// Content topic the message arrived on. - pub content_topic: String, - /// Opaque payload bytes. - pub payload: Vec, - /// Delivery timestamp, ms since the Unix epoch, UTC. - pub timestamp: u64, - /// Optional sender identity (protocol-dependent). - pub sender: Option>, -} - impl From for Message { fn from(message: crate::bindings::nexum::host::types::Message) -> Self { Self { diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 7b89ece6..ee3ae410 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -9,7 +9,7 @@ //! ## Module layout //! //! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` +//! the `nexum_sdk::host` trait seam. It does not know `wit-bindgen` //! exists. //! - `lib.rs` (this file) declares the handlers and defers the //! per-cdylib glue to `#[nexum_sdk::module]`. diff --git a/modules/examples/balance-tracker/src/strategy.rs b/modules/examples/balance-tracker/src/strategy.rs index 91e25193..f1f5bf73 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/modules/examples/balance-tracker/src/strategy.rs @@ -1,11 +1,13 @@ //! Pure strategy logic for the balance-tracker module. //! -//! Every interaction with the world flows through the [`Host`] trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. +//! Every interaction with the world flows through the host trait +//! seam exposed by `nexum-sdk`, bounded to exactly the interfaces the +//! module declares ([`ChainHost`] + [`LocalStoreHost`]) - no direct +//! calls to wit-bindgen-generated free functions live here. The +//! `lib.rs` glue wraps a `WitBindgenHost` adapter around the module's +//! per-cdylib wit-bindgen imports and hands it to [`on_block`]; tests +//! under `#[cfg(test)]` hand the same function a +//! `nexum_sdk_test::MockHost`. //! //! Aligns balance-tracker with the M3 "host trait + adapter" recipe //! the other four modules already follow (PR #55 review). Previously @@ -15,7 +17,7 @@ use nexum_sdk::address::parse_address_list; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; use nexum_sdk::prelude::{Address, U256}; /// Resolved settings parsed from `[config]` at `init` and read on @@ -35,7 +37,11 @@ pub struct Settings { /// Each address is independent; a single flaky `eth_getBalance` does /// not abort the loop - the failure is logged and the next address is /// still polled. -pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result<(), Fault> { +pub fn on_block( + host: &H, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> { for addr in &settings.addresses { if let Err(err) = check_one(host, chain_id, *addr, settings.change_threshold) { tracing::warn!("balance-tracker {addr:#x}: {err}"); @@ -47,7 +53,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result /// Poll one address: fetch latest balance, diff against the last /// stored value, emit a log if the delta crosses `threshold`, then /// persist the new value under `balance:{addr}`. -fn check_one( +fn check_one( host: &H, chain_id: u64, addr: Address, @@ -74,7 +80,7 @@ fn check_one( } /// `chain::request("eth_getBalance", [addr, "latest"])` -> `U256`. -fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { +fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { let params = format!("[\"{addr:#x}\",\"latest\"]"); let result_json = host.request(chain_id, "eth_getBalance", ¶ms)?; parse_balance_hex(&result_json).ok_or_else(|| { @@ -149,7 +155,7 @@ fn config_err(e: ConfigError) -> Fault { mod tests { use super::*; use nexum_sdk::Level; - use nexum_sdk::host::{ChainError, Fault, LocalStoreHost as _}; + use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::prelude::address; use nexum_sdk_test::{MockHost, capture_tracing};