diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 81bd4e5c..174bb4e8 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -1,19 +1,19 @@ //! The typed CoW intent client. //! -//! [`CowClient`] binds the strategy-facing [`IntentClient`] to the CoW +//! [`CowClient`] binds the keeper-facing [`IntentClient`] to the CoW //! venue id and speaks the venue's own [`CowIntentBody`] over it, so -//! strategy code submits a typed CoW body without naming the venue on +//! keeper code submits a typed CoW body without naming the venue on //! every call or handling wire bytes. The classification API //! ([`classify`](crate::classification::classify)) travels in the same //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use nexum_venue_sdk::client::{ClientError, IntentClient, IntentPool}; +use nexum_venue_sdk::client::{ClientError, IntentClient, VenueClient}; use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; use crate::body::CowIntentBody; -/// The venue id the CoW adapter registers under and the router resolves. +/// The venue id the CoW adapter registers under and the registry resolves. /// Every [`CowClient`] call routes here. pub const VENUE: &str = "cow"; @@ -25,11 +25,11 @@ pub struct CowClient

{ inner: IntentClient

, } -impl CowClient

{ - /// Bind a pool handle to the CoW venue. - pub fn new(pool: P) -> Self { +impl CowClient

{ + /// Bind a client handle to the CoW venue. + pub fn new(venues: P) -> Self { Self { - inner: IntentClient::new(pool, VENUE), + inner: IntentClient::new(venues, VENUE), } } @@ -66,13 +66,13 @@ mod tests { /// Records the venue every call routed to and the bytes submitted. /// Cloneable over a shared log so the test can inspect it after the - /// pool moves into the client. + /// handle moves into the client. #[derive(Clone, Default)] - struct SpyPool { + struct SpyClient { submitted: SubmitLog, } - impl IntentPool for SpyPool { + impl VenueClient for SpyClient { fn submit(&self, venue: &str, body: Vec) -> Result { self.submitted .borrow_mut() @@ -112,15 +112,15 @@ mod tests { fn submit_routes_to_the_cow_venue_with_encoded_body() { use nexum_venue_sdk::IntentBody; - let pool = SpyPool::default(); + let spy = SpyClient::default(); let body = sample_body(); let expected = body.to_bytes().expect("body encodes"); - let client = CowClient::new(pool.clone()); + let client = CowClient::new(spy.clone()); assert_eq!(client.venue(), VENUE); client.submit(&body).expect("submit succeeds"); - let calls = pool.submitted.borrow(); + let calls = spy.submitted.borrow(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, VENUE); assert_eq!(calls[0].1, expected); diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index c760b37f..d9566925 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -9,7 +9,7 @@ //! //! [`venue`] is the adapter counterpart: it emits the same per-cdylib //! wit-bindgen and `export!`, but for a per-component venue-adapter -//! world exporting the `nexum:intent/adapter` face and importing only +//! world exporting the `videre:venue/adapter` face and importing only //! the manifest's declared scoped transport. //! //! [`derive@IntentBody`] implements the venue SDK's versioned body codec @@ -271,7 +271,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// The associated functions the `nexum:intent/adapter` face mandates. A +/// The associated functions the `videre:venue/adapter` face mandates. A /// venue adapter must define all four; `init` is separate (a no-op when /// absent, exactly as in a module). const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"]; @@ -280,7 +280,7 @@ const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"] /// /// Apply to an inherent `impl` block whose associated functions are the /// adapter face: `derive_header`, `submit`, `status`, `cancel` (all -/// required, from `nexum:intent/adapter`), plus an optional `init` +/// required, from `videre:venue/adapter`), plus an optional `init` /// (absent means a no-op). Each takes and returns the per-cdylib /// wit-bindgen payloads for its signature. The macro reads the crate's /// `module.toml`, synthesizes a per-component world exporting the @@ -298,7 +298,7 @@ const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"] /// /// The same crate-root resolution invariants as [`macro@module`] apply: /// the wit-bindgen output lands at the module crate root (so the emitted -/// glue resolves `Guest`, `Fault`, and the `nexum::*` type modules +/// glue resolves `Guest`, `Fault`, and the `nexum::*`/`videre::*` type modules /// there), the consuming crate must declare `wit-bindgen` as a direct /// dependency, and the crate root must not shadow std prelude names. #[proc_macro_attribute] @@ -423,12 +423,12 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { #init_impl } - impl exports::nexum::intent::adapter::Guest for __NexumVenueAdapterExport { + impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result< - nexum::intent::types::IntentHeader, - nexum::intent::types::VenueError, + videre::types::types::IntentHeader, + videre::types::types::VenueError, > { <#self_ty>::derive_header(body) } @@ -436,8 +436,8 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { fn submit( body: ::std::vec::Vec, ) -> ::core::result::Result< - nexum::intent::types::SubmitOutcome, - nexum::intent::types::VenueError, + videre::types::types::SubmitOutcome, + videre::types::types::VenueError, > { <#self_ty>::submit(body) } @@ -445,15 +445,15 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { fn status( receipt: ::std::vec::Vec, ) -> ::core::result::Result< - nexum::intent::types::IntentStatus, - nexum::intent::types::VenueError, + videre::types::types::IntentStatus, + videre::types::types::VenueError, > { <#self_ty>::status(receipt) } fn cancel( receipt: ::std::vec::Vec, - ) -> ::core::result::Result<(), nexum::intent::types::VenueError> { + ) -> ::core::result::Result<(), videre::types::types::VenueError> { <#self_ty>::cancel(receipt) } } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index 95614893..3fcf3818 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -32,7 +32,7 @@ struct Capability { /// Every capability the macro recognises, in emission order. Mirrors /// the runtime's core registry plus the extension namespaces the -/// workspace ships (`nexum:intent/pool`, `shepherd:cow/cow-api`). +/// workspace ships (`videre:venue/client`, `shepherd:cow/cow-api`). const KNOWN: &[Capability] = &[ Capability { name: "chain", @@ -71,9 +71,9 @@ const KNOWN: &[Capability] = &[ adapter: Some("logging"), }, Capability { - name: "pool", - import: Some("nexum:intent/pool@0.1.0"), - packages: &["nexum-value-flow", "nexum-intent"], + name: "client", + import: Some("videre:venue/client@0.1.0"), + packages: &["videre-value-flow", "videre-types", "videre-venue"], adapter: None, }, Capability { @@ -149,7 +149,7 @@ const VENUE_CAPABILITIES: &[&str] = &["chain", "messaging", "http"]; /// Build the per-component venue-adapter world from the declared /// capability names. The world exports `init` and the -/// `nexum:intent/adapter` face and imports exactly the declared scoped +/// `videre:venue/adapter` face and imports exactly the declared scoped /// transport, so a macro-built adapter's imports equal its declarations /// by construction. A capability outside the venue-permitted set is a /// compile error: an adapter that reaches for host key material or @@ -167,11 +167,16 @@ pub fn synthesize_venue(declared: &[String]) -> Result { } let mut imports = String::new(); - // The export face (`nexum:intent/adapter`, its types, and the - // value-flow vocabulary they are expressed in) needs the intent + // The export face (`videre:venue/adapter`, its types, and the + // value-flow vocabulary they are expressed in) needs the videre // packages on the resolve path beyond the leaf host package, in // dependency order: a package precedes its dependants. - let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + let mut packages = vec![ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ]; for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { continue; @@ -198,7 +203,7 @@ pub fn synthesize_venue(declared: &[String]) -> Result { wit.push_str(&imports); wit.push_str( "\n export init: func(config: config) -> result<_, fault>;\n \ - export nexum:intent/adapter@0.1.0;\n}\n", + export videre:venue/adapter@0.1.0;\n}\n", ); Ok(ModuleWorld { @@ -278,8 +283,13 @@ mod tests { const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; /// The package set every venue world resolves against: the exported - /// adapter face pulls the intent vocabulary, in dependency order. - const VENUE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; + /// adapter face pulls the videre vocabulary, in dependency order. + const VENUE_PACKAGES: [&str; 4] = [ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ]; #[test] fn logging_only_world_imports_logging_alone() { @@ -299,12 +309,17 @@ mod tests { } #[test] - fn pool_pulls_the_intent_packages() { - let world = synthesize(&["pool".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); + fn client_pulls_the_videre_packages() { + let world = synthesize(&["client".to_string()]).unwrap(); + assert!(world.wit.contains("import videre:venue/client@0.1.0;")); assert_eq!( world.packages, - vec!["nexum-host", "nexum-value-flow", "nexum-intent"] + vec![ + "nexum-host", + "videre-value-flow", + "videre-types", + "videre-venue" + ] ); assert!(world.adapters.is_empty()); } @@ -340,7 +355,7 @@ mod tests { .wit .contains("export init: func(config: config) -> result<_, fault>;") ); - assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); assert_eq!(world.packages, VENUE_PACKAGES); assert!(world.adapters.is_empty()); } @@ -368,12 +383,18 @@ mod tests { fn venue_world_with_no_capabilities_imports_nothing() { let world = synthesize_venue(&[]).unwrap(); assert!(!world.wit.contains("import")); - assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); } #[test] fn venue_world_refuses_non_transport_capabilities() { - for cap in ["local-store", "remote-store", "identity", "logging", "pool"] { + for cap in [ + "local-store", + "remote-store", + "identity", + "logging", + "client", + ] { let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); assert!(err.contains(cap), "message was: {err}"); assert!(err.contains("venue adapter"), "message was: {err}"); diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 52e9a3fa..f587536a 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -11,11 +11,11 @@ //! //! `nexum:host` is a leaf package: the host `event` variant carries an //! intent-status transition as opaque bytes, so the core world resolves -//! against `wit/nexum-host` alone. The intent and value-flow vocabulary -//! generates in the adapter bindgen below, and the pool bindgen remaps -//! onto it with `with`, so one Rust type serves the router and the -//! adapter face alike. `PartialEq` is derived so the router can compare -//! a polled status against the last delivered one. +//! against `wit/nexum-host` alone. The videre vocabulary generates in the +//! adapter bindgen below, and the client bindgen remaps onto it with +//! `with`, so one Rust type serves the registry and the adapter face +//! alike. `PartialEq` is derived so the registry can compare a polled +//! status against the last delivered one. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host"], @@ -26,25 +26,25 @@ wasmtime::component::bindgen!({ }); /// WIT bindings for the second component kind: the -/// `nexum:adapter/venue-adapter` world. An adapter imports only the scoped +/// `videre:venue/venue-adapter` world. An adapter imports only the scoped /// transport it needs (chain and messaging; outbound HTTP is wasi:http, /// linked and allowlisted separately as for event-module) and exports the -/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host` +/// `videre:venue/adapter` face plus `init`. The shared `nexum:host` /// interfaces are reused from the `event-module` bindings above via /// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type /// an adapter sees are the very ones the core host constructs. The -/// `nexum:intent` and `nexum:value-flow` types generate here (the leaf -/// host world no longer reaches them) and the pool bindgen below remaps +/// `videre:types` and `videre:value-flow` types generate here (the leaf +/// host world no longer reaches them) and the client bindgen below remaps /// onto them. mod venue_adapter { wasmtime::component::bindgen!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", + "../../wit/videre-value-flow", + "../../wit/videre-types", "../../wit/nexum-host", - "../../wit/nexum-adapter", + "../../wit/videre-venue", ], - world: "nexum:adapter/venue-adapter", + world: "videre:venue/venue-adapter", imports: { default: async }, exports: { default: async }, with: { @@ -58,86 +58,77 @@ mod venue_adapter { pub use venue_adapter::VenueAdapter; -/// The strategy-facing `nexum:intent/pool` import bound host-side. The pool -/// world imports the interface a module calls; the intent and value-flow -/// types it uses are reused from the adapter bindings above via `with`, so -/// the `SubmitOutcome` and `VenueError` the router hands back to a module +/// The keeper-facing `videre:venue/client` import bound host-side. The +/// world imports the interface a module calls; the videre types it uses +/// are reused from the adapter bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the registry hands back to a module /// are the very ones an adapter's `submit` produced - no lift between two /// structurally identical copies. Async, because the `Host` impl awaits the /// per-adapter mutex and the adapter's own async guest calls. -mod pool_host { +mod client_host { wasmtime::component::bindgen!({ inline: " - package nexum:pool-host; - world pool-host { - import nexum:intent/pool@0.1.0; + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; } ", - path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], imports: { default: async }, with: { - "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, - "nexum:intent/types": super::venue_adapter::nexum::intent::types, + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, }, }); } -/// The router-observed status transition delivered through the `event` -/// variant, re-exported at the plain spelling the router names. +/// The host-bound client interface: the `Host` trait the registry implements +/// and the `add_to_linker` the module linker calls. +pub use client_host::videre::venue::client; +/// The registry-observed status transition delivered through the `event` +/// variant, re-exported at the plain spelling the registry names. pub use nexum::host::types::IntentStatusUpdate; -/// The host-bound pool interface: the `Host` trait the router implements and -/// the `add_to_linker` the module linker calls. -pub use pool_host::nexum::intent::pool; -/// The shared intent ontology, re-exported at the plain spellings the router -/// and the `pool::Host` impl name. -pub use venue_adapter::nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +/// The shared intent ontology, re-exported at the plain spellings the +/// registry and the `client::Host` impl name. +pub use venue_adapter::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, + VenueError, }; /// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::nexum::value_flow::types as value_flow; - -/// Bindgen smoke for the `nexum:value-flow` types package. The package has -/// no host consumer yet (the intent router that will bind it lands later), -/// so this compiles it under test only, through a throwaway world that -/// imports the interface. Its value is the identifier-hygiene gate: the -/// test names every generated type, variant, and field by its plain Rust -/// spelling, so a WIT id that collided with a Rust keyword would surface as -/// an `r#` escape and fail to compile here rather than in a downstream -/// binding. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Bindgen smoke for the `videre:value-flow` types package, compiled under +/// test through a throwaway world that imports the interface. Its value is +/// the identifier-hygiene gate: the test names every generated type, +/// variant, and field by its plain Rust spelling, so a WIT id that collided +/// with a Rust keyword would surface as an `r#` escape and fail to compile +/// here rather than in a downstream binding. #[cfg(test)] mod value_flow_smoke { wasmtime::component::bindgen!({ inline: " - package nexum:value-flow-smoke; + package videre:value-flow-smoke; world smoke { - import nexum:value-flow/types@0.1.0; + import videre:value-flow/types@0.1.0; } ", - path: ["../../wit/nexum-value-flow"], + path: ["../../wit/videre-value-flow"], }); #[test] fn identifiers_bind_unescaped() { - use nexum::value_flow::types::{Asset, AssetAmount, OffchainDesc, ServiceDesc, Settlement}; + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; - let _ = Settlement::EvmChain(1); - let _ = Settlement::Offchain(String::new()); - - let service = ServiceDesc { - kind: String::new(), - summary: String::new(), - }; - let offchain = OffchainDesc { - domain: String::new(), - summary: String::new(), + let erc20 = Erc20 { + token: vec![0u8; 20], }; - - let _ = Asset::NativeToken(Settlement::EvmChain(1)); - let _ = Asset::Erc20((1, Vec::new())); - let _ = Asset::Erc721((1, Vec::new(), Vec::new())); - let _ = Asset::Erc1155((1, Vec::new(), Vec::new())); - let _ = Asset::Service(service); - let asset = Asset::Offchain(offchain); + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); let amount = AssetAmount { asset, @@ -147,34 +138,39 @@ mod value_flow_smoke { } } -/// Bindgen smoke for the `nexum:intent` package, mirroring the value-flow -/// smoke above: no host consumer exists yet (the pool router lands later), -/// so the package compiles under test only, through a throwaway world that -/// imports the pool interface and, transitively, the types interface and -/// its value-flow dependency. The test names every generated type, case, -/// and field by its plain Rust spelling, and a dummy `pool` host impl pins +/// Bindgen smoke for the `videre:types` and `videre:venue` packages, +/// mirroring the value-flow smoke above: a throwaway world imports the +/// client interface and, transitively, the types interface and its +/// value-flow dependency. The test names every generated type, case, and +/// field by its plain Rust spelling, and a dummy `client` host impl pins /// the three function signatures, so a keyword collision or an accidental /// signature change fails this build rather than a downstream binding. #[cfg(test)] -mod intent_smoke { +mod client_smoke { wasmtime::component::bindgen!({ inline: " - package nexum:intent-smoke; + package videre:client-smoke; world smoke { - import nexum:intent/pool@0.1.0; + import videre:venue/client@0.1.0; } ", - path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], }); - use nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, + VenueError, }; - use nexum::value_flow::types::Settlement; + use videre::value_flow::types::{Asset, AssetAmount}; - struct DummyPool; + struct DummyClient; - impl nexum::intent::pool::Host for DummyPool { + impl videre::venue::client::Host for DummyClient { fn submit(&mut self, _venue: String, _body: Vec) -> Result { Err(VenueError::UnknownVenue) } @@ -192,55 +188,56 @@ mod intent_smoke { } } + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + #[test] fn identifiers_bind_unescaped() { - use nexum::intent::pool::Host; + use videre::venue::client::Host; - let _ = AuthScheme::Eip712; let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Presign; - let _ = AuthScheme::OffchainSig; - let _ = AuthScheme::Unsigned; + let _ = AuthScheme::Eip712; let header = IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip712, }; - assert!(header.gives.is_empty() && header.wants.is_empty()); + assert!(header.wants.amount.is_empty()); let _ = IntentStatus::Pending; let _ = IntentStatus::Open; - let _ = IntentStatus::Settled(None); - let _ = IntentStatus::Failed(FailReason { - code: String::new(), - detail: String::new(), - }); - let _ = IntentStatus::Expired; + let _ = IntentStatus::Fulfilled; let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; let tx = UnsignedTx { - chain_id: 1, + chain: 1, to: Vec::new(), value: Vec::new(), - input: Vec::new(), + data: Vec::new(), }; let _ = SubmitOutcome::Accepted(Vec::new()); let _ = SubmitOutcome::RequiresSigning(tx); + let _ = VenueError::UnknownVenue; let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::InvalidReceipt; - let _ = VenueError::Rejected(String::new()); + let _ = VenueError::Unsupported; let _ = VenueError::Denied(String::new()); - let _ = VenueError::Unsupported(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::InternalError(String::new()); + let _ = VenueError::Timeout; - let mut pool = DummyPool; - assert!(pool.submit(String::new(), Vec::new()).is_err()); - assert!(pool.status(String::new(), Vec::new()).is_err()); - assert!(pool.cancel(String::new(), Vec::new()).is_err()); + let mut client = DummyClient; + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.status(String::new(), Vec::new()).is_err()); + assert!(client.cancel(String::new(), Vec::new()).is_err()); } } diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 262e5939..34ad617b 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -268,7 +268,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // will see: at least one intent-status subscriber and at least one // installed adapter to poll. let poll_statuses = supervisor.has_intent_status_subscribers() - && supervisor.pool_router().venue_count() > 0; + && supervisor.venue_registry().venue_count() > 0; // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. @@ -308,7 +308,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { ); let intent_status_stream = poll_statuses.then(|| { event_loop::open_intent_status_stream( - supervisor.pool_router(), + supervisor.venue_registry(), engine_cfg.limits.status_poll_interval(), &executor, &mut reconnect_tasks, diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index ea278de3..06cbc08a 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,7 +26,7 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::pool_router::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, PoolQuota}; +use crate::host::venue_registry::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, SubmitQuota}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; @@ -284,7 +284,7 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Default cadence for router-driven intent status polling (5 s). Fast +/// Default cadence for registry-driven intent status polling (5 s). Fast /// enough that a settling intent is observed within a block time or two, /// slow enough that per-receipt venue calls stay negligible. const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); @@ -488,11 +488,11 @@ impl ModuleLimits { } /// Resolved per-caller submission quota (overrides or defaults). A zero - /// `max_charges` is saturated up to 1 by the router builder, so a + /// `max_charges` is saturated up to 1 by the registry builder, so a /// misconfigured budget still admits one submission rather than bricking /// every venue. - pub fn quota(&self) -> PoolQuota { - PoolQuota::new( + pub fn quota(&self) -> SubmitQuota { + SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), self.quota .window_secs @@ -588,7 +588,7 @@ pub struct PoisonLimitsSection { } /// `[limits.quota]` per-caller intent submission budget. Both optional; -/// omitted values resolve to the router defaults via [`ModuleLimits::quota`]. +/// omitted values resolve to the registry defaults via [`ModuleLimits::quota`]. /// /// A caller (a strategy module, keyed by its namespace) may accrue at most /// `max_charges` submissions within a sliding `window_secs`; a decode failure @@ -607,7 +607,7 @@ pub struct QuotaLimitsSection { /// omitted value resolves to the built-in default and a degenerate zero /// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. /// -/// The cadence is how often the router polls each installed adapter's +/// The cadence is how often the registry polls each installed adapter's /// `status` export for the receipts it watches; only observed transitions /// fan out as `intent-status` events. #[derive(Debug, Default, Deserialize)] diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index a5b80c14..40b65ade 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -11,6 +11,6 @@ mod identity; mod local_store; mod logging; mod messaging; -mod pool; mod remote_store; mod types; +mod venue_client; diff --git a/crates/nexum-runtime/src/host/impls/pool.rs b/crates/nexum-runtime/src/host/impls/pool.rs deleted file mode 100644 index d02e29e5..00000000 --- a/crates/nexum-runtime/src/host/impls/pool.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! `nexum:intent/pool`: the strategy-facing intent import. Every method is a -//! thin delegation to the shared [`PoolRouter`](crate::host::pool_router) -//! carried in the store; the router owns the venue resolution, per-adapter -//! serialisation, guard seam, and quota. The caller identity the router meters -//! against is this store's module namespace. - -use crate::bindings::pool::Host; -use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -impl Host for HostState { - async fn submit(&mut self, venue: String, body: Vec) -> Result { - self.pool_router - .submit(&self.run.module, &venue, body) - .await - } - - async fn status( - &mut self, - venue: String, - receipt: Vec, - ) -> Result { - self.pool_router.status(&venue, receipt).await - } - - async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { - self.pool_router.cancel(&venue, receipt).await - } -} diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs new file mode 100644 index 00000000..26e76f59 --- /dev/null +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -0,0 +1,36 @@ +//! `videre:venue/client`: the keeper-facing venue import. Every method is a +//! thin delegation to the shared +//! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the +//! registry owns the venue resolution, per-adapter serialisation, guard +//! seam, and quota. The caller identity the registry meters against is this +//! store's module namespace. + +use crate::bindings::client::Host; +use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; +use crate::host::venue_registry::VenueId; + +impl Host for HostState { + async fn submit(&mut self, venue: String, body: Vec) -> Result { + self.venue_registry + .submit(&self.run.module, &VenueId::from(venue), body) + .await + } + + async fn status( + &mut self, + venue: String, + receipt: Vec, + ) -> Result { + self.venue_registry + .status(&VenueId::from(venue), receipt) + .await + } + + async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + self.venue_registry + .cancel(&VenueId::from(venue), receipt) + .await + } +} diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 5c41e467..1cf10f5d 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -31,6 +31,6 @@ pub mod http; mod impls; pub mod local_store_redb; pub mod logs; -pub mod pool_router; pub mod provider_pool; pub mod state; +pub mod venue_registry; diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index d299ccd0..5dd07d85 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -13,7 +13,7 @@ use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -use super::pool_router::PoolRouter; +use super::venue_registry::VenueRegistry; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -51,10 +51,10 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, - /// The intent pool router the `nexum:intent/pool` import dispatches to. + /// The venue registry the `videre:venue/client` import dispatches to. /// Every module store carries the same shared handle; an adapter store, - /// which cannot call pool, carries an empty one. - pub pool_router: PoolRouter, + /// which cannot call the client face, carries an empty one. + pub venue_registry: VenueRegistry, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/venue_registry.rs similarity index 67% rename from crates/nexum-runtime/src/host/pool_router.rs rename to crates/nexum-runtime/src/host/venue_registry.rs index 6353cf8a..dd4637b7 100644 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -1,16 +1,16 @@ -//! The intent pool router: the strategy-facing `nexum:intent/pool` import +//! The venue registry: the keeper-facing `videre:venue/client` import //! resolved to installed venue adapters. //! -//! A module's `pool::submit(venue, body)` reaches the host here. The router -//! resolves the venue id to the one installed adapter that answers for it, -//! then drives a fixed sequence against that adapter: derive the header, -//! run the guard interposition seam on it, and only then submit. Status and -//! cancel are pass-throughs; they are not submissions, so they skip the -//! header, the guard, and the quota. +//! A module's `client::submit(venue, body)` reaches the host here. The +//! registry resolves the venue id to the one installed adapter that answers +//! for it, then drives a fixed sequence against that adapter: derive the +//! header, run the guard interposition seam on it, and only then submit. +//! Status and cancel are pass-throughs; they are not submissions, so they +//! skip the header, the guard, and the quota. //! //! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, -//! so each adapter sits behind its own async mutex: concurrent pool calls to -//! the same venue queue on that mutex, while calls to different venues run +//! so each adapter sits behind its own async mutex: concurrent client calls +//! to the same venue queue on that mutex, while calls to different venues run //! in parallel. The lock is held across the guest await, which is the whole //! point - it is the actor boundary that keeps one adapter store //! single-threaded. @@ -23,6 +23,7 @@ //! own budget rather than the adapter's. use std::collections::{HashMap, VecDeque}; +use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -33,7 +34,8 @@ use tracing::warn; use wasmtime::Store; use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, SubmitOutcome, VenueAdapter, VenueError, + IntentHeader, IntentStatus, IntentStatusUpdate, RateLimit, SubmitOutcome, VenueAdapter, + VenueError, }; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -43,18 +45,48 @@ pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; /// Default sliding window the per-caller submission budget is counted over. pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Venue identifier: the id an adapter registers under and a submission +/// names. Opaque beyond equality. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VenueId(String); + +impl VenueId { + /// The id at its wire spelling. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for VenueId { + fn from(id: String) -> Self { + Self(id) + } +} + +impl From<&str> for VenueId { + fn from(id: &str) -> Self { + Self(id.to_owned()) + } +} + +impl fmt::Display for VenueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + /// Per-caller submission quota. Both a forwarded submission and a charged /// decode failure consume one unit; the window slides so a caller's budget /// refills as old charges age out. #[derive(Debug, Clone, Copy)] -pub struct PoolQuota { +pub struct SubmitQuota { /// Maximum charges a single caller may accrue within `window`. pub max_charges: u32, /// Sliding window the charges are counted across. pub window: Duration, } -impl PoolQuota { +impl SubmitQuota { /// Pair a budget with the window it is counted over. pub const fn new(max_charges: u32, window: Duration) -> Self { Self { @@ -64,30 +96,37 @@ impl PoolQuota { } } -impl Default for PoolQuota { +impl Default for SubmitQuota { fn default() -> Self { Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) } } -/// The guard interposition seam. The router runs this on the adapter-derived -/// header after `derive-header` and before `submit`. The shipped policy is a -/// no-op that allows every egress; the egress-guard epic replaces the -/// installed policy with the real facts-plus-analysers pipeline without the -/// router changing shape. -pub trait GuardPolicy: Send + Sync { +/// The guard interposition seam. The registry runs this on the +/// adapter-derived header after `derive-header` and before `submit`. The +/// shipped policy is the unit guard, which allows every egress; the +/// egress-guard epic replaces the installed policy with the real +/// facts-plus-analysers pipeline without the registry changing shape. +pub trait EgressGuard: Send + Sync { /// Decide whether the derived header may proceed to the adapter's submit. fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; } +/// The unit guard: allow every egress. +impl EgressGuard for () { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Allow + } +} + /// What the guard sees: who is submitting, to which venue, and the header the /// adapter derived from the opaque body. The header is the stable ontology /// policy has teeth on; the raw body never reaches the guard. pub struct GuardContext<'a> { /// Namespace of the calling module. pub caller: &'a str, - /// Venue id the submission is routed to. - pub venue: &'a str, + /// Venue the submission is routed to. + pub venue: &'a VenueId, /// Adapter-derived header for the body. pub header: &'a IntentHeader, } @@ -100,24 +139,15 @@ pub enum GuardVerdict { Deny(String), } -/// The shipped no-op policy: allow every egress. Named so the composition -/// root reads plainly and the egress-guard epic has an obvious thing to swap. -pub struct AllowAllGuard; - -impl GuardPolicy for AllowAllGuard { - fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { - GuardVerdict::Allow - } -} - /// The per-adapter invocation seam. One installed adapter answers for exactly -/// one venue; the router owns the adapter's `Store` behind an async mutex and -/// reaches it only through this trait, so the router's sequencing and quota -/// logic is testable against a stub that never spins up a wasmtime store. +/// one venue; the registry owns the adapter's `Store` behind an async mutex +/// and reaches it only through this trait, so the registry's sequencing and +/// quota logic is testable against a stub that never spins up a wasmtime +/// store. /// -/// The futures are boxed so the router can hold heterogeneous adapters behind -/// one `dyn` slot without the whole router turning generic over an adapter -/// type it never names. +/// The futures are boxed so the registry can hold heterogeneous adapters +/// behind one `dyn` slot without the whole registry turning generic over an +/// adapter type it never names. pub trait VenueInvoker: Send { /// Project the opaque body onto the stable header the guard runs on. fn derive_header<'a>( @@ -139,16 +169,16 @@ pub trait VenueInvoker: Send { /// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` /// bindings, refuelled before each guest call. A trap is projected onto -/// `internal-error` rather than propagated: a misbehaving adapter must not be -/// the caller's fault, and it must not unwind through the router into the +/// `unavailable` rather than propagated: a misbehaving adapter must not be +/// the caller's fault, and it must not unwind through the registry into the /// calling module's store. -pub struct AdapterActor { +pub struct VenueActor { store: Store>, bindings: VenueAdapter, fuel_per_call: u64, } -impl AdapterActor { +impl VenueActor { /// Wrap an instantiated adapter store for routing. pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { Self { @@ -163,7 +193,7 @@ impl AdapterActor { fn refuel(&mut self) -> Result<(), VenueError> { self.store .set_fuel(self.fuel_per_call) - .map_err(|e| VenueError::InternalError(format!("adapter refuel failed: {e}"))) + .map_err(|e| VenueError::Unavailable(format!("adapter refuel failed: {e}"))) } } @@ -171,10 +201,10 @@ impl AdapterActor { /// carried so an operator sees why the adapter died without the wasm frame /// list leaking to the calling module. fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { - VenueError::InternalError(format!("adapter trapped: {}", trap.root_cause())) + VenueError::Unavailable(format!("adapter trapped: {}", trap.root_cause())) } -impl VenueInvoker for AdapterActor { +impl VenueInvoker for VenueActor { fn derive_header<'a>( &'a mut self, body: &'a [u8], @@ -183,7 +213,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_derive_header(&mut self.store, body) .await { @@ -201,7 +231,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_submit(&mut self.store, body) .await { @@ -216,7 +246,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_status(&mut self.store, &receipt) .await { @@ -231,7 +261,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_cancel(&mut self.store, &receipt) .await { @@ -251,86 +281,74 @@ struct QuotaLedger { per_caller: HashMap>, } -/// One receipt the router polls for status transitions. `last` starts +/// One receipt the registry polls for status transitions. `last` starts /// `None` so the first successful poll always reports, giving a /// subscriber the intent's current state without waiting for a change. struct WatchedIntent { - venue: String, + venue: VenueId, receipt: Vec, last: Option, } /// A polled status is terminal when the intent can never change again: -/// the router stops watching the receipt after reporting it. -fn is_terminal(status: &IntentStatus) -> bool { +/// the registry stops watching the receipt after reporting it. +fn is_terminal(status: IntentStatus) -> bool { matches!( status, - IntentStatus::Settled(_) - | IntentStatus::Failed(_) - | IntentStatus::Expired - | IntentStatus::Cancelled + IntentStatus::Fulfilled | IntentStatus::Cancelled | IntentStatus::Expired ) } -/// Lower an adapter-reported status onto the opaque status body the host -/// `event` stream carries: `settled` is `fulfilled` plus its proof, and -/// `failed` is `cancelled` plus its reason (the body's lifecycle enum has -/// no failed case). -fn status_body(status: &IntentStatus) -> StatusBody { +/// Lower a polled status onto the opaque status body the host `event` +/// stream carries. The registry attests the lifecycle state alone; proof +/// and failure reason ride the body only when the venue supplies them. +fn status_body(status: IntentStatus) -> StatusBody { use nexum_status_body::IntentStatus as Lifecycle; - let (status, proof, reason) = match status { - IntentStatus::Pending => (Lifecycle::Pending, None, None), - IntentStatus::Open => (Lifecycle::Open, None, None), - IntentStatus::Settled(proof) => (Lifecycle::Fulfilled, proof.clone(), None), - IntentStatus::Failed(reason) => ( - Lifecycle::Cancelled, - None, - Some(nexum_status_body::FailReason { - code: reason.code.clone(), - detail: reason.detail.clone(), - }), - ), - IntentStatus::Expired => (Lifecycle::Expired, None, None), - IntentStatus::Cancelled => (Lifecycle::Cancelled, None, None), + let status = match status { + IntentStatus::Pending => Lifecycle::Pending, + IntentStatus::Open => Lifecycle::Open, + IntentStatus::Fulfilled => Lifecycle::Fulfilled, + IntentStatus::Cancelled => Lifecycle::Cancelled, + IntentStatus::Expired => Lifecycle::Expired, }; StatusBody { status, - proof, - reason, + proof: None, + reason: None, } } -/// The shared router state. Cloning a [`PoolRouter`] is an `Arc` bump; every -/// module store carries the same handle, so a submission from any module -/// reaches the same adapters and the same quota ledger. -struct PoolRouterInner { - adapters: HashMap, - guard: Arc, - quota: PoolQuota, +/// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; +/// every module store carries the same handle, so a submission from any +/// module reaches the same adapters and the same quota ledger. +struct VenueRegistryInner { + adapters: HashMap, + guard: Arc, + quota: SubmitQuota, ledger: Mutex, /// Receipts under status watch, appended by accepted submissions and /// pruned as they reach a terminal status. watched: Mutex>, } -/// The strategy-facing pool router, cheap to clone and shared across every +/// The keeper-facing venue registry, cheap to clone and shared across every /// module store. #[derive(Clone)] -pub struct PoolRouter { - inner: Arc, +pub struct VenueRegistry { + inner: Arc, } -impl PoolRouter { - /// An empty router: no adapters, the no-op guard, the default quota. This - /// is what an adapter store (which cannot call pool) and the single-module - /// `just run` path carry. +impl VenueRegistry { + /// An empty registry: no adapters, the unit guard, the default quota. + /// This is what an adapter store (which cannot call the client face) and + /// the single-module `just run` path carry. pub fn empty() -> Self { - PoolRouterBuilder::new(PoolQuota::default()).build() + VenueRegistryBuilder::new(SubmitQuota::default()).build() } /// Resolve a venue id to its installed adapter slot. - fn resolve(&self, venue: &str) -> Result { + fn resolve(&self, venue: &VenueId) -> Result { self.inner .adapters .get(venue) @@ -372,16 +390,17 @@ impl PoolRouter { pub async fn submit( &self, caller: &str, - venue: &str, + venue: &VenueId, body: Vec, ) -> Result { let slot = self.resolve(venue)?; // Gate before touching the adapter so a quota-exhausted caller never - // reaches the adapter store or its mutex. + // reaches the adapter store or its mutex. Exhaustion is retryable + // once the window slides, so it is rate-limited, never denied. if !self.quota_admits(caller) { - return Err(VenueError::Denied(format!( - "submission quota exhausted for caller {caller}" - ))); + return Err(VenueError::RateLimited(RateLimit { + retry_after_ms: Some(window_ms(self.inner.quota.window)), + })); } let mut adapter = slot.lock().await; let header = match adapter.derive_header(&body).await { @@ -416,16 +435,16 @@ impl PoolRouter { /// Put a `(venue, receipt)` pair under status watch. Idempotent: a /// re-submitted receipt keeps its existing watch entry. - fn watch(&self, venue: &str, receipt: Vec) { + fn watch(&self, venue: &VenueId, receipt: Vec) { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); if watched .iter() - .any(|w| w.venue == venue && w.receipt == receipt) + .any(|w| w.venue == *venue && w.receipt == receipt) { return; } watched.push(WatchedIntent { - venue: venue.to_owned(), + venue: venue.clone(), receipt, last: None, }); @@ -444,12 +463,11 @@ impl PoolRouter { /// return the transitions: statuses that differ from the last one /// reported for that receipt (the first successful poll always /// reports). A terminal status is reported once and the receipt is - /// dropped from the watch; a transport failure leaves the entry - /// untouched for the next cadence, except `invalid-receipt`, which - /// means the venue disowns the receipt, so watching is pointless. + /// dropped from the watch; a failure leaves the entry untouched for + /// the next cadence. pub async fn poll_status_transitions(&self) -> Vec { // Snapshot so the std mutex is never held across the guest await. - let snapshot: Vec<(String, Vec)> = { + let snapshot: Vec<(VenueId, Vec)> = { let watched = self.inner.watched.lock().expect("watch list poisoned"); watched .iter() @@ -458,7 +476,7 @@ impl PoolRouter { }; let mut updates = Vec::new(); for (venue, receipt) in snapshot { - // Installed adapters never leave the router, so a resolve + // Installed adapters never leave the registry, so a resolve // failure here is unreachable; skip defensively regardless. let Ok(slot) = self.resolve(&venue) else { continue; @@ -473,10 +491,6 @@ impl PoolRouter { updates.push(update); } } - Err(VenueError::InvalidReceipt) => { - warn!(venue = %venue, "venue disowns a watched receipt - dropping it"); - self.unwatch(&venue, &receipt); - } Err(err) => { warn!( venue = %venue, @@ -497,19 +511,19 @@ impl PoolRouter { /// cadence). fn record_polled_status( &self, - venue: &str, + venue: &VenueId, receipt: &[u8], status: IntentStatus, ) -> Option { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); let pos = watched .iter() - .position(|w| w.venue == venue && w.receipt == receipt)?; - let changed = watched[pos].last.as_ref() != Some(&status); + .position(|w| w.venue == *venue && w.receipt == receipt)?; + let changed = watched[pos].last != Some(status); let update = if changed { - match status_body(&status).encode() { + match status_body(status).encode() { Ok(body) => Some(IntentStatusUpdate { - venue: venue.to_owned(), + venue: venue.as_str().to_owned(), receipt: receipt.to_vec(), status: body, }), @@ -525,7 +539,7 @@ impl PoolRouter { } else { None }; - if is_terminal(&status) { + if is_terminal(status) { watched.remove(pos); } else { watched[pos].last = Some(status); @@ -533,15 +547,13 @@ impl PoolRouter { update } - /// Drop a `(venue, receipt)` pair from the status watch. - fn unwatch(&self, venue: &str, receipt: &[u8]) { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - watched.retain(|w| !(w.venue == venue && w.receipt == receipt)); - } - /// Report where a previously submitted intent is in its life. Not a /// submission: no header, no guard, no quota, just the serialised call. - pub async fn status(&self, venue: &str, receipt: Vec) -> Result { + pub async fn status( + &self, + venue: &VenueId, + receipt: Vec, + ) -> Result { let slot = self.resolve(venue)?; let mut adapter = slot.lock().await; adapter.status(receipt).await @@ -549,7 +561,7 @@ impl PoolRouter { /// Ask the venue to withdraw an intent. Not a submission, so it skips the /// header, guard, and quota like `status`. - pub async fn cancel(&self, venue: &str, receipt: Vec) -> Result<(), VenueError> { + pub async fn cancel(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { let slot = self.resolve(venue)?; let mut adapter = slot.lock().await; adapter.cancel(receipt).await @@ -561,6 +573,11 @@ impl PoolRouter { } } +/// A quota window as whole milliseconds, saturating at `u64::MAX`. +fn window_ms(window: Duration) -> u64 { + u64::try_from(window.as_millis()).unwrap_or(u64::MAX) +} + /// Drop charge timestamps that have aged out of the window. fn prune(history: &mut VecDeque, window: Duration) { let now = Instant::now(); @@ -573,29 +590,29 @@ fn prune(history: &mut VecDeque, window: Duration) { } } -/// Assembles a [`PoolRouter`]: adapters install first (at supervisor boot, -/// before any module store carries the built router), then the router -/// freezes. The guard defaults to the no-op [`AllowAllGuard`]; the -/// egress-guard epic overrides it here. -pub struct PoolRouterBuilder { - adapters: HashMap, - guard: Arc, - quota: PoolQuota, +/// Assembles a [`VenueRegistry`]: adapters install first (at supervisor +/// boot, before any module store carries the built registry), then the +/// registry freezes. The guard defaults to the unit guard; the egress-guard +/// epic overrides it here. +pub struct VenueRegistryBuilder { + adapters: HashMap, + guard: Arc, + quota: SubmitQuota, } -impl PoolRouterBuilder { - /// Start an empty builder with the given quota and the no-op guard. - pub fn new(quota: PoolQuota) -> Self { +impl VenueRegistryBuilder { + /// Start an empty builder with the given quota and the unit guard. + pub fn new(quota: SubmitQuota) -> Self { Self { adapters: HashMap::new(), - guard: Arc::new(AllowAllGuard), + guard: Arc::new(()), quota, } } /// Override the guard policy. The egress-guard epic wires the real /// pipeline through here; tests inject a denying policy to prove the seam. - pub fn with_guard(mut self, guard: Arc) -> Self { + pub fn with_guard(mut self, guard: Arc) -> Self { self.guard = guard; self } @@ -605,7 +622,7 @@ impl PoolRouterBuilder { /// which is a config error worth failing boot over. pub fn install( &mut self, - venue: String, + venue: VenueId, invoker: impl VenueInvoker + 'static, ) -> Result<(), DuplicateVenue> { if self.adapters.contains_key(&venue) { @@ -616,17 +633,17 @@ impl PoolRouterBuilder { Ok(()) } - /// Freeze the builder into a shared router. - pub fn build(self) -> PoolRouter { + /// Freeze the builder into a shared registry. + pub fn build(self) -> VenueRegistry { if self.quota.max_charges == 0 { - // A zero budget would deny every submission; saturate up to one so - // a misconfigured quota still admits a single submission rather + // A zero budget would refuse every submission; saturate up to one + // so a misconfigured quota still admits a single submission rather // than bricking every venue. Mirrors the poison-policy clamp. - warn!("pool submission quota max_charges is 0; clamping to 1"); + warn!("submission quota max_charges is 0; clamping to 1"); } - let quota = PoolQuota::new(self.quota.max_charges.max(1), self.quota.window); - PoolRouter { - inner: Arc::new(PoolRouterInner { + let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); + VenueRegistry { + inner: Arc::new(VenueRegistryInner { adapters: self.adapters, guard: self.guard, quota, @@ -639,10 +656,10 @@ impl PoolRouterBuilder { /// Two installed adapters claimed the same venue id. #[derive(Debug, thiserror::Error)] -#[error("venue id {venue:?} is claimed by more than one installed adapter")] +#[error("venue id {venue} is claimed by more than one installed adapter")] pub struct DuplicateVenue { /// The colliding venue id. - pub venue: String, + pub venue: VenueId, } #[cfg(test)] @@ -651,11 +668,16 @@ mod tests { use nexum_status_body::IntentStatus as Lifecycle; - use crate::bindings::value_flow::Settlement; - use crate::bindings::{AuthScheme, FailReason, IntentHeader, UnsignedTx}; + use crate::bindings::value_flow::{Asset, AssetAmount}; + use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; use super::*; + /// The venue id every test installs its stub adapter under. + fn cow() -> VenueId { + VenueId::from("cow") + } + /// Decode an update's opaque status body. fn decoded(update: &IntentStatusUpdate) -> StatusBody { StatusBody::decode(&update.status).expect("status body decodes") @@ -671,8 +693,8 @@ mod tests { } /// A programmable adapter that records call counts and returns canned - /// outcomes, so the router's sequencing, guard seam, and quota are tested - /// without a wasmtime store. + /// outcomes, so the registry's sequencing, guard seam, and quota are + /// tested without a wasmtime store. #[derive(Default)] struct StubCalls { derive: AtomicUsize, @@ -774,7 +796,7 @@ mod tests { /// A guard that refuses every egress with a fixed reason. struct DenyGuard; - impl GuardPolicy for DenyGuard { + impl EgressGuard for DenyGuard { fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { GuardVerdict::Deny("blocked by test policy".to_owned()) } @@ -782,36 +804,43 @@ mod tests { fn header() -> IntentHeader { IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), - authorisation: AuthScheme::Unsigned, + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, } } - fn router_with( - quota: PoolQuota, - guard: Option>, + fn registry_with( + quota: SubmitQuota, + guard: Option>, adapter: StubAdapter, - ) -> PoolRouter { - let mut builder = PoolRouterBuilder::new(quota); + ) -> VenueRegistry { + let mut builder = VenueRegistryBuilder::new(quota); if let Some(guard) = guard { builder = builder.with_guard(guard); } - builder - .install("cow".to_owned(), adapter) - .expect("install adapter"); + builder.install(cow(), adapter).expect("install adapter"); builder.build() } #[tokio::test] async fn submit_round_trips_through_derive_guard_submit() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); - let outcome = router - .submit("mod-a", "cow", b"body".to_vec()) + let outcome = registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); @@ -823,10 +852,14 @@ mod tests { #[tokio::test] async fn unknown_venue_is_rejected_without_touching_an_adapter() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); - let err = router - .submit("mod-a", "unlisted", b"body".to_vec()) + let err = registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) .await .expect_err("unknown venue rejected"); @@ -838,14 +871,14 @@ mod tests { #[tokio::test] async fn guard_deny_blocks_submit_after_deriving_the_header() { let calls = Arc::new(StubCalls::default()); - let router = router_with( - PoolQuota::default(), + let registry = registry_with( + SubmitQuota::default(), Some(Arc::new(DenyGuard)), StubAdapter::new(calls.clone()), ); - let err = router - .submit("mod-a", "cow", b"body".to_vec()) + let err = registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect_err("guard denies"); @@ -857,19 +890,34 @@ mod tests { } #[tokio::test] - async fn submission_quota_denies_once_the_budget_is_spent() { + async fn submission_quota_rate_limits_once_the_budget_is_spent() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(2, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); - assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); - let err = router - .submit("mod-a", "cow", b"b".to_vec()) + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + let err = registry + .submit("mod-a", &cow(), b"b".to_vec()) .await .expect_err("third submit over quota"); - assert!(matches!(err, VenueError::Denied(reason) if reason.contains("quota"))); + // Exhaustion is retryable once the window slides: rate-limited + // carrying the window, never denied. + assert!(matches!( + err, + VenueError::RateLimited(rl) if rl.retry_after_ms == Some(3_600_000) + )); // The over-quota call is stopped at the gate, so the adapter saw only // the two admitted submits. assert_eq!(calls.submit.load(Ordering::SeqCst), 2); @@ -878,17 +926,28 @@ mod tests { #[tokio::test] async fn quota_is_per_caller() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); assert!( - router.submit("mod-a", "cow", b"b".to_vec()).await.is_err(), + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_err(), "mod-a is over its own budget" ); // A different caller has its own budget. assert!( - router.submit("mod-b", "cow", b"b".to_vec()).await.is_ok(), + registry + .submit("mod-b", &cow(), b"b".to_vec()) + .await + .is_ok(), "mod-b has an independent budget" ); } @@ -896,18 +955,18 @@ mod tests { #[tokio::test] async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); let adapter = StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); - let router = router_with(quota, None, adapter); + let registry = registry_with(quota, None, adapter); // First garbage body: derive fails, the failure is charged. - let first = router.submit("mod-a", "cow", b"junk".to_vec()).await; + let first = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; assert!(matches!(first, Err(VenueError::InvalidBody(_)))); // Second: the charge from the decode failure exhausts the budget, so // the caller is stopped at the gate and the adapter is not re-invoked. - let second = router.submit("mod-a", "cow", b"junk".to_vec()).await; - assert!(matches!(second, Err(VenueError::Denied(_)))); + let second = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; + assert!(matches!(second, Err(VenueError::RateLimited(_)))); assert_eq!( calls.derive.load(Ordering::SeqCst), 1, @@ -918,19 +977,19 @@ mod tests { #[tokio::test] async fn non_decode_venue_errors_are_not_charged() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); let adapter = StubAdapter::new(calls.clone()) .with_derive(Err(VenueError::Unavailable("rpc down".into()))); - let router = router_with(quota, None, adapter); + let registry = registry_with(quota, None, adapter); assert!(matches!( - router.submit("mod-a", "cow", b"b".to_vec()).await, + registry.submit("mod-a", &cow(), b"b".to_vec()).await, Err(VenueError::Unavailable(_)) )); // A venue-side failure did not spend the caller's budget: it may try // again, so derive is reached a second time. assert!(matches!( - router.submit("mod-a", "cow", b"b".to_vec()).await, + registry.submit("mod-a", &cow(), b"b".to_vec()).await, Err(VenueError::Unavailable(_)) )); assert_eq!(calls.derive.load(Ordering::SeqCst), 2); @@ -941,14 +1000,14 @@ mod tests { let calls = Arc::new(StubCalls::default()); // A spent budget must not block reads: status and cancel are not // submissions. - let quota = PoolQuota::new(1, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); assert!(matches!( - router.status("cow", b"r".to_vec()).await, + registry.status(&cow(), b"r".to_vec()).await, Ok(IntentStatus::Open) )); - assert!(router.cancel("cow", b"r".to_vec()).await.is_ok()); + assert!(registry.cancel(&cow(), b"r".to_vec()).await.is_ok()); assert_eq!(calls.status.load(Ordering::SeqCst), 1); assert_eq!(calls.cancel.load(Ordering::SeqCst), 1); } @@ -956,14 +1015,14 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_calls_to_one_adapter_are_serialised() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1000, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(1000, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); let mut handles = Vec::new(); for _ in 0..8 { - let router = router.clone(); + let registry = registry.clone(); handles.push(tokio::spawn(async move { - let _ = router.submit("mod-a", "cow", b"b".to_vec()).await; + let _ = registry.submit("mod-a", &cow(), b"b".to_vec()).await; })); } for h in handles { @@ -976,22 +1035,23 @@ mod tests { #[test] fn duplicate_venue_id_is_rejected() { - let mut builder = PoolRouterBuilder::new(PoolQuota::default()); + let mut builder = VenueRegistryBuilder::new(SubmitQuota::default()); let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); builder - .install("cow".to_owned(), StubAdapter::new(a)) + .install(cow(), StubAdapter::new(a)) .expect("first install"); let err = builder - .install("cow".to_owned(), StubAdapter::new(b)) + .install(cow(), StubAdapter::new(b)) .expect_err("second install collides"); - assert_eq!(err.venue, "cow"); + assert_eq!(err.venue, cow()); } #[test] fn zero_quota_saturates_to_one() { - let router = PoolRouterBuilder::new(PoolQuota::new(0, Duration::from_secs(60))).build(); - assert_eq!(router.inner.quota.max_charges, 1); + let registry = + VenueRegistryBuilder::new(SubmitQuota::new(0, Duration::from_secs(60))).build(); + assert_eq!(registry.inner.quota.max_charges, 1); } // ── status watch + polling ──────────────────────────────────────── @@ -999,21 +1059,21 @@ mod tests { #[tokio::test] async fn accepted_submission_goes_under_status_watch() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls)); + let registry = registry_with(SubmitQuota::default(), None, StubAdapter::new(calls)); - assert_eq!(router.watched_count(), 0); - router - .submit("mod-a", "cow", b"body".to_vec()) + assert_eq!(registry.watched_count(), 0); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); - assert_eq!(router.watched_count(), 1); + assert_eq!(registry.watched_count(), 1); // Re-submitting the same receipt does not double-watch it. - router - .submit("mod-a", "cow", b"body".to_vec()) + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); - assert_eq!(router.watched_count(), 1); + assert_eq!(registry.watched_count(), 1); } #[tokio::test] @@ -1021,42 +1081,46 @@ mod tests { let calls = Arc::new(StubCalls::default()); let adapter = StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { - chain_id: 1, + chain: 1, to: vec![0u8; 20], value: Vec::new(), - input: Vec::new(), + data: Vec::new(), }))); - let router = router_with(PoolQuota::default(), None, adapter); + let registry = registry_with(SubmitQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); // No receipt exists yet, so there is nothing to poll. - assert_eq!(router.watched_count(), 0); - assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!(registry.watched_count(), 0); + assert!(registry.poll_status_transitions().await.is_empty()); } #[tokio::test] async fn poll_reports_the_first_status_then_dedupes_repeats() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); - router - .submit("mod-a", "cow", b"body".to_vec()) + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); // First poll: `last` is unset, so the current status reports. - let first = router.poll_status_transitions().await; + let first = registry.poll_status_transitions().await; assert_eq!(first.len(), 1); assert_eq!(first[0].venue, "cow"); assert_eq!(first[0].receipt, b"receipt"); assert_eq!(decoded(&first[0]), plain(Lifecycle::Open)); // Second poll: same status, nothing to report. - assert!(router.poll_status_transitions().await.is_empty()); + assert!(registry.poll_status_transitions().await.is_empty()); assert_eq!(calls.status.load(Ordering::SeqCst), 2); - assert_eq!(router.watched_count(), 1, "open is not terminal"); + assert_eq!(registry.watched_count(), 1, "open is not terminal"); } #[tokio::test] @@ -1066,17 +1130,17 @@ mod tests { Ok(IntentStatus::Pending), Ok(IntentStatus::Pending), Ok(IntentStatus::Open), - Ok(IntentStatus::Settled(Some(b"tx".to_vec()))), + Ok(IntentStatus::Fulfilled), ]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); let mut seen = Vec::new(); for _ in 0..4 { - seen.extend(router.poll_status_transitions().await); + seen.extend(registry.poll_status_transitions().await); } let statuses: Vec = seen.iter().map(decoded).collect(); assert_eq!( @@ -1084,17 +1148,13 @@ mod tests { vec![ plain(Lifecycle::Pending), plain(Lifecycle::Open), - StatusBody { - status: Lifecycle::Fulfilled, - proof: Some(b"tx".to_vec()), - reason: None, - }, + plain(Lifecycle::Fulfilled), ], "the repeated pending is deduplicated; each transition reports once", ); - assert_eq!(router.watched_count(), 0, "settled prunes the watch"); + assert_eq!(registry.watched_count(), 0, "fulfilled prunes the watch"); // A further poll has nothing left to ask the adapter about. - assert!(router.poll_status_transitions().await.is_empty()); + assert!(registry.poll_status_transitions().await.is_empty()); } #[tokio::test] @@ -1102,59 +1162,35 @@ mod tests { let calls = Arc::new(StubCalls::default()); let adapter = StubAdapter::new(calls) .with_status_script([Err(VenueError::Unavailable("venue down".into()))]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); - assert!(router.poll_status_transitions().await.is_empty()); + assert!(registry.poll_status_transitions().await.is_empty()); assert_eq!( - router.watched_count(), + registry.watched_count(), 1, "transient failure keeps the entry" ); // The venue recovered: the next poll reports the current status. - let updates = router.poll_status_transitions().await; + let updates = registry.poll_status_transitions().await; assert_eq!(updates.len(), 1); assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); } #[test] - fn failed_lowers_to_cancelled_plus_reason() { - let body = status_body(&IntentStatus::Failed(FailReason { - code: "oc".into(), - detail: "od".into(), - })); - assert_eq!( - body, - StatusBody { - status: Lifecycle::Cancelled, - proof: None, - reason: Some(nexum_status_body::FailReason { - code: "oc".into(), - detail: "od".into(), - }), - }, - ); - } - - #[tokio::test] - async fn disowned_receipt_is_dropped_from_the_watch() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls).with_status_script([Err(VenueError::InvalidReceipt)]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) - .await - .expect("submit succeeds"); - - assert!(router.poll_status_transitions().await.is_empty()); - assert_eq!( - router.watched_count(), - 0, - "a receipt the venue disowns is never polled again", - ); + fn every_lifecycle_state_lowers_onto_the_status_body() { + for (wire, lowered) in [ + (IntentStatus::Pending, Lifecycle::Pending), + (IntentStatus::Open, Lifecycle::Open), + (IntentStatus::Fulfilled, Lifecycle::Fulfilled), + (IntentStatus::Cancelled, Lifecycle::Cancelled), + (IntentStatus::Expired, Lifecycle::Expired), + ] { + assert_eq!(status_body(wire), plain(lowered)); + } } } diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 50108786..ef00fcef 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -39,17 +39,18 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Capability names under the `nexum:intent/` package a module may import. -/// Only the strategy-facing `pool` interface is a capability; the `types` -/// package is type-only and needs no declaration. -pub const INTENT_CAPABILITIES: &[&str] = &["pool"]; - -/// The intent namespace: the `nexum:intent/pool` import is linked into every -/// module linker, so a module that submits intents declares the `pool` -/// capability the same way it declares a `nexum:host/` one. -pub const INTENT_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "nexum:intent/", - ifaces: INTENT_CAPABILITIES, +/// Capability names under the `videre:venue/` package a module may import. +/// Only the keeper-facing `client` interface is a capability; the +/// `videre:types` and `videre:value-flow` packages are type-only and need +/// no declaration. +pub const VENUE_CAPABILITIES: &[&str] = &["client"]; + +/// The venue namespace: the `videre:venue/client` import is linked into +/// every module linker, so a module that submits intents declares the +/// `client` capability the same way it declares a `nexum:host/` one. +pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "videre:venue/", + ifaces: VENUE_CAPABILITIES, }; /// The interfaces a `venue-adapter` world links: the scoped transport @@ -127,10 +128,10 @@ impl Default for CapabilityRegistry { impl CapabilityRegistry { /// The registry with the core `nexum:host/` namespace plus the - /// strategy-facing `nexum:intent/pool` import every module linker carries. + /// keeper-facing `videre:venue/client` import every module linker carries. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE, INTENT_NAMESPACE], + namespaces: vec![CORE_NAMESPACE, VENUE_NAMESPACE], } } @@ -327,12 +328,17 @@ mod tests { } #[test] - fn intent_pool_is_a_core_capability_but_intent_types_is_not() { + fn venue_client_is_a_core_capability_but_videre_types_is_not() { let r = CapabilityRegistry::core(); - assert_eq!(r.wit_import_to_cap("nexum:intent/pool@0.1.0"), Some("pool")); - assert!(r.is_known("pool")); - // The type-only interface is not a capability and needs no declaration. - assert_eq!(r.wit_import_to_cap("nexum:intent/types@0.1.0"), None); + assert_eq!( + r.wit_import_to_cap("videre:venue/client@0.1.0"), + Some("client") + ); + assert!(r.is_known("client")); + // The type-only interfaces are not capabilities and need no + // declaration. + assert_eq!(r.wit_import_to_cap("videre:types/types@0.1.0"), None); + assert_eq!(r.wit_import_to_cap("videre:value-flow/types@0.1.0"), None); } #[test] diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index dd6fa885..a63526df 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,8 +40,8 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; -use crate::host::pool_router::PoolRouter; use crate::host::provider_pool::ProviderError; +use crate::host::venue_registry::VenueRegistry; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; @@ -147,32 +147,32 @@ where streams } -/// Router-driven intent status polling: one task that, on every cadence +/// Registry-driven intent status polling: one task that, on every cadence /// tick, polls each installed adapter's status export through the shared -/// [`PoolRouter`] and forwards the observed transitions. The task is +/// [`VenueRegistry`] and forwards the observed transitions. The task is /// spawned via `executor` into `tasks` like the reconnect tasks and exits /// cleanly when the loop's receiver drops. pub fn open_intent_status_stream( - router: PoolRouter, + registry: VenueRegistry, cadence: Duration, executor: &TaskExecutor, tasks: &mut TaskSet, ) -> IntentStatusStream { let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); - tasks.push(executor.spawn(Box::pin(status_poll_task(router, cadence, tx)))); + tasks.push(executor.spawn(Box::pin(status_poll_task(registry, cadence, tx)))); Box::pin(receiver_stream(rx)) } /// Poll loop behind [`open_intent_status_stream`]. Sleeps the cadence /// first so the engine's boot dispatch settles before the first poll. async fn status_poll_task( - router: PoolRouter, + registry: VenueRegistry, cadence: Duration, tx: mpsc::Sender, ) -> TaskExit { loop { tokio::time::sleep(cadence).await; - for update in router.poll_status_transitions().await { + for update in registry.poll_status_transitions().await { if tx.send(update).await.is_err() { // Receiver dropped -> engine shutting down. return TaskExit::ReceiverGone; diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 78390678..363b0cc9 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -48,10 +48,10 @@ use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; -use crate::host::pool_router::{AdapterActor, PoolRouter, PoolRouterBuilder}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; +use crate::host::venue_registry::{VenueActor, VenueId, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ self, CapabilityRegistry, LoadedManifest, ModuleKind, ResourceSection, Subscription, }; @@ -61,13 +61,13 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// The intent pool router: every installed venue adapter's serialising + /// The venue registry: every installed venue adapter's serialising /// store, keyed by venue id. Cached so a module restart rebuilds a store /// carrying the same shared handle. Adapters boot through the same store, /// fuel, and memory machinery as modules but carry no subscriptions: - /// modules reach them through this router, not through dispatch. Folding + /// modules reach them through this registry, not through dispatch. Folding /// adapters into the restart and poison sweeps is still a later change. - pool_router: PoolRouter, + venue_registry: VenueRegistry, /// Venue adapters loaded at boot, whether or not `init` succeeded. adapters_total: usize, /// Adapters whose `init` succeeded and that are installed for routing. @@ -254,16 +254,16 @@ struct LoadedModule { } /// A venue adapter instantiated into a supervised store, ready to install in -/// the pool router. It boots through the same store, fuel, and memory +/// the venue registry. It boots through the same store, fuel, and memory /// machinery as a module but carries no subscriptions: modules reach it -/// through the router, not through dispatch. Adapter restart and poison +/// through the registry, not through dispatch. Adapter restart and poison /// handling are still a later change; an `init` failure leaves `alive` false /// so the adapter is loaded but not routable. struct LoadedAdapter { /// Venue id the adapter answers for (its manifest name). - venue_id: String, - /// The refuelable adapter store, ready to serialise behind a router mutex. - actor: AdapterActor, + venue_id: VenueId, + /// The refuelable adapter store, ready to serialise behind a registry mutex. + actor: VenueActor, /// Whether `init` succeeded; a failed adapter is not installed for routing. alive: bool, } @@ -281,14 +281,15 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - // Adapters instantiate first: the pool router must contain them before - // any module store (which carries the built router) is built. Adapters - // link only their scoped transport, against a dedicated linker built - // from the same core backends, and their own stores carry an empty - // router since an adapter cannot call pool. + // Adapters instantiate first: the venue registry must contain them + // before any module store (which carries the built registry) is + // built. Adapters link only their scoped transport, against a + // dedicated linker built from the same core backends, and their own + // stores carry an empty registry since an adapter cannot call the + // client face. let adapter_linker = build_adapter_linker::(engine)?; let adapter_registry = CapabilityRegistry::adapter(); - let mut router_builder = PoolRouterBuilder::new(engine_cfg.limits.quota()); + let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { @@ -305,7 +306,7 @@ impl Supervisor { .with_context(|| format!("load adapter {}", entry.path.display()))?; if loaded.alive { adapters_alive += 1; - router_builder + registry_builder .install(loaded.venue_id.clone(), loaded.actor) .with_context(|| format!("install adapter {}", loaded.venue_id))?; } else { @@ -315,7 +316,7 @@ impl Supervisor { ); } } - let pool_router = router_builder.build(); + let venue_registry = registry_builder.build(); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { @@ -327,7 +328,7 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), - pool_router.clone(), + venue_registry.clone(), ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -343,7 +344,7 @@ impl Supervisor { ); Ok(Self { modules, - pool_router, + venue_registry, adapters_total, adapters_alive, engine: engine.clone(), @@ -377,9 +378,9 @@ impl Supervisor { manifest: manifest.map(Path::to_path_buf), }; // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so the router is empty here and - // every pool call resolves to `unknown-venue`. - let pool_router = PoolRouter::empty(); + // configured through `engine.toml`, so the registry is empty here and + // every client call resolves to `unknown-venue`. + let venue_registry = VenueRegistry::empty(); let loaded = Self::load_one( engine, linker, @@ -388,12 +389,12 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), - pool_router.clone(), + venue_registry.clone(), ) .await?; Ok(Self { modules: vec![loaded], - pool_router, + venue_registry, adapters_total: 0, adapters_alive: 0, engine: engine.clone(), @@ -423,7 +424,7 @@ impl Supervisor { chain_response_max_bytes: usize, state_quota: u64, clocks: Option<&WasiClockOverride>, - pool_router: PoolRouter, + venue_registry: VenueRegistry, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -481,7 +482,7 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, - pool_router, + venue_registry, }, ); store.limiter(|state| &mut state.limits); @@ -490,7 +491,7 @@ impl Supervisor { } // One flat argument per shared input threaded onto the store, plus the - // pool router the module's `nexum:intent/pool` import dispatches to. + // venue registry the module's `videre:venue/client` import dispatches to. #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, @@ -500,7 +501,7 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - pool_router: PoolRouter, + venue_registry: VenueRegistry, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -565,7 +566,7 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), state_bytes, clocks, - pool_router, + venue_registry, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -661,7 +662,7 @@ impl Supervisor { /// capability set, build a supervised store carrying the operator's /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings /// against the adapter linker, and run `init`. Nothing dispatches to - /// the result yet; it boots so the router can later reach it. + /// the result yet; it boots so the registry can later reach it. async fn load_adapter( engine: &Engine, linker: &Linker>, @@ -732,9 +733,10 @@ impl Supervisor { ); let run = RunId::new(adapter_namespace.clone(), 0); - // An adapter store cannot call pool, so it carries an empty router; - // this also keeps the real router out of the adapter's `HostState`, - // so there is no reference cycle back into the router that owns it. + // An adapter store cannot call the client face, so it carries an + // empty registry; this also keeps the real registry out of the + // adapter's `HostState`, so there is no reference cycle back into + // the registry that owns it. let mut store = Self::build_store( engine, components, @@ -747,7 +749,7 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), limits_cfg.state_bytes(), clocks, - PoolRouter::empty(), + VenueRegistry::empty(), )?; let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) .await @@ -782,8 +784,8 @@ impl Supervisor { store.set_fuel(limits_cfg.fuel())?; Ok(LoadedAdapter { - venue_id: adapter_namespace, - actor: AdapterActor::new(store, bindings, limits_cfg.fuel()), + venue_id: VenueId::from(adapter_namespace), + actor: VenueActor::new(store, bindings, limits_cfg.fuel()), alive: init_succeeded, }) } @@ -799,7 +801,7 @@ impl Supervisor { } /// Number of adapters whose `init` succeeded and that are installed in the - /// pool router for routing. + /// venue registry for routing. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { self.adapters_alive @@ -914,10 +916,10 @@ impl Supervisor { let linker = build_linker::(&self.engine, &self.extensions)?; // Borrowed before the `&mut self.modules[idx]` reborrow so the restart - // path applies the same clock override and the same shared pool router + // path applies the same clock override and the same shared registry // as the initial boot. let clocks = self.clocks.clone(); - let pool_router = self.pool_router.clone(); + let venue_registry = self.venue_registry.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -934,7 +936,7 @@ impl Supervisor { module.chain_response_max_bytes, module.local_store_bytes, clocks.as_ref(), - pool_router, + venue_registry, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -1126,7 +1128,7 @@ impl Supervisor { ok } - /// Dispatch a router-observed intent status transition to every module + /// Dispatch a registry-observed intent status transition to every module /// subscribed to `intent-status` events whose venue filter admits the /// update's venue. Returns the number of modules invoked. Mirrors /// `dispatch_block`: dead modules past their backoff are restarted @@ -1187,9 +1189,9 @@ impl Supervisor { }) } - /// The shared intent pool router carried by every module store. - pub fn pool_router(&self) -> PoolRouter { - self.pool_router.clone() + /// The shared venue registry carried by every module store. + pub fn venue_registry(&self) -> VenueRegistry { + self.venue_registry.clone() } /// Shared per-module dispatch path: refuel, call `on_event`, and @@ -1423,10 +1425,10 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; - // The intent pool import is linked into every module linker; it dispatches - // to the shared router carried in each store's `HostState`. Modules that do - // not import it are unaffected. - crate::bindings::pool::add_to_linker::, HasSelf>>( + // The venue client import is linked into every module linker; it + // dispatches to the shared registry carried in each store's `HostState`. + // Modules that do not import it are unaffected. + crate::bindings::client::add_to_linker::, HasSelf>>( &mut linker, |state| state, )?; diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index ec5da908..c71f43db 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -542,7 +542,7 @@ chain_id = 1 // ── intent-status subscription E2E ──────────────────────────────────── -/// A scripted venue adapter for the router: accepts every submission with +/// A scripted venue adapter for the registry: accepts every submission with /// a fixed receipt and serves statuses front-first from a script, falling /// back to `open` once drained. struct ScriptedAdapter { @@ -557,7 +557,7 @@ impl ScriptedAdapter { } } -impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { +impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { fn derive_header<'a>( &'a mut self, _body: &'a [u8], @@ -567,11 +567,16 @@ impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { > { Box::pin(async move { Ok(crate::bindings::IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: crate::bindings::value_flow::Settlement::EvmChain(1), - authorisation: crate::bindings::AuthScheme::Unsigned, + gives: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: vec![1], + }, + wants: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: Vec::new(), + }, + settlement: crate::bindings::Settlement { chain: 1 }, + authorisation: crate::bindings::AuthScheme::Eip712, }) }) } @@ -613,12 +618,14 @@ impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { } } -/// Build a router with one scripted adapter installed under `cow`. -fn scripted_router(adapter: ScriptedAdapter) -> crate::host::pool_router::PoolRouter { - let mut builder = crate::host::pool_router::PoolRouterBuilder::new( - crate::host::pool_router::PoolQuota::default(), +/// Build a registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { + let mut builder = crate::host::venue_registry::VenueRegistryBuilder::new( + crate::host::venue_registry::SubmitQuota::default(), ); - builder.install("cow".to_owned(), adapter).expect("install"); + builder + .install(crate::host::venue_registry::VenueId::from("cow"), adapter) + .expect("install"); builder.build() } @@ -645,7 +652,7 @@ venue = "cow" } /// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the router observed by polling the adapter's status +/// the transitions the registry observed by polling the adapter's status /// export, and a transition from a venue outside its filter is not /// delivered. #[tokio::test] @@ -678,24 +685,28 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { .expect("boot_single"); assert!(supervisor.has_intent_status_subscribers()); - // The router watches the receipt of an accepted submission and polls + // The registry watches the receipt of an accepted submission and polls // the adapter's status export; each poll here observes a transition. - let router = scripted_router(ScriptedAdapter::new([ + let registry = scripted_registry(ScriptedAdapter::new([ IntentStatus::Pending, - IntentStatus::Settled(None), + IntentStatus::Fulfilled, ])); - router - .submit("test-caller", "cow", b"body".to_vec()) + registry + .submit( + "test-caller", + &crate::host::venue_registry::VenueId::from("cow"), + b"body".to_vec(), + ) .await .expect("submit"); let mut delivered = 0; for _ in 0..2 { - for update in router.poll_status_transitions().await { + for update in registry.poll_status_transitions().await { delivered += supervisor.dispatch_intent_status(update).await; } } - assert_eq!(delivered, 2, "pending then settled, one subscriber each"); + assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); // A venue outside the module's filter is not delivered. @@ -747,9 +758,13 @@ async fn e2e_intent_status_flows_through_the_event_loop() { .await .expect("boot_single"); - let router = scripted_router(ScriptedAdapter::new([])); - router - .submit("test-caller", "cow", b"body".to_vec()) + let registry = scripted_registry(ScriptedAdapter::new([])); + registry + .submit( + "test-caller", + &crate::host::venue_registry::VenueId::from("cow"), + b"body".to_vec(), + ) .await .expect("submit"); @@ -757,7 +772,7 @@ async fn e2e_intent_status_flows_through_the_event_loop() { let executor = manager.executor(); let mut tasks = TaskSet::new(); let stream = crate::runtime::event_loop::open_intent_status_stream( - router, + registry, Duration::from_millis(10), &executor, &mut tasks, @@ -789,13 +804,14 @@ async fn e2e_intent_status_flows_through_the_event_loop() { } /// The first-train acceptance path, end to end over two real components: -/// the echo-client module submits through `nexum:intent/pool`, the host -/// router forwards to the installed echo-venue adapter, and the module -/// receives the settled `intent-status` the router polls back. Proves the -/// intent core round-trips module -> host router -> venue adapter with no +/// the echo-client module submits through `videre:venue/client`, the host +/// registry forwards to the installed echo-venue adapter, and the module +/// receives the fulfilled `intent-status` the registry polls back. Proves +/// the intent core round-trips module -> host registry -> venue adapter +/// with no /// scripted stand-ins on either side. #[tokio::test] -async fn e2e_echo_module_router_adapter_round_trip() { +async fn e2e_echo_module_registry_adapter_round_trip() { use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; use crate::host::component::ChainMethod; use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; @@ -843,7 +859,7 @@ async fn e2e_echo_module_router_adapter_round_trip() { assert!(supervisor.has_intent_status_subscribers()); // A block drives the module's on_block, which submits to the echo venue - // through the shared pool router; the router watches the accepted receipt. + // through the shared registry; the registry watches the accepted receipt. let block = nexum::host::types::Block { chain_id: 1, number: 19_000_000, @@ -852,13 +868,13 @@ async fn e2e_echo_module_router_adapter_round_trip() { }; assert_eq!(supervisor.dispatch_block(block).await, 1); - // Poll the router the module submitted through and fan its transitions + // Poll the registry the module submitted through and fan its transitions // back to the module. echo-venue settles instantly, so the first poll // reports a terminal status and the watch is pruned. - let router = supervisor.pool_router(); + let registry = supervisor.venue_registry(); let mut delivered = 0; for _ in 0..2 { - for update in router.poll_status_transitions().await { + for update in registry.poll_status_transitions().await { assert_eq!(update.venue, "echo-venue"); let body = nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); @@ -886,7 +902,7 @@ async fn e2e_echo_module_router_adapter_round_trip() { messages .iter() .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the pool; records were: {messages:?}", + "module submitted through the client face; records were: {messages:?}", ); assert!( messages diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 58fc8ffc..f711ea96 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -2,7 +2,7 @@ //! it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the four intent functions from `nexum:intent/adapter`. +//! world itself, the four intent functions from `videre:venue/adapter`. //! Functions are associated (no `self`): the component model instantiates //! one adapter per venue and calls exports statically, so adapter state //! lives in the adapter's own statics, exactly as in event modules. @@ -62,7 +62,7 @@ macro_rules! export_venue_adapter { } } - impl $crate::bindings::exports::nexum::intent::adapter::Guest + impl $crate::bindings::exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { fn derive_header( diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs index 3e88fff2..f40e1585 100644 --- a/crates/nexum-venue-sdk/src/bindings.rs +++ b/crates/nexum-venue-sdk/src/bindings.rs @@ -1,4 +1,4 @@ -//! Guest bindings for the `nexum:adapter/venue-adapter` world. +//! Guest bindings for the `videre:venue/venue-adapter` world. //! //! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, //! the venue SDK generates the adapter world's bindings once, here: the @@ -7,17 +7,17 @@ //! types, and [`export_venue_adapter!`](crate::export_venue_adapter) //! emits the component export glue into the adapter's own cdylib via the //! generated (hidden) export macro. Downstream bindgens wanting type -//! identity with this crate remap `nexum:intent/types` and -//! `nexum:value-flow/types` onto these modules with `with`. +//! identity with this crate remap `videre:types/types` and +//! `videre:value-flow/types` onto these modules with `with`. wit_bindgen::generate!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", + "../../wit/videre-value-flow", + "../../wit/videre-types", "../../wit/nexum-host", - "../../wit/nexum-adapter", + "../../wit/videre-venue", ], - world: "nexum:adapter/venue-adapter", + world: "videre:venue/venue-adapter", generate_all, pub_export_macro: true, export_macro_name: "__export_venue_adapter_world", diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs index d542aca2..1f202ce2 100644 --- a/crates/nexum-venue-sdk/src/body.rs +++ b/crates/nexum-venue-sdk/src/body.rs @@ -72,16 +72,15 @@ pub enum BodyError { } /// Fold a codec failure into the wire error an adapter returns: decode -/// failures are the caller's malformed body (`invalid-body`, whose WIT -/// contract names exactly these two causes), an encode failure is the -/// adapter's own bug (`internal-error`). +/// failures are the caller's malformed body (`invalid-body`); an encode +/// failure is the adapter's own bug, reported retryable (`unavailable`). impl From for VenueError { fn from(err: BodyError) -> Self { match err { BodyError::Empty | BodyError::UnknownVersion { .. } | BodyError::Malformed { .. } => { VenueError::InvalidBody(err.to_string()) } - BodyError::Encode { .. } => VenueError::InternalError(err.to_string()), + BodyError::Encode { .. } => VenueError::Unavailable(err.to_string()), } } } diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs index edfc8dd7..5048eda1 100644 --- a/crates/nexum-venue-sdk/src/client.rs +++ b/crates/nexum-venue-sdk/src/client.rs @@ -1,12 +1,12 @@ //! The typed intent client core: [`IntentClient`] over the byte-level -//! [`IntentPool`] seam. +//! [`VenueClient`] seam. //! -//! The pool boundary carries opaque bodies; this module is where a +//! The client boundary carries opaque bodies; this module is where a //! typed body meets it. [`IntentClient`] binds one venue and encodes -//! through [`IntentBody`] before submission, so strategy code never +//! through [`IntentBody`] before submission, so keeper code never //! handles wire bytes. The seam is byte-level on purpose: the -//! strategy-module SDK implements [`IntentPool`] over its own -//! `nexum:intent/pool` import shims, tests implement it in memory +//! strategy-module SDK implements [`VenueClient`] over its own +//! `videre:venue/client` import shims, tests implement it in memory //! (an in-process adapter works directly), and the typed layer above is //! shared by both. @@ -14,9 +14,9 @@ use strum::IntoStaticStr; use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; -/// Byte-level access to the strategy-facing `nexum:intent/pool` +/// Byte-level access to the keeper-facing `videre:venue/client` /// interface, venue named per call as on the wire. -pub trait IntentPool { +pub trait VenueClient { /// Submit an opaque intent body to the named venue. fn submit(&self, venue: &str, body: Vec) -> Result; @@ -30,18 +30,18 @@ pub trait IntentPool { } /// A typed intent client bound to one venue: encodes an [`IntentBody`] -/// to wire bytes and forwards through the [`IntentPool`] seam. +/// to wire bytes and forwards through the [`VenueClient`] seam. #[derive(Clone, Debug)] pub struct IntentClient

{ - pool: P, + venues: P, venue: String, } -impl IntentClient

{ - /// Bind a pool handle to the venue id the router resolves. - pub fn new(pool: P, venue: impl Into) -> Self { +impl IntentClient

{ + /// Bind a client handle to the venue id the registry resolves. + pub fn new(venues: P, venue: impl Into) -> Self { Self { - pool, + venues, venue: venue.into(), } } @@ -54,39 +54,39 @@ impl IntentClient

{ /// Encode a typed body and submit it to the bound venue. pub fn submit(&self, body: &B) -> Result { let bytes = body.to_bytes()?; - self.pool + self.venues .submit(&self.venue, bytes) .map_err(ClientError::Venue) } /// Report where a previously submitted intent is in its life. pub fn status(&self, receipt: &[u8]) -> Result { - self.pool + self.venues .status(&self.venue, receipt) .map_err(ClientError::Venue) } /// Ask the bound venue to withdraw an intent. pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - self.pool + self.venues .cancel(&self.venue, receipt) .map_err(ClientError::Venue) } } /// Why a typed intent call failed: before the wire (the body failed to -/// encode) or beyond it (the pool or venue refused). +/// encode) or beyond it (the registry or venue refused). /// /// `IntoStaticStr` yields a snake_case label per case for log and /// metric fields. #[derive(Clone, Debug, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub enum ClientError { - /// The typed body failed to encode; nothing reached the pool. + /// The typed body failed to encode; nothing crossed the wire. #[error(transparent)] Body(#[from] BodyError), - /// The pool or the venue behind it failed the call. The payload is - /// the wire `venue-error`, which carries no `Display`; format via + /// The registry or the venue behind it failed the call. The payload + /// is the wire `venue-error`, which carries no `Display`; format via /// `Debug`. #[error("venue error: {0:?}")] Venue(VenueError), diff --git a/crates/nexum-venue-sdk/src/faults.rs b/crates/nexum-venue-sdk/src/faults.rs index 9a0e3a1e..f8e00ce1 100644 --- a/crates/nexum-venue-sdk/src/faults.rs +++ b/crates/nexum-venue-sdk/src/faults.rs @@ -11,7 +11,7 @@ use nexum_sdk::host; use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; -use crate::{Fault, VenueError}; +use crate::{Fault, RateLimit, VenueError}; /// Lift the wire fault into the SDK-neutral vocabulary the transport /// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is @@ -53,37 +53,39 @@ impl From for Fault { } /// Fold a transport fault into the venue error an intent function -/// returns: a policy refusal stays `denied`, retryable transport states -/// (`unavailable`, `rate-limited`, `timeout`) fold to `unavailable`, -/// `unsupported` passes through, and the caller-shaped cases -/// (`invalid-input`, `internal`) become `internal-error` because inside -/// an intent function the transport's caller is the adapter itself. +/// returns: `denied`, `rate-limited`, `timeout`, and `unsupported` map +/// structurally; `unavailable` keeps its detail; the caller-shaped cases +/// (`invalid-input`, `internal`) fold to retryable `unavailable` because +/// inside an intent function the transport's caller is the adapter +/// itself, never the module. impl From for VenueError { fn from(fault: host::Fault) -> Self { match fault { host::Fault::Denied(s) => VenueError::Denied(s), - host::Fault::Unsupported(s) => VenueError::Unsupported(s), - host::Fault::Unavailable(_) | host::Fault::RateLimited(_) | host::Fault::Timeout => { - VenueError::Unavailable(fault.to_string()) - } - other => VenueError::InternalError(other.to_string()), + host::Fault::Unsupported(_) => VenueError::Unsupported, + host::Fault::RateLimited(rl) => VenueError::RateLimited(RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => VenueError::Timeout, + host::Fault::Unavailable(s) => VenueError::Unavailable(s), + other => VenueError::Unavailable(other.to_string()), } } } /// Fold a wasi:http fetch failure into the venue error an intent -/// function returns: an allowlist refusal stays `denied`, timeouts and -/// transport failures are retryable `unavailable`, and a request the -/// adapter itself malformed is `internal-error`. +/// function returns: an allowlist refusal stays `denied`, a timeout is +/// `timeout`, and transport failures (including a request the adapter +/// itself malformed) are retryable `unavailable`. impl From for VenueError { fn from(err: nexum_sdk::http::FetchError) -> Self { use nexum_sdk::http::FetchError; match err { FetchError::Denied => VenueError::Denied(err.to_string()), - FetchError::Timeout(_) | FetchError::Transport(_) => { + FetchError::Timeout(_) => VenueError::Timeout, + FetchError::Transport(_) | FetchError::InvalidRequest(_) => { VenueError::Unavailable(err.to_string()) } - FetchError::InvalidRequest(_) => VenueError::InternalError(err.to_string()), } } } @@ -119,13 +121,16 @@ mod tests { VenueError::from(host::Fault::Denied("nope".into())), VenueError::Denied("nope".into()), ); + assert_eq!(VenueError::from(host::Fault::Timeout), VenueError::Timeout); assert!(matches!( - VenueError::from(host::Fault::Timeout), - VenueError::Unavailable(_) + VenueError::from(host::Fault::RateLimited(host::RateLimit { + retry_after_ms: Some(250), + })), + VenueError::RateLimited(rl) if rl.retry_after_ms == Some(250) )); assert!(matches!( VenueError::from(host::Fault::InvalidInput("bug".into())), - VenueError::InternalError(_) + VenueError::Unavailable(_) )); } @@ -142,7 +147,7 @@ mod tests { )); assert!(matches!( VenueError::from(FetchError::InvalidRequest("bad url".into())), - VenueError::InternalError(_) + VenueError::Unavailable(_) )); } } diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs index 45a5dc43..1c0559e3 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -19,7 +19,7 @@ //! //! - [`client`] - the typed intent client core: [`IntentClient`] binds a //! venue and encodes through [`IntentBody`] before the byte-level -//! [`IntentPool`] seam. Lives here (not in the strategy SDK) so the +//! [`VenueClient`] seam. Lives here (not in the strategy SDK) so the //! codec and the client that speaks it version together. //! //! - [`transport`] - typed wrappers over the world's scoped imports: @@ -41,7 +41,7 @@ //! //! [`ChainHost`]: nexum_sdk::host::ChainHost //! [`IntentClient`]: client::IntentClient -//! [`IntentPool`]: client::IntentPool +//! [`VenueClient`]: client::VenueClient #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] @@ -57,7 +57,7 @@ pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, IntentPool}; +pub use client::{ClientError, IntentClient, VenueClient}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`nexum_macros::IntentBody`]. pub use nexum_macros::IntentBody; @@ -76,11 +76,12 @@ pub use nexum_macros::venue; /// The intent ontology at its plain spellings: the types the /// [`VenueAdapter`] face and the client core speak. -pub use bindings::nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +pub use bindings::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, + VenueError, }; /// The value-flow vocabulary intent headers are expressed in. -pub use bindings::nexum::value_flow::types as value_flow; +pub use bindings::videre::value_flow::types as value_flow; /// The wire config table (`nexum:host/types.config`) `init` receives. pub use bindings::nexum::host::types::Config; diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs index c6afd9ca..699915f0 100644 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ b/crates/nexum-venue-sdk/tests/adapter.rs @@ -3,13 +3,13 @@ //! `export_venue_adapter!`, and round-trips a versioned body through //! `#[derive(IntentBody)]` - including the typed unknown-version //! failure and the typed client core driving the adapter through the -//! [`IntentPool`] seam. +//! [`VenueClient`] seam. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, - IntentPool, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, + IntentStatus, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, }; /// First published body version: a fixed-price quote. @@ -60,15 +60,17 @@ impl VenueAdapter for DemoAdapter { } fn derive_header(body: Vec) -> Result { - let (amount_wei, valid_until) = Self::decode(&body)?; + let (amount_wei, _valid_until_ms) = Self::decode(&body)?; Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), + gives: AssetAmount { + asset: Asset::Native, amount: amount_wei.to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until, - settlement: Settlement::EvmChain(1), + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip712, }) } @@ -82,7 +84,11 @@ impl VenueAdapter for DemoAdapter { if receipt == RECEIPT { Ok(IntentStatus::Open) } else { - Err(VenueError::InvalidReceipt) + // A receipt this venue never issued can never succeed, so the + // refusal is the non-retryable case. + Err(VenueError::Denied( + "receipt not issued by this venue".into(), + )) } } @@ -95,11 +101,11 @@ impl VenueAdapter for DemoAdapter { // venue-adapter world. nexum_venue_sdk::export_venue_adapter!(DemoAdapter); -/// In-process pool: routes the demo venue id straight into the adapter, -/// standing in for the host router the strategy-side seam will bind. -struct InProcessPool; +/// In-process client: routes the demo venue id straight into the adapter, +/// standing in for the host registry the keeper-side seam will bind. +struct InProcessClient; -impl IntentPool for InProcessPool { +impl VenueClient for InProcessClient { fn submit(&self, venue: &str, body: Vec) -> Result { if venue != "demo" { return Err(VenueError::UnknownVenue); @@ -192,9 +198,9 @@ fn empty_and_malformed_bodies_fail_typedly() { fn adapter_projects_the_header_from_a_versioned_body() { let bytes = v2_body().to_bytes().unwrap(); let header = DemoAdapter::derive_header(bytes).unwrap(); - assert_eq!(header.gives.len(), 1); - assert_eq!(header.gives[0].amount, 1_000_000u64.to_be_bytes().to_vec()); - assert_eq!(header.valid_until, Some(1_700_000_000_000)); + assert_eq!(header.gives.asset, Asset::Native); + assert_eq!(header.gives.amount, 1_000_000u64.to_be_bytes().to_vec()); + assert_eq!(header.settlement, Settlement { chain: 1 }); assert_eq!(header.authorisation, AuthScheme::Eip712); } @@ -210,8 +216,8 @@ fn adapter_reports_an_unknown_version_as_invalid_body() { } #[test] -fn typed_client_round_trips_through_the_pool_seam() { - let client = IntentClient::new(InProcessPool, "demo"); +fn typed_client_round_trips_through_the_client_seam() { + let client = IntentClient::new(InProcessClient, "demo"); let outcome = client.submit(&v2_body()).unwrap(); let SubmitOutcome::Accepted(receipt) = outcome else { @@ -224,13 +230,13 @@ fn typed_client_round_trips_through_the_pool_seam() { assert!(matches!( client.status(&[0, 1]).unwrap_err(), - ClientError::Venue(VenueError::InvalidReceipt) + ClientError::Venue(VenueError::Denied(_)) )); } #[test] -fn unbound_venue_is_unknown_at_the_pool() { - let client = IntentClient::new(InProcessPool, "nowhere"); +fn unbound_venue_is_unknown_at_the_client() { + let client = IntentClient::new(InProcessClient, "nowhere"); assert!(matches!( client.submit(&v2_body()).unwrap_err(), ClientError::Venue(VenueError::UnknownVenue) diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json index 980751be..ded49d81 100644 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -5,19 +5,16 @@ "name": "v1-small", "body": "00010000000000000002000000676d", "header": { - "gives": [ - { - "asset": { - "native-token": { - "evm-chain": 1 - } - }, - "amount": "01" - } - ], - "wants": [], + "gives": { + "asset": "native", + "amount": "01" + }, + "wants": { + "asset": "native", + "amount": "" + }, "settlement": { - "evm-chain": 1 + "chain": 1 }, "authorisation": "eip712" }, @@ -27,34 +24,24 @@ "name": "v2-full", "body": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", "header": { - "gives": [ - { - "asset": { - "native-token": { - "evm-chain": 1 - } - }, - "amount": "0f4240" - } - ], - "wants": [ - { - "asset": { - "erc20": { - "chain-id": 1, - "address": "0102030405060708090a0b0c0d0e0f1011121314" - } - }, - "amount": "0f4240" - } - ], - "valid-until": 1700000000000, + "gives": { + "asset": "native", + "amount": "0f4240" + }, + "wants": { + "asset": { + "erc20": { + "token": "0102030405060708090a0b0c0d0e0f1011121314" + } + }, + "amount": "0f4240" + }, "settlement": { - "evm-chain": 1 + "chain": 1 }, "authorisation": "eip712" }, - "notes": "v2 adds the expiry and an erc20 want at the recipient address" + "notes": "v2 adds an erc20 want at the recipient token address" } ] } diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index bee21583..f2908622 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -14,8 +14,8 @@ use std::fmt; use std::path::Path; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; -use nexum_venue_sdk::{AuthScheme, IntentHeader}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; +use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; use crate::fixture::{self, FixtureError, hex_bytes}; @@ -53,12 +53,9 @@ pub struct HeaderGolden { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct GoldenHeader { /// Value leaving the user's control. - pub gives: Vec, - /// Value expected in return. - pub wants: Vec, - /// Expiry in milliseconds since the Unix epoch, UTC. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub valid_until: Option, + pub gives: GoldenAssetAmount, + /// Value expected in return. Display-grade, not host-verified. + pub wants: GoldenAssetAmount, /// Where the deal settles. pub settlement: GoldenSettlement, /// How the venue authorises the intent. @@ -66,29 +63,26 @@ pub struct GoldenHeader { } /// Serde mirror of the wire `asset-amount`. `amount` is big-endian -/// unsigned, hex in the file; an empty string is zero. +/// unsigned, minimal-length, hex in the file; an empty string is zero. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GoldenAssetAmount { /// The asset moving. pub asset: GoldenAsset, - /// Big-endian unsigned amount bytes. + /// Big-endian minimal-length unsigned amount bytes. #[serde(with = "hex_bytes")] pub amount: Vec, } /// Serde mirror of the wire `settlement`. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub enum GoldenSettlement { - /// Settles on an EVM chain, by chain id. - EvmChain(u64), - /// Settles off-chain in the named domain. - Offchain(String), +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GoldenSettlement { + /// EVM chain id the deal settles on. + pub chain: u64, } -/// Serde mirror of the wire `asset`. Token addresses and ids are hex -/// in the file. +/// Serde mirror of the wire `asset`. Token addresses are hex in the file. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde( rename_all = "kebab-case", @@ -96,51 +90,13 @@ pub enum GoldenSettlement { deny_unknown_fields )] pub enum GoldenAsset { - /// The settlement domain's own gas token. - NativeToken(GoldenSettlement), - /// An ERC-20 token. + /// The settlement chain's gas token. + Native, + /// An ERC-20 token on the settlement chain. Erc20 { - /// Chain the token lives on. - chain_id: u64, - /// 20-byte contract address. - #[serde(with = "hex_bytes")] - address: Vec, - }, - /// An ERC-721 NFT. - Erc721 { - /// Chain the token lives on. - chain_id: u64, /// 20-byte contract address. #[serde(with = "hex_bytes")] - address: Vec, - /// Token id, big-endian, arbitrary width. - #[serde(with = "hex_bytes")] - token_id: Vec, - }, - /// An ERC-1155 token. - Erc1155 { - /// Chain the token lives on. - chain_id: u64, - /// 20-byte contract address. - #[serde(with = "hex_bytes")] - address: Vec, - /// Token id, big-endian, arbitrary width. - #[serde(with = "hex_bytes")] - token_id: Vec, - }, - /// A non-token service obligation. - Service { - /// Namespaced service kind, e.g. `swarm:postage`. - kind: String, - /// Human-readable description for the consent sheet. - summary: String, - }, - /// A real-world asset settled off-chain. - Offchain { - /// Jurisdiction or registry domain. - domain: String, - /// Human-readable description for the consent sheet. - summary: String, + token: Vec, }, } @@ -148,24 +104,17 @@ pub enum GoldenAsset { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum GoldenAuthScheme { - /// EIP-712 typed-data signature by host-held keys. - Eip712, /// EIP-1271 contract signature. Eip1271, - /// Pre-signed authorisation at the settlement contract. - Presign, - /// Venue-defined off-chain signature scheme. - OffchainSig, - /// No authorisation travels with the body. - Unsigned, + /// EIP-712 typed-data signature by host-held keys. + Eip712, } impl From for GoldenHeader { fn from(header: IntentHeader) -> Self { Self { - gives: header.gives.into_iter().map(Into::into).collect(), - wants: header.wants.into_iter().map(Into::into).collect(), - valid_until: header.valid_until, + gives: header.gives.into(), + wants: header.wants.into(), settlement: header.settlement.into(), authorisation: header.authorisation.into(), } @@ -183,9 +132,8 @@ impl From for GoldenAssetAmount { impl From for GoldenSettlement { fn from(settlement: Settlement) -> Self { - match settlement { - Settlement::EvmChain(chain_id) => GoldenSettlement::EvmChain(chain_id), - Settlement::Offchain(domain) => GoldenSettlement::Offchain(domain), + Self { + chain: settlement.chain, } } } @@ -193,26 +141,8 @@ impl From for GoldenSettlement { impl From for GoldenAsset { fn from(asset: Asset) -> Self { match asset { - Asset::NativeToken(settlement) => GoldenAsset::NativeToken(settlement.into()), - Asset::Erc20((chain_id, address)) => GoldenAsset::Erc20 { chain_id, address }, - Asset::Erc721((chain_id, address, token_id)) => GoldenAsset::Erc721 { - chain_id, - address, - token_id, - }, - Asset::Erc1155((chain_id, address, token_id)) => GoldenAsset::Erc1155 { - chain_id, - address, - token_id, - }, - Asset::Service(desc) => GoldenAsset::Service { - kind: desc.kind, - summary: desc.summary, - }, - Asset::Offchain(desc) => GoldenAsset::Offchain { - domain: desc.domain, - summary: desc.summary, - }, + Asset::Native => GoldenAsset::Native, + Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, } } } @@ -220,11 +150,8 @@ impl From for GoldenAsset { impl From for GoldenAuthScheme { fn from(scheme: AuthScheme) -> Self { match scheme { - AuthScheme::Eip712 => GoldenAuthScheme::Eip712, AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, - AuthScheme::Presign => GoldenAuthScheme::Presign, - AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, - AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, } } } @@ -336,47 +263,24 @@ impl HeaderGoldens { #[cfg(test)] mod tests { use nexum_venue_sdk::VenueError; - use nexum_venue_sdk::value_flow::{OffchainDesc, ServiceDesc}; + use nexum_venue_sdk::value_flow::Erc20; use super::*; fn wire_header() -> IntentHeader { IntentHeader { - gives: vec![ - AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(100)), - amount: vec![0x0d, 0xe0, 0xb6], - }, - AssetAmount { - asset: Asset::Erc20((1, vec![0xAA; 20])), - amount: vec![1, 0], - }, - AssetAmount { - asset: Asset::Erc721((1, vec![0xBB; 20], vec![7])), - amount: vec![1], - }, - AssetAmount { - asset: Asset::Erc1155((1, vec![0xCC; 20], vec![8])), - amount: vec![2], - }, - AssetAmount { - asset: Asset::Service(ServiceDesc { - kind: "swarm:postage".to_owned(), - summary: "storage for 30 days".to_owned(), - }), - amount: Vec::new(), - }, - ], - wants: vec![AssetAmount { - asset: Asset::Offchain(OffchainDesc { - domain: "iso:AU".to_owned(), - summary: "a deed".to_owned(), + gives: AssetAmount { + asset: Asset::Native, + amount: vec![0x0d, 0xe0, 0xb6], + }, + wants: AssetAmount { + asset: Asset::Erc20(Erc20 { + token: vec![0xAA; 20], }), - amount: Vec::new(), - }], - valid_until: Some(1_700_000_000_000), - settlement: Settlement::Offchain("acme".to_owned()), - authorisation: AuthScheme::OffchainSig, + amount: vec![1, 0], + }, + settlement: Settlement { chain: 100 }, + authorisation: AuthScheme::Eip1271, } } @@ -395,12 +299,11 @@ mod tests { let json = goldens.to_json(); assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); // The wire spellings are the contract for non-Rust readers. - assert!(json.contains("\"native-token\"")); - assert!(json.contains("\"chain-id\"")); - assert!(json.contains("\"token-id\"")); - assert!(json.contains("\"valid-until\"")); - assert!(json.contains("\"offchain-sig\"")); - assert!(json.contains("\"evm-chain\"")); + assert!(json.contains("\"native\"")); + assert!(json.contains("\"erc20\"")); + assert!(json.contains("\"token\"")); + assert!(json.contains("\"chain\"")); + assert!(json.contains("\"eip1271\"")); } #[test] @@ -432,7 +335,7 @@ mod tests { calls += 1; if calls == 1 { let mut header = wire_header(); - header.valid_until = None; + header.authorisation = AuthScheme::Eip712; Ok(header) } else { Err(VenueError::InvalidBody("nope".to_owned())) @@ -454,6 +357,6 @@ mod tests { goldens .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) .unwrap(); - goldens.assert_conforms(|_| Err::(VenueError::InvalidReceipt)); + goldens.assert_conforms(|_| Err::(VenueError::Timeout)); } } diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs index 74ce00b2..be6eef02 100644 --- a/crates/nexum-venue-test/src/reference.rs +++ b/crates/nexum-venue-test/src/reference.rs @@ -11,8 +11,8 @@ //! must reproduce them byte for byte. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; -use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, VenueError}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; /// The published codec vector file, verbatim. pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); @@ -59,29 +59,36 @@ pub enum ReferenceBody { } /// The reference venue's pure header derivation, the subject the -/// published goldens pin. Gives the amount as chain-1 native token, -/// wants (for v2) the same amount as an ERC-20 at the recipient -/// address, and authorises via EIP-712. +/// published goldens pin. Gives the amount as the chain's native token, +/// wants (for v2) the same amount as an ERC-20 at the recipient token +/// address, and authorises via EIP-712. V1 wants nothing, spelled as a +/// zero native amount. pub fn derive_reference_header(body: Vec) -> Result { - let (amount_wei, valid_until, wants) = match ReferenceBody::from_bytes(&body)? { - ReferenceBody::V1(quote) => (quote.amount_wei, None, Vec::new()), + let (amount_wei, wants) = match ReferenceBody::from_bytes(&body)? { + ReferenceBody::V1(quote) => ( + quote.amount_wei, + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + ), ReferenceBody::V2(quote) => ( quote.amount_wei, - quote.valid_until_ms, - vec![AssetAmount { - asset: Asset::Erc20((1, quote.recipient)), + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: quote.recipient, + }), amount: minimal_be(quote.amount_wei), - }], + }, ), }; Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), + gives: AssetAmount { + asset: Asset::Native, amount: minimal_be(amount_wei), - }], + }, wants, - valid_until, - settlement: Settlement::EvmChain(1), + settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip712, }) } @@ -227,8 +234,7 @@ mod tests { derive_reference_header, ) .unwrap() - .notes = - Some("v2 adds the expiry and an erc20 want at the recipient address".to_owned()); + .notes = Some("v2 adds an erc20 want at the recipient token address".to_owned()); goldens } diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs index 290c2ef4..d6c37040 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -3,7 +3,7 @@ //! golden files, and a deliberately divergent adapter is caught by //! them. -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ AuthScheme, Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, }; @@ -58,9 +58,7 @@ fn divergent_derivation_is_caught_by_the_published_goldens() { // The classic byte-order bug: little-endian amounts. let derive = |body: Vec| -> Result { let mut header = derive_reference_header(body)?; - for give in &mut header.gives { - give.amount.reverse(); - } + header.gives.amount.reverse(); Ok(header) }; let report = HeaderGoldens::from_json(HEADER_GOLDENS_JSON) @@ -124,10 +122,7 @@ fn published_files_document_the_wire_format_in_hex() { ); // And the expected header speaks the value-flow vocabulary. let derived = derive_reference_header(golden.body.clone()).unwrap(); - assert_eq!( - derived.gives[0].asset, - Asset::NativeToken(Settlement::EvmChain(1)), - ); + assert_eq!(derived.gives.asset, Asset::Native); assert_eq!(derived.authorisation, AuthScheme::Eip712); - let _: &AssetAmount = &derived.gives[0]; + let _: &AssetAmount = &derived.gives; } diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index 018adaba..c98acb31 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -26,8 +26,6 @@ use crate::cow_orderbook::{CowApiError, OrderBookPool}; mod bindings { wasmtime::component::bindgen!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", "../../wit/nexum-host", "../../wit/shepherd-cow", ], diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 70222401..63764ab3 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -16,7 +16,7 @@ The blocker is deployment. The fork is `abiVersion 2.0.0-dev`, `deployments/netw ## Decision -The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `nexum:intent/pool`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. +The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. The chassis (ADR-0009) supplies the mechanism: the watch-set and journal stores are unchanged; the two-gate store (`next_block:` / `next_epoch:`) now holds the contract-supplied `waitUntil` / `nextPollTimestamp` slots, so its value source moves from off-chain revert-decode to the contract verdict while its shape is unchanged. The `ConditionalSource` seam returns a structured `Verdict` mirroring `GeneratorResultCode` plus the hints; it does not decode or schedule. diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 0c04791b..21fd3d1c 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -38,8 +38,6 @@ use wit_bindgen as _; #[cfg(target_arch = "wasm32")] wit_bindgen::generate!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", "../../wit/nexum-host", "../../wit/shepherd-cow", ], diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml index 95e02533..f4cf47a0 100644 --- a/modules/examples/echo-client/Cargo.toml +++ b/modules/examples/echo-client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through nexum:intent/pool on every block and logs the intent-status transitions the router fans back." +description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through videre:venue/client on every block and logs the intent-status transitions the registry fans back." [lints] workspace = true diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml index f10708d1..4b1df513 100644 --- a/modules/examples/echo-client/module.toml +++ b/modules/examples/echo-client/module.toml @@ -1,7 +1,8 @@ -# echo-client module manifest - the strategy half of the echo pair. It -# submits through nexum:intent/pool and observes intent-status, so it -# declares the `pool` capability alongside `logging`; the per-module world -# the macro derives imports exactly nexum:intent/pool and nexum:host/logging. +# echo-client module manifest - the keeper half of the echo pair. It +# submits through videre:venue/client and observes intent-status, so it +# declares the `client` capability alongside `logging`; the per-module world +# the macro derives imports exactly videre:venue/client and +# nexum:host/logging. [module] name = "echo-client" @@ -10,8 +11,8 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# `pool` grants the nexum:intent/pool import; `logging` the log sink. -required = ["pool", "logging"] +# `client` grants the videre:venue/client import; `logging` the log sink. +required = ["client", "logging"] optional = [] [capabilities.http] @@ -22,7 +23,7 @@ allow = [] kind = "block" chain_id = 1 -# Observe the status transitions the router polls from the echo-venue adapter. +# Observe the status transitions the registry polls from the echo-venue adapter. [[subscription]] kind = "intent-status" venue = "echo-venue" diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 62407488..6a769517 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -1,15 +1,15 @@ //! # echo-client (reference Shepherd intent module) //! -//! The strategy half of the echo pair. On every chain-1 block it submits an -//! opaque body through `nexum:intent/pool` to the `echo-venue` adapter and -//! logs the receipt, and it logs each `intent-status` transition the router -//! fans back from that venue. Paired with the echo-venue adapter it is the -//! smallest end-to-end demonstration of the intent core: module -> host -//! router -> venue adapter, and the status event back. +//! The keeper half of the echo pair. On every chain-1 block it submits an +//! opaque body through `videre:venue/client` to the `echo-venue` adapter and +//! logs the receipt, and it logs each `intent-status` transition the +//! registry fans back from that venue. Paired with the echo-venue adapter it +//! is the smallest end-to-end demonstration of the intent core: module -> +//! host registry -> venue adapter, and the status event back. //! -//! It declares two capabilities (`pool`, `logging`), so the built component -//! imports `nexum:intent/pool` and `nexum:host/logging` and nothing else: -//! the per-module world matches the manifest by construction. +//! It declares two capabilities (`client`, `logging`), so the built +//! component imports `videre:venue/client` and `nexum:host/logging` and +//! nothing else: the per-module world matches the manifest by construction. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. @@ -17,8 +17,8 @@ #![allow(clippy::too_many_arguments)] use nexum::host::{logging, types}; -use nexum::intent::pool; -use nexum::intent::types::SubmitOutcome; +use videre::types::types::SubmitOutcome; +use videre::venue::client; /// Venue id the paired echo-venue adapter answers for; the module submits /// to and observes exactly this venue. @@ -33,7 +33,7 @@ impl EchoClient { // receipt, so the body content is immaterial; the block number keeps // it non-empty and legible in the logs. let body = block.number.to_be_bytes().to_vec(); - match pool::submit(ECHO_VENUE, &body) { + match client::submit(ECHO_VENUE, &body) { Ok(SubmitOutcome::Accepted(receipt)) => logging::log( logging::Level::Info, &format!( diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index ef80f731..d99726ad 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -2,7 +2,7 @@ //! //! The minimal reference venue adapter: it accepts any body, echoes it back //! as the receipt, and settles instantly (every receipt it issued reports -//! `settled`). It carries no real venue protocol, so it doubles as the +//! `fulfilled`). It carries no real venue protocol, so it doubles as the //! smallest end-to-end demonstration of `#[nexum_venue_sdk::venue]` - the //! attribute supplies the per-cdylib wit-bindgen call for a world derived //! from `module.toml`, the `Guest` export glue, and `export!`, leaving only @@ -19,8 +19,10 @@ #![allow(clippy::too_many_arguments)] use nexum::host::chain; -use nexum::intent::types::{IntentHeader, IntentStatus, SubmitOutcome, VenueError}; -use nexum::value_flow::types::{Asset, AssetAmount, Settlement}; +use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Settlement, SubmitOutcome, VenueError, +}; +use videre::value_flow::types::{Asset, AssetAmount}; struct EchoVenue; @@ -33,16 +35,19 @@ impl EchoVenue { fn derive_header(body: Vec) -> Result { // The echo venue gives back exactly the bytes handed to it, so the // header's `gives` amount is the body length: enough to exercise - // the value-flow vocabulary without a real schema. + // the value-flow vocabulary without a real schema. Wants nothing, + // spelled as a zero native amount. Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), - amount: (body.len() as u64).to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), - authorisation: nexum::intent::types::AuthScheme::Unsigned, + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, }) } @@ -55,25 +60,25 @@ impl EchoVenue { Ok(SubmitOutcome::Accepted(body)) } - fn status(receipt: Vec) -> Result { - if receipt.is_empty() { - Err(VenueError::InvalidReceipt) - } else { - // Settles instantly: the intent reaches a terminal state on the - // first status poll, with no venue-side settlement proof. - Ok(IntentStatus::Settled(None)) - } + fn status(_receipt: Vec) -> Result { + // Settles instantly: the intent reaches a terminal state on the + // first status poll. + Ok(IntentStatus::Fulfilled) } - fn cancel(receipt: Vec) -> Result<(), VenueError> { - if receipt.is_empty() { - Err(VenueError::InvalidReceipt) - } else { - Ok(()) - } + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) } } +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(value: u64) -> Vec { + let bytes = value.to_be_bytes(); + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + /// echo-venue as the `nexum-venue-test` conformance target: the adapter's /// pure header derivation is held to a hand-written golden through the kit's /// serde mirror types. The macro mints echo-venue's own bindgen @@ -83,43 +88,15 @@ impl EchoVenue { #[cfg(test)] mod conformance { use super::*; - use nexum::intent::types::AuthScheme; use nexum_venue_test::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, }; - fn settlement_to_golden(settlement: Settlement) -> GoldenSettlement { - match settlement { - Settlement::EvmChain(chain_id) => GoldenSettlement::EvmChain(chain_id), - Settlement::Offchain(domain) => GoldenSettlement::Offchain(domain), - } - } - fn asset_to_golden(asset: Asset) -> GoldenAsset { match asset { - Asset::NativeToken(settlement) => { - GoldenAsset::NativeToken(settlement_to_golden(settlement)) - } - Asset::Erc20((chain_id, address)) => GoldenAsset::Erc20 { chain_id, address }, - Asset::Erc721((chain_id, address, token_id)) => GoldenAsset::Erc721 { - chain_id, - address, - token_id, - }, - Asset::Erc1155((chain_id, address, token_id)) => GoldenAsset::Erc1155 { - chain_id, - address, - token_id, - }, - Asset::Service(desc) => GoldenAsset::Service { - kind: desc.kind, - summary: desc.summary, - }, - Asset::Offchain(desc) => GoldenAsset::Offchain { - domain: desc.domain, - summary: desc.summary, - }, + Asset::Native => GoldenAsset::Native, + Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, } } @@ -132,20 +109,18 @@ mod conformance { fn auth_to_golden(scheme: AuthScheme) -> GoldenAuthScheme { match scheme { - AuthScheme::Eip712 => GoldenAuthScheme::Eip712, AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, - AuthScheme::Presign => GoldenAuthScheme::Presign, - AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, - AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, } } fn header_to_golden(header: IntentHeader) -> GoldenHeader { GoldenHeader { - gives: header.gives.into_iter().map(amount_to_golden).collect(), - wants: header.wants.into_iter().map(amount_to_golden).collect(), - valid_until: header.valid_until, - settlement: settlement_to_golden(header.settlement), + gives: amount_to_golden(header.gives), + wants: amount_to_golden(header.wants), + settlement: GoldenSettlement { + chain: header.settlement.chain, + }, authorisation: auth_to_golden(header.authorisation), } } @@ -155,25 +130,32 @@ mod conformance { EchoVenue::derive_header(body).map(header_to_golden) } + fn zero_native() -> GoldenAssetAmount { + GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: Vec::new(), + } + } + #[test] fn derive_header_conforms_to_the_published_golden() { // The echo contract: gives chain-1 native token whose amount is the - // body length as eight big-endian bytes, wants nothing, and carries - // no authorisation. A conforming adapter reproduces this exactly. + // body length in minimal big-endian bytes, wants zero native, and + // authorises via EIP-1271. A conforming adapter reproduces this + // exactly. let golden = HeaderGolden { name: "four-byte-body".to_owned(), body: vec![1, 2, 3, 4], header: GoldenHeader { - gives: vec![GoldenAssetAmount { - asset: GoldenAsset::NativeToken(GoldenSettlement::EvmChain(1)), - amount: 4u64.to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until: None, - settlement: GoldenSettlement::EvmChain(1), - authorisation: GoldenAuthScheme::Unsigned, + gives: GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: vec![4], + }, + wants: zero_native(), + settlement: GoldenSettlement { chain: 1 }, + authorisation: GoldenAuthScheme::Eip1271, }, - notes: Some("amount is the 8-byte big-endian body length".to_owned()), + notes: Some("amount is the minimal big-endian body length".to_owned()), }; let goldens = HeaderGoldens { venue: "echo-venue".to_owned(), @@ -184,22 +166,21 @@ mod conformance { #[test] fn divergent_derivation_is_caught_by_the_golden() { - // A little-endian amount is the classic byte-order bug; the golden - // must reject it, proving the check has teeth on echo-venue. + // A non-minimal amount is the classic uint bug; the golden must + // reject it, proving the check has teeth on echo-venue. let goldens = HeaderGoldens { venue: "echo-venue".to_owned(), goldens: vec![HeaderGolden { name: "four-byte-body".to_owned(), body: vec![1, 2, 3, 4], header: GoldenHeader { - gives: vec![GoldenAssetAmount { - asset: GoldenAsset::NativeToken(GoldenSettlement::EvmChain(1)), - amount: 4u64.to_le_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until: None, - settlement: GoldenSettlement::EvmChain(1), - authorisation: GoldenAuthScheme::Unsigned, + gives: GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: 4u64.to_be_bytes().to_vec(), + }, + wants: zero_native(), + settlement: GoldenSettlement { chain: 1 }, + authorisation: GoldenAuthScheme::Eip1271, }, notes: None, }], diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index be6ef1b7..ff846bdf 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -24,8 +24,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", "../../../wit/shepherd-cow", ], diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index b072abd0..2f0cea8b 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -17,8 +17,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index cd5c7d41..d1b9598a 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -22,8 +22,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index 14b55f13..3e6b6b3b 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -14,8 +14,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index cf1dd031..948b65e1 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -13,8 +13,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 09fcb89c..56eaa348 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -14,8 +14,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/slow-host/src/lib.rs b/modules/fixtures/slow-host/src/lib.rs index 5e490752..97b420be 100644 --- a/modules/fixtures/slow-host/src/lib.rs +++ b/modules/fixtures/slow-host/src/lib.rs @@ -27,8 +27,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index c23eb237..0483cb52 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -25,8 +25,6 @@ wit_bindgen::generate!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", "../../wit/nexum-host", "../../wit/shepherd-cow", ], diff --git a/wit/nexum-adapter/venue-adapter.wit b/wit/nexum-adapter/venue-adapter.wit deleted file mode 100644 index 9656bc47..00000000 --- a/wit/nexum-adapter/venue-adapter.wit +++ /dev/null @@ -1,30 +0,0 @@ -package nexum:adapter@0.1.0; - -/// A venue adapter: the second component kind. Where an event-module -/// automates strategy over the six core primitives, a venue adapter -/// speaks one venue's protocol and nothing else. It imports only the -/// scoped transport it needs to reach its venue - chain RPC and -/// messaging - and exports the intent adapter face. It has no -/// local-store, remote-store, identity, or logging import: an adapter -/// structurally cannot touch host key material or persistent state, only -/// move bytes to and from its venue. The host links exactly these -/// imports into an adapter store, so an adapter that reaches for anything -/// else fails to instantiate. -world venue-adapter { - use nexum:host/types@0.2.0.{config, fault}; - - // Scoped transport. Outbound HTTP is wasi:http, linked separately and - // gated per-adapter by the `[[adapters]].http_allow` allowlist in - // engine.toml, the same way event-module treats outbound HTTP; the - // per-adapter `[[adapters]].messaging_topics` scopes the messaging - // content topics an adapter may reach. Time and randomness are - // ambient wasi:clocks / wasi:random. - import nexum:host/chain@0.2.0; - import nexum:host/messaging@0.2.0; - - /// Configure the adapter from its `[config]` before any submission. - /// Mirrors the event-module `init` so the supervisor boots both kinds - /// through the same store, fuel, and restart machinery. - export init: func(config: config) -> result<_, fault>; - export nexum:intent/adapter@0.1.0; -} diff --git a/wit/nexum-intent/adapter.wit b/wit/nexum-intent/adapter.wit deleted file mode 100644 index 1c0a28ce..00000000 --- a/wit/nexum-intent/adapter.wit +++ /dev/null @@ -1,31 +0,0 @@ -package nexum:intent@0.1.0; - -/// The venue face of the intent core, the mirror of the strategy-facing -/// `pool` interface. One installed adapter answers for exactly one venue, -/// so none of these functions carries a `venue` argument: the router -/// resolves a venue id to its adapter and calls the adapter directly. -/// Bodies stay opaque bytes at this boundary; the adapter recovers typing -/// against its own venue schema. The package depends only on its own -/// types, never on `nexum:host`, so the adapter contract's freeze cadence -/// stays independent of host versioning. -interface adapter { - use types.{intent-header, intent-status, receipt, submit-outcome, venue-error}; - - /// Project an opaque intent body onto the stable header guard policy - /// runs on. A pure derivation: no transport, no side effects, so the - /// host can derive and inspect a header before deciding to submit. - derive-header: func(body: list) -> result; - - /// Submit an opaque intent body to this adapter's venue. Success is - /// either the venue's receipt or requires-signing: a transaction the - /// host must sign and send before the intent exists. - submit: func(body: list) -> result; - - /// Report where a previously submitted intent is in its life. - status: func(receipt: receipt) -> result; - - /// Ask the venue to withdraw an intent. Success means the venue - /// accepted the cancellation, not that settlement can no longer - /// happen: an already in-flight settlement may still win the race. - cancel: func(receipt: receipt) -> result<_, venue-error>; -} diff --git a/wit/nexum-intent/pool.wit b/wit/nexum-intent/pool.wit deleted file mode 100644 index 7655292a..00000000 --- a/wit/nexum-intent/pool.wit +++ /dev/null @@ -1,26 +0,0 @@ -package nexum:intent@0.1.0; - -/// The strategy-module face of the intent core. The host is a router plus -/// a policy checkpoint: it resolves the venue id to the installed adapter, -/// has the adapter derive the header, runs guard policy on it, and only -/// then forwards the call. Bodies are opaque bytes at this boundary; typing -/// is recovered guest-side by venue SDK crates and host-side by the -/// adapter's header derivation. Bodies carry their own routing: there is no -/// chain parameter, a multichain venue's body schema names the chain and -/// the derived header's settlement field exposes the choice to policy. -interface pool { - use types.{intent-status, receipt, submit-outcome, venue-error}; - - /// Submit an opaque intent body to the named venue. Success is either - /// the venue's receipt or requires-signing: a transaction the host - /// must sign and send before the intent exists. - submit: func(venue: string, body: list) -> result; - - /// Report where a previously submitted intent is in its life. - status: func(venue: string, receipt: receipt) -> result; - - /// Ask the venue to withdraw an intent. Success means the venue - /// accepted the cancellation, not that settlement can no longer - /// happen: an already in-flight settlement may still win the race. - cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; -} diff --git a/wit/nexum-intent/types.wit b/wit/nexum-intent/types.wit deleted file mode 100644 index 32c2e16d..00000000 --- a/wit/nexum-intent/types.wit +++ /dev/null @@ -1,132 +0,0 @@ -package nexum:intent@0.1.0; - -/// The venue-neutral intent ontology: what a deal gives and wants, how the -/// venue authorises it, and how its life at the venue is reported. Built on -/// the nexum:value-flow vocabulary so a submission is described the same -/// way to strategy modules, venue adapters, and guard policy. The package -/// deliberately does not depend on nexum:host: embedding the host fault -/// type in venue-error would pin this contract's freeze cadence to host -/// versioning, so venue-error carries its own transport cases. -/// -/// Identifier hygiene follows the value-flow rule: every id is checked -/// against WIT keywords and the reserved words of the nine binding-target -/// languages, preferring a two-word kebab id wherever a single word is a -/// keyword anywhere (`unsigned` not `none`, a Python keyword; -/// `internal-error` not `internal`, a Swift declaration keyword). -interface types { - use nexum:value-flow/types@0.1.0.{asset-amount, settlement}; - - /// How an intent is authorised at its venue. - variant auth-scheme { - /// An EIP-712 typed-data signature by host-held keys. The only - /// scheme that reaches the identity checkpoint at submit time. - eip712, - /// An EIP-1271 contract signature: consent happened on-chain when - /// the commitment was created, so the host signs nothing here. - eip1271, - /// A pre-signed authorisation recorded at the settlement contract - /// ahead of submission. - presign, - /// A venue-defined off-chain signature scheme. - offchain-sig, - /// No authorisation travels with the body. - unsigned, - } - - /// The adapter-derived description of an intent body: the stable - /// ontology guard policy runs on. Policy has teeth on `gives` (what - /// leaves the user's control); `wants` is display-grade because the - /// host can rarely verify the counterparty's obligation and must not - /// pretend to. - record intent-header { - /// Value leaving the user's control. - gives: list, - /// Value expected in return. Display-grade, not host-verified. - wants: list, - /// Expiry in milliseconds since the Unix epoch, UTC; absent means - /// the venue's own default lifetime applies. - valid-until: option, - /// Where the deal settles. - settlement: settlement, - /// How the venue authorises the intent. - authorisation: auth-scheme, - } - - /// Venue-scoped stable identifier for a submitted intent (for CoW, - /// the 56-byte order UID). Opaque to the host and to policy. - type receipt = list; - - /// Why an intent failed terminally, as reported by the venue. - record fail-reason { - /// Venue-scoped machine-readable code, stable enough for a - /// module to match on. - code: string, - /// Human-readable detail for logs and the consent surface. - detail: string, - } - - /// Where an intent is in its life at the venue. - variant intent-status { - /// Accepted for processing but not yet live at the venue. - pending, - /// Live at the venue and eligible for settlement. - open, - /// Settled. The payload is venue-defined settlement proof (for an - /// EVM venue, typically the settlement transaction hash). - settled(option>), - /// Terminally failed. - failed(fail-reason), - /// Reached its expiry without settling. - expired, - /// Withdrawn before settlement. - cancelled, - } - - /// An EVM call the host must sign and send for the intent to exist - /// on-chain. The adapter only describes the call: the host routes it - /// through the guard's host-signed class, fills the gas and fee - /// fields, and signs, so adapters still structurally cannot move - /// value. Always a call to existing code; adapters cannot deploy. - record unsigned-tx { - /// Chain the transaction must land on. - chain-id: u64, - /// 20-byte address of the contract to call. - to: list, - /// Native value, big-endian unsigned; an empty list is zero. - value: list, - /// ABI-encoded calldata. - input: list, - } - - /// What a successful submit produced. A variant from day one: an - /// on-chain-settlement venue (an ethflow-style order) has no receipt - /// to give until a transaction is signed, and bolting that on later - /// would break every deployed module. - variant submit-outcome { - /// The venue holds the intent; the receipt is its stable id. - accepted(receipt), - /// Settlement requires a host-signed on-chain transaction first. - requires-signing(unsigned-tx), - } - - /// Failure of a pool or adapter call. - variant venue-error { - /// No installed adapter answers to the venue id. - unknown-venue, - /// Body bytes failed to decode against the venue's published - /// schema: malformed bytes or an unknown outer version. - invalid-body(string), - /// The receipt was not issued by this venue or is malformed. - invalid-receipt, - /// The venue refused the intent under its own rules. - rejected(string), - /// Guard policy refused the egress before it reached the venue. - denied(string), - /// The venue or adapter does not support the operation. - unsupported(string), - /// Transport or venue infrastructure failure; retry later. - unavailable(string), - /// Adapter or router failure that is not the caller's fault. - internal-error(string), - } -} diff --git a/wit/nexum-value-flow/types.wit b/wit/nexum-value-flow/types.wit deleted file mode 100644 index d626d409..00000000 --- a/wit/nexum-value-flow/types.wit +++ /dev/null @@ -1,98 +0,0 @@ -package nexum:value-flow@0.1.0; - -/// The egress-neutral vocabulary for value in motion: where a deal settles, -/// what asset moves, and how much of it. Shared by intent headers, -/// simulation balance diffs, and analyser verdict subjects so that a spend -/// is written the same way in all three places. This is the platform's -/// hardest-freezing contract; the package carries no dependency so it can -/// outlive any interface built on it. -/// -/// Identifier hygiene is a freeze gate. Every identifier below is checked -/// against WIT keywords (including in-flight proposals -- the package is -/// `value-flow`, not `value`, because the component model's value-imports -/// feature is circling that word) and against the reserved words of the -/// nine binding-target languages (Rust, Python, JS, Go, C#, Java, Kotlin, -/// Swift, Dart). A two-word kebab id is preferred wherever a single word is -/// a keyword anywhere: `native-token` not `native` (a Java modifier), -/// `offchain` not `external` (a Dart keyword). WIT parses the rejected -/// spellings today; the cost lands later, as escaped identifiers in the -/// generated bindings for exactly the personas the SDK exists to serve. -interface types { - /// Where a deal comes to rest. A variant from day one: the chain-id - /// plumbing assumes every venue settles on an EVM chain, which the - /// off-chain marketplace target breaks. - variant settlement { - /// Settles on an EVM chain, identified by its chain id. - evm-chain(u64), - /// Settles off-chain: the payload is a jurisdiction or a - /// venue-defined domain. Settlement here is a legal or - /// out-of-band process, not a chain state transition. - offchain(string), - } - - /// A non-token service obligation whose worth the host cannot compute, - /// e.g. Swarm postage: storage capacity for a duration. Policy on a - /// service asset is adapter-attested, not host-verified; the consent - /// surface renders `summary` and must say the host verifies nothing. - record service-desc { - /// Namespaced service kind the venue defines, e.g. `swarm:postage`. - /// Stable enough for policy to match on. - kind: string, - /// Adapter-supplied human-readable description for the consent sheet. - summary: string, - } - - /// A real-world asset whose settlement is a legal process rather than a - /// chain state transition: a deed, a chattel, a registry entry. The host - /// verifies nothing about it. The case name mirrors `settlement.offchain` - /// deliberately: the same concept on two axes -- where the asset lives, - /// and where the deal settles. - record offchain-desc { - /// Jurisdiction or registry domain the asset lives in, e.g. an - /// ISO country code or a venue-defined registry name. - domain: string, - /// Adapter-supplied human-readable description for the consent sheet. - summary: string, - } - - /// A kind of value that can move. The ERC cases carry a bare chain id - /// rather than a `settlement` because an ERC token is inherently EVM; - /// `native-token` wraps `settlement` because a chain's own gas token is - /// the one asset that exists on every settlement domain. Each token - /// tuple carries raw big-endian bytes: a 20-byte address, and for the - /// NFT cases a token id of arbitrary width. - variant asset { - /// The settlement domain's own gas token (ETH, BZZ, ...). Only an - /// on-chain settlement carries a gas token, so `native-token` pairs - /// meaningfully with `settlement.evm-chain`; `native-token(offchain)` - /// is representable but intentionally invalid -- an off-chain domain - /// has no gas token -- so consumers MUST reject that pairing rather - /// than ascribe a meaning to it. - native-token(settlement), - /// An ERC-20 token: (chain id, 20-byte contract address). - erc20(tuple>), - /// An ERC-721 NFT: (chain id, 20-byte contract address, token id). - erc721(tuple, list>), - /// An ERC-1155 token: (chain id, 20-byte contract address, token id). - erc1155(tuple, list>), - /// A non-token service, e.g. storage capacity for a duration. - service(service-desc), - /// A real-world asset settled off-chain. - offchain(offchain-desc), - } - - /// An amount of one asset. `amount` is a big-endian unsigned integer, - /// most-significant byte first, with no fixed width; an empty list is - /// zero. Amounts are never negative -- direction lives in whichever - /// field holds the pair (a header's `gives` vs `wants`), not here. - /// - /// The canonical wire form is minimal-length: no leading zero bytes, so - /// zero is the empty list and five is `[0x05]`, never `[0x00, 0x05]`. - /// Encoders MUST emit the minimal form; decoders MUST compare amounts by - /// integer value, not by byte equality, so a non-minimal encoding from a - /// lenient peer still compares equal to its canonical twin. - record asset-amount { - asset: asset, - amount: list, - } -} diff --git a/wit/videre-types/types.wit b/wit/videre-types/types.wit new file mode 100644 index 00000000..e8b69c81 --- /dev/null +++ b/wit/videre-types/types.wit @@ -0,0 +1,83 @@ +package videre:types@0.1.0; + +/// The venue-neutral intent ontology. Depends only on value-flow; never on +/// nexum:host, so the venue-error transport cases are its own. +interface types { + use videre:value-flow/types@0.1.0.{asset-amount}; + + /// How an intent is authorised at its venue. Non-EVM schemes are 0.2+. + variant auth-scheme { + eip1271, + eip712, + } + + /// Where a deal settles. EVM-only in 0.1. + record settlement { + chain: u64, + } + + /// Adapter-derived description of an intent body: the ontology guard policy + /// runs on. Policy has teeth on `gives`; `wants` is display-grade. + record intent-header { + gives: asset-amount, + wants: asset-amount, + settlement: settlement, + authorisation: auth-scheme, + } + + /// Venue-scoped stable id for a submitted intent. Opaque to host and policy. + type receipt = list; + + /// An EVM call the host must sign and send. The adapter only describes it; + /// the host fills gas/fee and signs, so adapters cannot move value. Always + /// a call to existing code. + record unsigned-tx { + chain: u64, + /// 20-byte contract address. + to: list, + /// Native value, big-endian minimal; empty is zero. + value: list, + /// ABI-encoded calldata. + data: list, + } + + /// What a successful submit produced. + variant submit-outcome { + accepted(receipt), + requires-signing(unsigned-tx), + } + + /// Lifecycle state. Coarse and portable; proof and failure reason ride the + /// opaque status body (docs/design/videre-wit-pinned-0.1.0.md). + enum intent-status { + pending, + open, + fulfilled, + cancelled, + expired, + } + + /// Failure of a client or adapter call. `denied` and `rate-limited` are the + /// only guard/transport shapes; `denied` MUST NOT be retried. + variant venue-error { + unknown-venue, + invalid-body(string), + unsupported, + denied(string), + rate-limited(rate-limit), + unavailable(string), + timeout, + } + + record rate-limit { + retry-after-ms: option, + } + + /// An indicative quotation for a body. Firm/RFQ maker-side offers are 0.2+. + record quotation { + gives: asset-amount, + wants: asset-amount, + fee: asset-amount, + valid-until-ms: u64, + } +} diff --git a/wit/videre-value-flow/types.wit b/wit/videre-value-flow/types.wit new file mode 100644 index 00000000..c3aa9de9 --- /dev/null +++ b/wit/videre-value-flow/types.wit @@ -0,0 +1,32 @@ +package videre:value-flow@0.1.0; + +/// Egress-neutral vocabulary for value in motion. Carries no dependency so it +/// outlives any contract built on it. EVM-only in 0.1. +interface types { + /// 20-byte EVM address, big-endian. + type address = list; + + /// Unsigned integer, big-endian, minimal-length: no leading zero bytes, + /// zero is the empty list. Decoders MUST compare by integer value, not by + /// byte equality. + type uint = list; + + /// An ERC-20 token on the intent's settlement chain. + record erc20 { + token: address, + } + + /// A kind of value that can move. erc721/erc1155/service/offchain are 0.2+. + variant asset { + /// The settlement chain's gas token. + native, + erc20(erc20), + } + + /// An amount of one asset. Never negative; direction lives in the field + /// that holds the pair (`gives` vs `wants`). + record asset-amount { + asset: asset, + amount: uint, + } +} diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit new file mode 100644 index 00000000..6516586c --- /dev/null +++ b/wit/videre-venue/venue.wit @@ -0,0 +1,39 @@ +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper names +/// a venue by string. +interface client { + use videre:types/types@0.1.0.{receipt, intent-status, submit-outcome, venue-error}; + + submit: func(venue: string, body: list) -> result; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} + +/// Provider (venue) face. Mirrors `client` without the venue selector: one +/// installed adapter answers for exactly one venue, so the registry resolves +/// a venue id to its adapter and calls it directly. +interface adapter { + use videre:types/types@0.1.0.{intent-header, receipt, intent-status, submit-outcome, venue-error}; + + /// Pure: derive the guard-facing header from a body. No I/O. + derive-header: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; +} + +/// A venue adapter component: the provider face over scoped transport only. +/// No local-store, remote-store, identity, or logging import, so an adapter +/// structurally cannot touch host key material or persistent state. Outbound +/// HTTP is wasi:http, linked separately and allowlisted per adapter. +world venue-adapter { + use nexum:host/types@0.2.0.{config, fault}; + + import nexum:host/chain@0.2.0; + import nexum:host/messaging@0.2.0; + + /// Configure the adapter from its `[config]` before any submission. + export init: func(config: config) -> result<_, fault>; + export adapter; +}