From 774d64da3557b00ab1fcfdf0772901e281487b0b Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 06:16:09 +0000 Subject: [PATCH] sdk: rename to videre-sdk and add the keeper sweep assembler and typed venue client Rename nexum-venue-sdk to videre-sdk across the workspace. Add the generic Keeper::sweep assembler (WatchSet to Gates to poll to Retrier to Journal) over a shared Sweep outcome resolving the dangling ConditionalSource::Outcome; the world-neutral primitives stay in nexum-sdk. Thread a VenueId newtype through the client seam, add the owned VenueFault mirror (retry-after-ms preserved) behind ClientError, mark the client-facing enums non-exhaustive, and seal IntentBody to its derive. --- Cargo.lock | 35 +- Cargo.toml | 2 +- crates/cow-venue/Cargo.toml | 4 +- crates/cow-venue/src/body.rs | 6 +- crates/cow-venue/src/client.rs | 22 +- crates/cow-venue/src/lib.rs | 2 +- crates/nexum-macros/src/intent_body.rs | 31 +- crates/nexum-macros/src/lib.rs | 16 +- crates/nexum-venue-test/Cargo.toml | 2 +- crates/nexum-venue-test/src/codec.rs | 4 +- crates/nexum-venue-test/src/header.rs | 8 +- crates/nexum-venue-test/src/reference.rs | 4 +- crates/nexum-venue-test/src/transport.rs | 2 +- crates/nexum-venue-test/tests/conformance.rs | 12 +- crates/no-std-probe/Cargo.toml | 2 +- crates/no-std-probe/src/lib.rs | 2 +- crates/videre-host/tests/platform.rs | 2 +- .../Cargo.toml | 8 +- .../src/adapter.rs | 8 +- .../src/bindings.rs | 2 +- .../src/body.rs | 11 +- .../src/client.rs | 98 ++-- .../src/faults.rs | 59 ++- crates/videre-sdk/src/keeper.rs | 451 ++++++++++++++++++ .../src/lib.rs | 34 +- .../src/transport.rs | 0 .../tests/adapter.rs | 43 +- docs/05-sdk-design.md | 4 +- justfile | 2 +- modules/examples/echo-venue/Cargo.toml | 2 +- modules/examples/echo-venue/module.toml | 2 +- modules/examples/echo-venue/src/lib.rs | 4 +- modules/fixtures/flaky-venue/Cargo.toml | 2 +- modules/fixtures/flaky-venue/src/lib.rs | 2 +- 34 files changed, 724 insertions(+), 164 deletions(-) rename crates/{nexum-venue-sdk => videre-sdk}/Cargo.toml (73%) rename crates/{nexum-venue-sdk => videre-sdk}/src/adapter.rs (95%) rename crates/{nexum-venue-sdk => videre-sdk}/src/bindings.rs (94%) rename crates/{nexum-venue-sdk => videre-sdk}/src/body.rs (91%) rename crates/{nexum-venue-sdk => videre-sdk}/src/client.rs (61%) rename crates/{nexum-venue-sdk => videre-sdk}/src/faults.rs (73%) create mode 100644 crates/videre-sdk/src/keeper.rs rename crates/{nexum-venue-sdk => videre-sdk}/src/lib.rs (76%) rename crates/{nexum-venue-sdk => videre-sdk}/src/transport.rs (100%) rename crates/{nexum-venue-sdk => videre-sdk}/tests/adapter.rs (87%) diff --git a/Cargo.lock b/Cargo.lock index d0d8f6e4..56f41150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,10 +1559,10 @@ version = "0.1.0" dependencies = [ "borsh", "nexum-sdk", - "nexum-venue-sdk", "serde", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "videre-sdk", ] [[package]] @@ -2162,8 +2162,8 @@ dependencies = [ name = "echo-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", "nexum-venue-test", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -2381,7 +2381,7 @@ dependencies = [ name = "flaky-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -3692,18 +3692,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nexum-venue-sdk" -version = "0.1.0" -dependencies = [ - "borsh", - "nexum-macros", - "nexum-sdk", - "strum", - "thiserror 2.0.18", - "wit-bindgen 0.59.0", -] - [[package]] name = "nexum-venue-test" version = "0.1.0" @@ -3713,11 +3701,11 @@ dependencies = [ "http", "nexum-sdk", "nexum-sdk-test", - "nexum-venue-sdk", "serde", "serde_json", "tempfile", "thiserror 2.0.18", + "videre-sdk", ] [[package]] @@ -3732,7 +3720,7 @@ dependencies = [ name = "no-std-probe" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", + "videre-sdk", ] [[package]] @@ -6072,6 +6060,19 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "videre-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-macros", + "nexum-sdk", + "nexum-sdk-test", + "strum", + "thiserror 2.0.18", + "wit-bindgen 0.59.0", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d7bf9904..13067951 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/nexum-sdk-test", "crates/nexum-status-body", "crates/nexum-tasks", - "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", "crates/no-std-probe", @@ -19,6 +18,7 @@ members = [ "crates/shepherd-sdk", "crates/shepherd-sdk-test", "crates/videre-host", + "crates/videre-sdk", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 975ea7a9..c7ab7fb4 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -21,7 +21,7 @@ workspace = true borsh = { workspace = true, optional = true } # Source of the `IntentBody` derive and trait the version enum implements, # and the typed intent client the `client` slice binds to the CoW venue. -nexum-venue-sdk = { path = "../nexum-venue-sdk", optional = true } +videre-sdk = { path = "../videre-sdk", optional = true } # `client` slice only: the keeper `RetryAction` the generated # classification table maps each errorType to. The TOML parse happens in # `build.rs`, so serde/toml/thiserror are build- and dev-only and never @@ -47,5 +47,5 @@ thiserror = { workspace = true } # depend on a future `adapter` slice without pulling the codec or the # keeper transitively. default = ["body"] -body = ["dep:borsh", "dep:nexum-venue-sdk"] +body = ["dep:borsh", "dep:videre-sdk"] client = ["body", "dep:nexum-sdk"] diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 3cad2c1e..8caed852 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -5,13 +5,13 @@ //! per-venue version enum the venue publishes, and `#[derive(IntentBody)]` //! gives it the borsh codec: a one-byte version tag plus the borsh //! payload, with an unknown tag failing as a typed -//! [`BodyError`](nexum_venue_sdk::BodyError) rather than a stringly borsh +//! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh //! error. The one non-obvious invariant: the tag order is the schema, so //! new versions append at the end and no variant is ever reordered or //! removed. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::IntentBody; +use videre_sdk::IntentBody; use crate::composable::ComposableBody; use crate::order::OrderBody; @@ -36,7 +36,7 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { use super::*; - use nexum_venue_sdk::BodyError; + use videre_sdk::BodyError; use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 7ac5c610..b8d4b302 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,8 +8,8 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use nexum_venue_sdk::client::{ClientError, IntentClient, VenueClient}; -use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; +use videre_sdk::client::{ClientError, IntentClient, VenueClient, VenueId}; +use videre_sdk::{IntentStatus, SubmitOutcome}; use crate::body::CowIntentBody; @@ -34,7 +34,7 @@ impl CowClient

{ } /// The venue id every call routes to (always [`VENUE`]). - pub fn venue(&self) -> &str { + pub fn venue(&self) -> &VenueId { self.inner.venue() } @@ -57,9 +57,9 @@ impl CowClient

{ #[cfg(test)] mod tests { use super::*; - use nexum_venue_sdk::VenueError; use std::cell::RefCell; use std::rc::Rc; + use videre_sdk::VenueFault; /// One recorded submit: the venue it routed to and the wire bytes. type SubmitLog = Rc)>>>; @@ -75,24 +75,24 @@ mod tests { impl VenueClient for SpyClient { fn quote( &self, - _venue: &str, + _venue: &VenueId, _body: Vec, - ) -> Result { + ) -> Result { unreachable!("quote not exercised") } - fn submit(&self, venue: &str, body: Vec) -> Result { + fn submit(&self, venue: &VenueId, body: Vec) -> Result { self.submitted .borrow_mut() .push((venue.to_string(), body.clone())); Ok(SubmitOutcome::Accepted(body)) } - fn status(&self, _venue: &str, _receipt: &[u8]) -> Result { + fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { unreachable!("status not exercised") } - fn cancel(&self, _venue: &str, _receipt: &[u8]) -> Result<(), VenueError> { + fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { unreachable!("cancel not exercised") } } @@ -118,14 +118,14 @@ mod tests { #[test] fn submit_routes_to_the_cow_venue_with_encoded_body() { - use nexum_venue_sdk::IntentBody; + use videre_sdk::IntentBody; let spy = SpyClient::default(); let body = sample_body(); let expected = body.to_bytes().expect("body encodes"); let client = CowClient::new(spy.clone()); - assert_eq!(client.venue(), VENUE); + assert_eq!(client.venue().as_str(), VENUE); client.submit(&body).expect("submit succeeds"); let calls = spy.submitted.borrow(); diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 230f6779..147d4920 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -6,7 +6,7 @@ //! typed client and the adapter component are later slices. //! //! The body slice is dependency-light on purpose. It links only the -//! venue SDK (for the [`IntentBody`](nexum_venue_sdk::IntentBody) derive) +//! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) //! and borsh, so a venue adapter component or a strategy module can carry //! the body types and codec without dragging in the host-side CoW //! machinery. The crate is `#![no_std]` (tests aside): the derive's diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs index cc8fe54b..8ebbc3d6 100644 --- a/crates/nexum-macros/src/intent_body.rs +++ b/crates/nexum-macros/src/intent_body.rs @@ -11,7 +11,7 @@ //! its type's `BorshDeserialize`. //! //! Generated code names the venue SDK by its crate path -//! (`::nexum_venue_sdk`), so the derive is only usable through that +//! (`::videre_sdk`), so the derive is only usable through that //! crate's re-export. The expansion names only `::core` and the SDK's //! `__private` re-exports (borsh, `alloc`), so a `#![no_std]` consumer //! needs no `extern crate alloc`. @@ -82,12 +82,12 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { encode_arms.push(quote! { Self::#ident(payload) => { - let mut out = ::nexum_venue_sdk::body::__private::alloc::vec::Vec::new(); + let mut out = ::videre_sdk::body::__private::alloc::vec::Vec::new(); out.push(#tag); - ::nexum_venue_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( - |err| ::nexum_venue_sdk::body::BodyError::Encode { + ::videre_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( + |err| ::videre_sdk::body::BodyError::Encode { version: #tag, - detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string(&err), + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string(&err), }, )?; ::core::result::Result::Ok(out) @@ -95,10 +95,10 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { }); decode_arms.push(quote! { #tag => ::core::result::Result::Ok(Self::#ident( - ::nexum_venue_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) - .map_err(|err| ::nexum_venue_sdk::body::BodyError::Malformed { + ::videre_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) + .map_err(|err| ::videre_sdk::body::BodyError::Malformed { version: #tag, - detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string( + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string( &err, ), })?, @@ -108,12 +108,15 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { Ok(quote! { #[automatically_derived] - impl ::nexum_venue_sdk::body::IntentBody for #name { + impl ::videre_sdk::body::__private::Derived for #name {} + + #[automatically_derived] + impl ::videre_sdk::body::IntentBody for #name { fn to_bytes( &self, ) -> ::core::result::Result< - ::nexum_venue_sdk::body::__private::alloc::vec::Vec, - ::nexum_venue_sdk::body::BodyError, + ::videre_sdk::body::__private::alloc::vec::Vec, + ::videre_sdk::body::BodyError, > { match self { #(#encode_arms)* @@ -122,14 +125,14 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { fn from_bytes( bytes: &[u8], - ) -> ::core::result::Result { + ) -> ::core::result::Result { let (version, payload) = bytes .split_first() - .ok_or(::nexum_venue_sdk::body::BodyError::Empty)?; + .ok_or(::videre_sdk::body::BodyError::Empty)?; match *version { #(#decode_arms)* version => ::core::result::Result::Err( - ::nexum_venue_sdk::body::BodyError::UnknownVersion { version }, + ::videre_sdk::body::BodyError::UnknownVersion { version }, ), } } diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 52f92d95..ec26d462 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -16,7 +16,7 @@ //! over a per-venue version enum. //! //! Consumers reach these through the SDK re-exports (`nexum_sdk::module`, -//! `nexum_venue_sdk::venue`, `nexum_venue_sdk::IntentBody`) rather than +//! `videre_sdk::venue`, `videre_sdk::IntentBody`) rather than //! depending on this crate directly. mod intent_body; @@ -36,7 +36,7 @@ use syn::{DeriveInput, ImplItem, ItemImpl, Type}; /// fails typedly as `BodyError::UnknownVersion`. /// /// Generated code resolves the SDK by crate path, so use the -/// `nexum_venue_sdk::IntentBody` re-export with `nexum-venue-sdk` as a +/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a /// direct dependency. #[proc_macro_derive(IntentBody)] pub fn derive_intent_body(input: TokenStream) -> TokenStream { @@ -307,7 +307,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { return syn::Error::new( proc_macro2::Span::call_site(), - "#[nexum_venue_sdk::venue] takes no arguments", + "#[videre_sdk::venue] takes no arguments", ) .to_compile_error() .into(); @@ -319,7 +319,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !is_plain_type(self_ty) { return syn::Error::new_spanned( self_ty, - "#[nexum_venue_sdk::venue] must be applied to an inherent impl of a named type", + "#[videre_sdk::venue] must be applied to an inherent impl of a named type", ) .to_compile_error() .into(); @@ -327,7 +327,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if let Some((_, trait_path, _)) = &input.trait_ { return syn::Error::new_spanned( trait_path, - "#[nexum_venue_sdk::venue] must be applied to an inherent impl, not a trait impl", + "#[videre_sdk::venue] must be applied to an inherent impl, not a trait impl", ) .to_compile_error() .into(); @@ -335,7 +335,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !input.generics.params.is_empty() { return syn::Error::new_spanned( &input.generics, - "#[nexum_venue_sdk::venue] must be applied to a non-generic impl", + "#[videre_sdk::venue] must be applied to a non-generic impl", ) .to_compile_error() .into(); @@ -355,7 +355,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { return syn::Error::new_spanned( self_ty, format!( - "#[nexum_venue_sdk::venue] requires the adapter face; this impl is missing {:?}. \ + "#[videre_sdk::venue] requires the adapter face; this impl is missing {:?}. \ Define all of `derive_header`, `quote`, `submit`, `status`, `cancel` (plus an \ optional `init`)", missing @@ -553,7 +553,7 @@ fn derive_module_world() -> Result<(Vec, world::ModuleWorld), String> { /// declarations. Returns the manifest path (for the rebuild anchor) /// alongside the world. fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { - let (manifest_path, declared) = read_manifest_capabilities("#[nexum_venue_sdk::venue]")?; + let (manifest_path, declared) = read_manifest_capabilities("#[videre_sdk::venue]")?; let venue_world = world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; Ok((manifest_path, venue_world)) diff --git a/crates/nexum-venue-test/Cargo.toml b/crates/nexum-venue-test/Cargo.toml index 67eb2e3c..c4fd08dc 100644 --- a/crates/nexum-venue-test/Cargo.toml +++ b/crates/nexum-venue-test/Cargo.toml @@ -31,7 +31,7 @@ nexum-sdk = { path = "../nexum-sdk" } nexum-sdk-test = { path = "../nexum-sdk-test" } # The contract under test: `IntentBody`, `BodyError`, the intent # header types, and the `MessagingHost` seam. -nexum-venue-sdk = { path = "../nexum-venue-sdk" } +videre-sdk = { path = "../videre-sdk" } serde = { workspace = true } serde_json.workspace = true thiserror.workspace = true diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index 52d46226..538db948 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -13,8 +13,8 @@ use std::path::Path; -use nexum_venue_sdk::{BodyError, IntentBody}; use serde::{Deserialize, Serialize}; +use videre_sdk::{BodyError, IntentBody}; use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; @@ -244,7 +244,7 @@ impl CodecVector { #[cfg(test)] mod tests { use borsh::{BorshDeserialize, BorshSerialize}; - use nexum_venue_sdk::IntentBody; + use videre_sdk::IntentBody; use super::*; diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index 6c32af65..5791859e 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -16,9 +16,9 @@ use std::fmt; use std::path::Path; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; -use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{AuthScheme, IntentHeader, Settlement}; use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; @@ -277,8 +277,8 @@ impl HeaderGoldens { #[cfg(test)] mod tests { - use nexum_venue_sdk::VenueError; - use nexum_venue_sdk::value_flow::Erc20; + use videre_sdk::VenueError; + use videre_sdk::value_flow::Erc20; use super::*; diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs index be6eef02..6ad7e546 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, Erc20}; -use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; /// The published codec vector file, verbatim. pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/nexum-venue-test/src/transport.rs index 7ec823f3..e90e4b24 100644 --- a/crates/nexum-venue-test/src/transport.rs +++ b/crates/nexum-venue-test/src/transport.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use nexum_sdk::host::{ChainError, ChainHost, Fault}; use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; pub use nexum_sdk_test::{ChainCall, MockChain}; -pub use nexum_venue_sdk::transport::{Message, MessagingHost}; +pub use videre_sdk::transport::{Message, MessagingHost}; /// Composed in-memory transport. Each field exposes the per-seam mock /// so tests can program responses and assert on calls. diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs index 8f0a6b3f..0faa7196 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -1,17 +1,17 @@ //! Acceptance surface for the conformance kit: an adapter written -//! against `nexum-venue-sdk` is held to the published vector and +//! against `videre-sdk` is held to the published vector and //! golden files, and a deliberately divergent adapter is caught by //! them. -use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; -use nexum_venue_sdk::{ - AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, - VenueError, -}; use nexum_venue_test::reference::{ CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, }; use nexum_venue_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, + VenueError, +}; /// An adapter under test: the reference venue implemented through the /// SDK trait, transport injected through the seams so the kit's mocks diff --git a/crates/no-std-probe/Cargo.toml b/crates/no-std-probe/Cargo.toml index 88fb4444..0a5c6279 100644 --- a/crates/no-std-probe/Cargo.toml +++ b/crates/no-std-probe/Cargo.toml @@ -15,4 +15,4 @@ workspace = true [dependencies] # Source of the `IntentBody` derive and trait the probe enum implements. -nexum-venue-sdk = { path = "../nexum-venue-sdk" } +videre-sdk = { path = "../videre-sdk" } diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs index 2b91da39..fd455704 100644 --- a/crates/no-std-probe/src/lib.rs +++ b/crates/no-std-probe/src/lib.rs @@ -4,7 +4,7 @@ #![no_std] #![warn(missing_docs)] -use nexum_venue_sdk::IntentBody; +use videre_sdk::IntentBody; /// The probe schema: one published version over a bare byte payload. #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 462eae22..67ea2120 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -111,7 +111,7 @@ fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { // ── world contract ──────────────────────────────────────────────────── /// The per-component venue-adapter world contract: an adapter built -/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped +/// through `#[videre_sdk::venue]` imports exactly the scoped /// transport its manifest declares (`chain`), by construction of the /// emitted world. The venue side never depended on toolchain elision; /// this pins that it does not regress to it. diff --git a/crates/nexum-venue-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml similarity index 73% rename from crates/nexum-venue-sdk/Cargo.toml rename to crates/videre-sdk/Cargo.toml index f3fd250a..674804aa 100644 --- a/crates/nexum-venue-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "nexum-venue-sdk" +name = "videre-sdk" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Guest-side SDK for venue adapters: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, and typed wrappers over the scoped transport imports." +description = "Guest-side videre SDK: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, the generic keeper sweep assembler, and typed wrappers over the scoped transport imports." [lib] # Plain library - adapters link this and emit their own cdylib for the @@ -31,3 +31,7 @@ nexum-sdk = { path = "../nexum-sdk" } strum.workspace = true thiserror.workspace = true wit-bindgen.workspace = true + +[dev-dependencies] +# In-memory `LocalStoreHost` behind the keeper sweep tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs similarity index 95% rename from crates/nexum-venue-sdk/src/adapter.rs rename to crates/videre-sdk/src/adapter.rs index 25364863..a2d58d9a 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -65,9 +65,9 @@ pub trait VenueAdapter { macro_rules! export_venue_adapter { ($adapter:ty) => { #[doc(hidden)] - struct __NexumVenueAdapterExport; + struct __VidereVenueAdapterExport; - impl $crate::bindings::Guest for __NexumVenueAdapterExport { + impl $crate::bindings::Guest for __VidereVenueAdapterExport { fn init( config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, ) -> ::core::result::Result<(), $crate::Fault> { @@ -76,7 +76,7 @@ macro_rules! export_venue_adapter { } impl $crate::bindings::exports::videre::venue::adapter::Guest - for __NexumVenueAdapterExport + for __VidereVenueAdapterExport { fn body_versions() -> ::std::vec::Vec { <$adapter as $crate::VenueAdapter>::body_versions() @@ -114,7 +114,7 @@ macro_rules! export_venue_adapter { } $crate::bindings::__export_venue_adapter_world!( - __NexumVenueAdapterExport with_types_in $crate::bindings + __VidereVenueAdapterExport with_types_in $crate::bindings ); }; } diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs similarity index 94% rename from crates/nexum-venue-sdk/src/bindings.rs rename to crates/videre-sdk/src/bindings.rs index f40e1585..e735f93c 100644 --- a/crates/nexum-venue-sdk/src/bindings.rs +++ b/crates/videre-sdk/src/bindings.rs @@ -21,6 +21,6 @@ wit_bindgen::generate!({ generate_all, pub_export_macro: true, export_macro_name: "__export_venue_adapter_world", - default_bindings_module: "nexum_venue_sdk::bindings", + default_bindings_module: "videre_sdk::bindings", additional_derives: [PartialEq], }); diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/videre-sdk/src/body.rs similarity index 91% rename from crates/nexum-venue-sdk/src/body.rs rename to crates/videre-sdk/src/body.rs index 58a64e12..eb3a64dd 100644 --- a/crates/nexum-venue-sdk/src/body.rs +++ b/crates/videre-sdk/src/body.rs @@ -19,9 +19,10 @@ use strum::IntoStaticStr; use crate::VenueError; /// The codec between a venue's typed body enum and the opaque bytes the -/// pool and adapter boundaries carry. Implement via -/// `#[derive(IntentBody)]` on the outer version enum. -pub trait IntentBody: Sized { +/// pool and adapter boundaries carry. Sealed to +/// `#[derive(IntentBody)]` on the outer version enum: the derive owns +/// the tag rules, so no hand impl can break them. +pub trait IntentBody: Sized + __private::Derived { /// Encode as the one-byte version tag plus the borsh payload. fn to_bytes(&self) -> Result, BodyError>; @@ -93,4 +94,8 @@ pub mod __private { pub extern crate alloc; pub use borsh; + + /// The [`IntentBody`](super::IntentBody) seal: implemented only by + /// `#[derive(IntentBody)]` expansions. + pub trait Derived {} } diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/videre-sdk/src/client.rs similarity index 61% rename from crates/nexum-venue-sdk/src/client.rs rename to crates/videre-sdk/src/client.rs index aa92da34..8652f849 100644 --- a/crates/nexum-venue-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -2,34 +2,73 @@ //! [`VenueClient`] seam. //! //! 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 keeper code never -//! handles wire bytes. The seam is byte-level on purpose: the +//! typed body meets it. [`IntentClient`] binds one [`VenueId`] and +//! encodes through [`IntentBody`] before submission, so keeper code +//! never handles wire bytes. The seam is byte-level on purpose: the //! 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. +use std::fmt; + use strum::IntoStaticStr; -use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueError}; +use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueFault}; + +/// Venue identifier: the id an adapter registers under and every client +/// call routes to. Opaque beyond equality. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct VenueId(String); + +impl VenueId { + /// The id at its wire spelling. + #[must_use] + 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 AsRef for VenueId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for VenueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} /// Byte-level access to the keeper-facing `videre:venue/client` -/// interface, venue named per call as on the wire. +/// interface, the venue named per call. pub trait VenueClient { /// Price an opaque intent body at the named venue. - fn quote(&self, venue: &str, body: Vec) -> Result; + fn quote(&self, venue: &VenueId, body: Vec) -> Result; /// Submit an opaque intent body to the named venue. - fn submit(&self, venue: &str, body: Vec) -> Result; + fn submit(&self, venue: &VenueId, body: Vec) -> Result; /// Report where a previously submitted intent is in its life. - fn status(&self, venue: &str, receipt: &[u8]) -> Result; + fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result; /// Ask the venue to withdraw an intent. Success means the venue /// accepted the cancellation, not that an in-flight settlement can /// no longer win the race. - fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError>; + fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault>; } /// A typed intent client bound to one venue: encodes an [`IntentBody`] @@ -37,20 +76,20 @@ pub trait VenueClient { #[derive(Clone, Debug)] pub struct IntentClient

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

{ /// Bind a client handle to the venue id the registry resolves. - pub fn new(venues: P, venue: impl Into) -> Self { + pub fn new(venues: P, venue: impl Into) -> Self { Self { venues, venue: venue.into(), } } - /// The venue id every call on this client routes to. - pub fn venue(&self) -> &str { + /// The venue every call on this client routes to. + pub fn venue(&self) -> &VenueId { &self.venue } @@ -59,10 +98,7 @@ impl IntentClient

{ /// the body the venue priced. pub fn quote(&self, body: &B) -> Result, ClientError> { let bytes = body.to_bytes()?; - let quotation = self - .venues - .quote(&self.venue, bytes.clone()) - .map_err(ClientError::Venue)?; + let quotation = self.venues.quote(&self.venue, bytes.clone())?; Ok(Quoted { client: self, bytes, @@ -73,23 +109,17 @@ 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.venues - .submit(&self.venue, bytes) - .map_err(ClientError::Venue) + Ok(self.venues.submit(&self.venue, bytes)?) } /// Report where a previously submitted intent is in its life. pub fn status(&self, receipt: &[u8]) -> Result { - self.venues - .status(&self.venue, receipt) - .map_err(ClientError::Venue) + Ok(self.venues.status(&self.venue, receipt)?) } /// Ask the bound venue to withdraw an intent. pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - self.venues - .cancel(&self.venue, receipt) - .map_err(ClientError::Venue) + Ok(self.venues.cancel(&self.venue, receipt)?) } } @@ -112,10 +142,7 @@ impl Quoted<'_, P> { /// Submit the quoted body to the venue that priced it. pub fn submit(self) -> Result { - self.client - .venues - .submit(&self.client.venue, self.bytes) - .map_err(ClientError::Venue) + Ok(self.client.venues.submit(&self.client.venue, self.bytes)?) } } @@ -124,15 +151,14 @@ impl Quoted<'_, P> { /// /// `IntoStaticStr` yields a snake_case label per case for log and /// metric fields. -#[derive(Clone, Debug, PartialEq, thiserror::Error, IntoStaticStr)] +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum ClientError { /// The typed body failed to encode; nothing crossed the wire. #[error(transparent)] Body(#[from] BodyError), - /// 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), + /// The registry or the venue behind it refused the call. + #[error(transparent)] + Venue(#[from] VenueFault), } diff --git a/crates/nexum-venue-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs similarity index 73% rename from crates/nexum-venue-sdk/src/faults.rs rename to crates/videre-sdk/src/faults.rs index f8e00ce1..9e751747 100644 --- a/crates/nexum-venue-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -1,7 +1,8 @@ //! Conversions between the three failure vocabularies an adapter //! touches: the wire [`Fault`] its exports return, the SDK-neutral //! [`host::Fault`] the transport seams speak, and the [`VenueError`] the -//! intent face reports. +//! intent face reports; plus [`VenueFault`], the owned client-side +//! mirror of the wire error. //! //! Every conversion here is lossy only downward (a structured case folds //! to a payload-bearing string case, never the reverse), so `?` in an @@ -9,10 +10,66 @@ //! vocabulary can carry. use nexum_sdk::host; +use strum::IntoStaticStr; use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; use crate::{Fault, RateLimit, VenueError}; +/// Owned mirror of the wire `venue-error` with `Display`: what typed +/// client code reports when the registry or a venue refuses. The +/// structured retry hint (`rate-limited`'s `retry-after-ms`) survives +/// the lift. +/// +/// `IntoStaticStr` yields a snake_case label per case for log and +/// metric fields. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum VenueFault { + /// No adapter is registered under the named venue id. + #[error("unknown venue")] + UnknownVenue, + /// The venue rejected the body as malformed. + #[error("invalid body: {0}")] + InvalidBody(String), + /// The venue does not support the operation. + #[error("unsupported")] + Unsupported, + /// The venue or a policy refused the call. + #[error("denied: {0}")] + Denied(String), + /// The venue throttled the call. + #[error("rate limited{}", retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] + RateLimited { + /// Venue-suggested wait before retrying, in milliseconds. + retry_after_ms: Option, + }, + /// The venue is temporarily unreachable or failing. + #[error("unavailable: {0}")] + Unavailable(String), + /// The call timed out. + #[error("timeout")] + Timeout, +} + +/// Lift the wire error into the owned mirror. Exhaustive: the wire enum +/// is this crate's own bindgen, so a new WIT case fails here first. +impl From for VenueFault { + fn from(err: VenueError) -> Self { + match err { + VenueError::UnknownVenue => Self::UnknownVenue, + VenueError::InvalidBody(s) => Self::InvalidBody(s), + VenueError::Unsupported => Self::Unsupported, + VenueError::Denied(s) => Self::Denied(s), + VenueError::RateLimited(rl) => Self::RateLimited { + retry_after_ms: rl.retry_after_ms, + }, + VenueError::Unavailable(s) => Self::Unavailable(s), + VenueError::Timeout => Self::Timeout, + } + } +} + /// Lift the wire fault into the SDK-neutral vocabulary the transport /// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is /// this crate's own bindgen, so a new WIT case fails here first. diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs new file mode 100644 index 00000000..23d2ae5d --- /dev/null +++ b/crates/videre-sdk/src/keeper.rs @@ -0,0 +1,451 @@ +//! The generic keeper sweep: one pass assembling the world-neutral +//! stores - [`WatchSet`] to [`Gates`] to [`ConditionalSource::poll`] to +//! [`Retrier`] to [`Journal`] - and routing submissions through the +//! [`VenueClient`] seam. +//! +//! [`Sweep`] is the shared poll outcome: the concrete +//! [`ConditionalSource::Outcome`] a keeper's sources produce so +//! [`Keeper::sweep`] can act on every one of them. The world-neutral +//! primitives stay in `nexum_sdk::keeper`; this module only assembles +//! them. + +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; +use nexum_sdk::prelude::{hex, keccak256}; + +use crate::client::{VenueClient, VenueId}; +use crate::{SubmitOutcome, UnsignedTx, VenueFault}; + +/// What one poll asks the sweep to do with its watch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Sweep { + /// Submit these encoded intent-body bytes to the bound venue. + Submit(Vec), + /// Nothing to do yet; the next tick re-polls. + WaitBlock, + /// Gate the watch for `seconds` on the epoch clock. + Backoff { + /// Seconds to wait before the next poll. + seconds: u64, + }, + /// The commitment is spent or unservable; drop the watch. + Drop, +} + +/// A keeper: one conditional source bound to one venue, swept over the +/// keeper stores. +pub struct Keeper { + source: S, + venues: P, + venue: VenueId, +} + +impl Keeper { + /// Bind a source to the venue id its submissions route to. + pub fn new(source: S, venues: P, venue: impl Into) -> Self { + Self { + source, + venues, + venue: venue.into(), + } + } + + /// The venue every submission routes to. + pub fn venue(&self) -> &VenueId { + &self.venue + } +} + +impl Keeper { + /// Sweep the watch set once at `tick`: poll every ready watch, + /// submit [`Sweep::Submit`] bodies through the venue client, and + /// run every other outcome and every venue refusal through the + /// [`Retrier`]. A venue-and-body key is checked against the + /// `submitted:` [`Journal`] before every submit and recorded on + /// acceptance, so an accepted body never reaches the venue twice; + /// a `requires-signing` answer journals nothing and is surfaced + /// afresh each sweep. Store faults abort the sweep; venue refusals + /// never do - they fold into per-watch retry actions. + pub fn sweep(&self, host: &H, tick: &Tick) -> Result + where + H: LocalStoreHost, + S: ConditionalSource, + { + let watches = WatchSet::new(host); + let gates = Gates::new(host); + let retrier = Retrier::new(host); + let journal = Journal::submitted(host); + let mut report = SweepReport::default(); + + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + report.skipped += 1; + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + report.gated += 1; + continue; + } + let Some(params) = watches.get(watch)? else { + report.skipped += 1; + continue; + }; + report.polled += 1; + + let action = match self.source.poll(host, watch, ¶ms, tick) { + Sweep::Submit(body) => { + let key = submission_key(&self.venue, &body); + if journal.contains(&key)? { + report.duplicates += 1; + continue; + } + match self.venues.submit(&self.venue, body) { + Ok(SubmitOutcome::Accepted(_)) => { + journal.record(&key)?; + report.submitted += 1; + continue; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + report.unsigned.push(tx); + continue; + } + Err(fault) => retry_action(fault), + } + } + Sweep::WaitBlock => RetryAction::TryNextBlock, + Sweep::Backoff { seconds } => RetryAction::Backoff { seconds }, + Sweep::Drop => RetryAction::Drop, + }; + match action { + RetryAction::Drop => report.dropped += 1, + _ => report.retried += 1, + } + retrier.apply(watch, action, tick.epoch_s)?; + } + Ok(report) + } +} + +/// One sweep's tally, by watch disposition. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct SweepReport { + /// Watches polled. + pub polled: usize, + /// Watches skipped by an unexpired gate. + pub gated: usize, + /// Watches skipped unread: a malformed key, or a row that vanished + /// mid-sweep. + pub skipped: usize, + /// Bodies the venue accepted, submission key newly journalled. + pub submitted: usize, + /// Bodies whose key an earlier sweep had journalled, skipped + /// without a venue call. + pub duplicates: usize, + /// Watches left in place for a later tick. + pub retried: usize, + /// Watches dropped. + pub dropped: usize, + /// Transactions the venue answered `requires-signing`; a sweep + /// cannot sign, so the caller owns them. + pub unsigned: Vec, +} + +/// Deterministic pre-submit journal key: the venue id and the +/// keccak-256 of the body. The hash is a fixed-length suffix, so the +/// key is unambiguous whatever the venue id contains. +fn submission_key(venue: &VenueId, body: &[u8]) -> String { + format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) +} + +/// Fold a venue refusal into the retry action the ledger runs: the +/// throttle hint becomes an epoch gate, transient failures retry next +/// block, and refusals no retry can cure drop the watch. +fn retry_action(fault: VenueFault) -> RetryAction { + match fault { + VenueFault::RateLimited { + retry_after_ms: Some(ms), + } => RetryAction::Backoff { + seconds: ms.div_ceil(1000), + }, + VenueFault::RateLimited { + retry_after_ms: None, + } + | VenueFault::Timeout + | VenueFault::Unavailable(_) => RetryAction::TryNextBlock, + VenueFault::UnknownVenue + | VenueFault::InvalidBody(_) + | VenueFault::Unsupported + | VenueFault::Denied(_) => RetryAction::Drop, + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; + use nexum_sdk::prelude::{Address, B256, hex, keccak256}; + use nexum_sdk_test::MockLocalStore; + + use super::{Keeper, Sweep, SweepReport}; + use crate::client::{VenueClient, VenueId}; + use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; + + /// Answers every poll with one programmed outcome. + struct StubSource(Sweep); + + impl nexum_sdk::keeper::ConditionalSource for StubSource { + type Outcome = Sweep; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + self.0.clone() + } + } + + /// Pops one programmed outcome per poll, from the back. + struct SeqSource(RefCell>); + + impl nexum_sdk::keeper::ConditionalSource for SeqSource { + type Outcome = Sweep; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + self.0.borrow_mut().pop().unwrap_or(Sweep::WaitBlock) + } + } + + /// Answers every submit with one programmed outcome, logging bodies. + struct StubVenue { + outcome: Result, + submitted: RefCell>>, + } + + impl StubVenue { + fn new(outcome: Result) -> Self { + Self { + outcome, + submitted: RefCell::new(Vec::new()), + } + } + } + + impl VenueClient for &StubVenue { + fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + self.submitted.borrow_mut().push(body); + self.outcome.clone() + } + + fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + const TICK: Tick = Tick { + chain_id: 1, + block: 100, + epoch_s: 1_000, + }; + + fn put_watch(host: &MockLocalStore) -> String { + WatchSet::new(host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("mock store accepts the watch") + } + + fn keeper(outcome: Sweep, venue: &StubVenue) -> Keeper { + Keeper::new(StubSource(outcome), venue, "stub") + } + + #[test] + fn accepted_body_is_journalled_and_never_resubmitted() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xA5, 0x5A]))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.polled, 1); + assert_eq!(report.submitted, 1); + assert_eq!(venue.submitted.borrow().as_slice(), [b"body".to_vec()]); + + let journal = Journal::submitted(&host); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!(journal.contains(&key).expect("journal reads")); + assert_eq!(WatchSet::new(&host).list().expect("list reads").len(), 1); + + // A later sweep re-polls the watch but never re-posts the body. + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.submitted, 0); + assert_eq!(report.duplicates, 1); + assert_eq!(venue.submitted.borrow().len(), 1); + } + + #[test] + fn a_changed_body_submits_afresh() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + // Polls pop from the back: `one` first, then `two`. + let source = SeqSource(RefCell::new(vec![ + Sweep::Submit(b"two".to_vec()), + Sweep::Submit(b"one".to_vec()), + ])); + let keeper = Keeper::new(source, &venue, "stub"); + + assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").submitted, 1); + assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").submitted, 1); + assert_eq!( + venue.submitted.borrow().as_slice(), + [b"one".to_vec(), b"two".to_vec()] + ); + } + + #[test] + fn requires_signing_hands_the_transaction_to_the_caller() { + let host = MockLocalStore::default(); + put_watch(&host); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = StubVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.unsigned, vec![tx.clone()]); + assert_eq!(report.submitted, 0); + + // Nothing accepted, nothing journalled: the next sweep + // surfaces the same transaction again. + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.unsigned, vec![tx]); + } + + #[test] + fn gated_watch_is_not_polled() { + let host = MockLocalStore::default(); + let key = put_watch(&host); + let watch = WatchRef::parse(&key).expect("well-formed key"); + Gates::new(&host) + .set_next_block(watch, TICK.block + 1) + .expect("gate writes"); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = keeper(Sweep::Submit(b"body".to_vec()), &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report.gated, 1); + assert_eq!(report.polled, 0); + assert!(venue.submitted.borrow().is_empty()); + } + + #[test] + fn drop_outcome_removes_the_watch() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = keeper(Sweep::Drop, &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report.dropped, 1); + assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); + } + + #[test] + fn backoff_outcome_gates_the_watch_on_the_epoch_clock() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + let keeper = keeper(Sweep::Backoff { seconds: 30 }, &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.retried, 1); + + // Still inside the backoff window: gated, not polled. + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.gated, 1); + + // At the threshold the gate opens again. + let later = Tick { + epoch_s: TICK.epoch_s + 30, + ..TICK + }; + let report = keeper.sweep(&host, &later).expect("sweep runs"); + assert_eq!(report.polled, 1); + } + + #[test] + fn rate_limited_refusal_backs_off_by_the_venue_hint() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.retried, 1); + + // 2500 ms rounds up to a 3 s epoch gate. + let at_2s = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + assert_eq!(keeper.sweep(&host, &at_2s).expect("sweep runs").gated, 1); + let at_3s = Tick { + epoch_s: TICK.epoch_s + 3, + ..TICK + }; + assert_eq!(keeper.sweep(&host, &at_3s).expect("sweep runs").polled, 1); + } + + #[test] + fn non_retryable_refusal_drops_the_watch() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let report = keeper(Sweep::Submit(b"body".to_vec()), &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report.dropped, 1); + assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); + } + + #[test] + fn transient_refusal_leaves_the_watch_for_the_next_tick() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.retried, 1); + assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").polled, 1); + } + + #[test] + fn empty_watch_set_reports_nothing() { + let host = MockLocalStore::default(); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = keeper(Sweep::WaitBlock, &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report, SweepReport::default()); + } +} diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs similarity index 76% rename from crates/nexum-venue-sdk/src/lib.rs rename to crates/videre-sdk/src/lib.rs index 5159ef05..c268e7ca 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -1,9 +1,10 @@ -//! # nexum-venue-sdk +//! # videre-sdk //! -//! Guest-side SDK for venue adapters: the second component kind, one -//! venue's protocol speaker exporting the `venue-adapter` world. Where -//! `nexum-sdk` serves the strategy-module persona, this crate serves the -//! venue author. +//! Guest-side SDK for the videre personas: the venue author (one +//! venue's protocol speaker exporting the `venue-adapter` world) and +//! the keeper author driving venues through the client seam. Where +//! `nexum-sdk` serves the strategy-module persona, this crate serves +//! both venue sides of it. //! //! ## What lives here //! @@ -17,10 +18,17 @@ //! one-byte version tag plus the borsh payload; an unknown tag fails //! typedly rather than as a stringly decode error. //! -//! - [`client`] - the typed intent client core: [`IntentClient`] binds a -//! venue and encodes through [`IntentBody`] before the byte-level -//! [`VenueClient`] seam. Lives here (not in the strategy SDK) so the -//! codec and the client that speaks it version together. +//! - [`client`] - the typed intent client core: [`VenueId`] and +//! [`IntentClient`], which binds a venue and encodes through +//! [`IntentBody`] before the byte-level [`VenueClient`] seam. Lives +//! here (not in the strategy SDK) so the codec and the client that +//! speaks it version together. +//! +//! - [`keeper`] - the generic sweep assembler: [`Keeper::sweep`] runs +//! the world-neutral `nexum_sdk::keeper` stores over a +//! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) +//! producing the shared [`Sweep`] outcome, submitting through the +//! [`VenueClient`] seam. //! //! - [`transport`] - typed wrappers over the world's scoped imports: //! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] @@ -29,7 +37,8 @@ //! wasi:http surface re-exported as [`transport::http`]. //! //! - [`faults`] - the conversions that make `?` work across the wire -//! fault, the SDK-neutral fault, and [`VenueError`]. +//! fault, the SDK-neutral fault, and [`VenueError`]; plus +//! [`VenueFault`], the owned client-side mirror. //! //! ## Why the bindgen lives in this crate //! @@ -53,11 +62,14 @@ pub mod adapter; pub mod body; pub mod client; pub mod faults; +pub mod keeper; pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, Quoted, VenueClient}; +pub use client::{ClientError, IntentClient, Quoted, VenueClient, VenueId}; +pub use faults::VenueFault; +pub use keeper::{Keeper, Sweep, SweepReport}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`nexum_macros::IntentBody`]. pub use nexum_macros::IntentBody; diff --git a/crates/nexum-venue-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs similarity index 100% rename from crates/nexum-venue-sdk/src/transport.rs rename to crates/videre-sdk/src/transport.rs diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs similarity index 87% rename from crates/nexum-venue-sdk/tests/adapter.rs rename to crates/videre-sdk/tests/adapter.rs index 21ab472a..8af3621f 100644 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -6,10 +6,11 @@ //! [`VenueClient`] seam. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; -use nexum_venue_sdk::{ +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, + VenueFault, VenueId, }; /// First published body version: a fixed-price quote. @@ -116,39 +117,39 @@ impl VenueAdapter for DemoAdapter { // The acceptance gate proper: the hand-written adapter exports as the // venue-adapter world. -nexum_venue_sdk::export_venue_adapter!(DemoAdapter); +videre_sdk::export_venue_adapter!(DemoAdapter); /// 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 VenueClient for InProcessClient { - fn quote(&self, venue: &str, body: Vec) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn quote(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::quote(body) + DemoAdapter::quote(body).map_err(Into::into) } - fn submit(&self, venue: &str, body: Vec) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn submit(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::submit(body) + DemoAdapter::submit(body).map_err(Into::into) } - fn status(&self, venue: &str, receipt: &[u8]) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::status(receipt.to_vec()) + DemoAdapter::status(receipt.to_vec()).map_err(Into::into) } - fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError> { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::cancel(receipt.to_vec()) + DemoAdapter::cancel(receipt.to_vec()).map_err(Into::into) } } @@ -254,7 +255,7 @@ fn typed_client_round_trips_through_the_client_seam() { assert!(matches!( client.status(&[0, 1]).unwrap_err(), - ClientError::Venue(VenueError::Denied(_)) + ClientError::Venue(VenueFault::Denied(_)) )); } @@ -284,6 +285,6 @@ 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) + ClientError::Venue(VenueFault::UnknownVenue) )); } diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index c5e4d812..52ed08d0 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -31,7 +31,7 @@ things from the SDK: venue (CoW Protocol, a DEX, a lending market, ...) to modules through a common intent surface, so a module author does not need to know the venue's wire format. This persona is planned but not - yet shipped: the crate (`nexum-venue-sdk`), the per-venue crates + yet shipped: the crate (`videre-sdk`), the per-venue crates (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the `#[nexum::venue]` macro, and the `nexum-venue-test` conformance kit are all tracked by the SDK-surfaces epic and have no code in the @@ -257,7 +257,7 @@ competing vision. The planned shape: -- **`nexum-venue-sdk`** - a new crate carrying the guest-side +- **`videre-sdk`** - a new crate carrying the guest-side `VenueAdapter` trait over the (also planned) adapter-world bindgen, a `borsh`-backed `IntentBody` derive that enforces a per-venue version enum (an adapter rejects an intent body tagged with an diff --git a/justfile b/justfile index 270a8248..696d781f 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ build-module: cargo build --target wasm32-wasip2 --release -p example # Build the reference venue adapter (echo-venue) for wasm32-wasip2. Its -# per-component world pins the #[nexum_venue_sdk::venue] acceptance test. +# per-component world pins the #[videre_sdk::venue] acceptance test. build-venue: cargo build --target wasm32-wasip2 --release -p echo-venue diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml index f900f97b..cd7f6172 100644 --- a/modules/examples/echo-venue/Cargo.toml +++ b/modules/examples/echo-venue/Cargo.toml @@ -12,7 +12,7 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml index 63f9a7a2..bd17af64 100644 --- a/modules/examples/echo-venue/module.toml +++ b/modules/examples/echo-venue/module.toml @@ -1,4 +1,4 @@ -# echo-venue adapter manifest - the reference #[nexum_venue_sdk::venue] +# echo-venue adapter manifest - the reference #[videre_sdk::venue] # component. Declares a single scoped-transport capability (chain), so the # per-component world the macro derives imports nexum:host/chain and # nothing else. diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 9af90589..7e62a376 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -3,7 +3,7 @@ //! The minimal reference venue adapter: it accepts any body, echoes it back //! as the receipt, and settles instantly (every receipt it issued reports //! `fulfilled`). It carries no real venue protocol, so it doubles as the -//! smallest end-to-end demonstration of `#[nexum_venue_sdk::venue]` - the +//! smallest end-to-end demonstration of `#[videre_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 //! the adapter face - and as the `nexum-venue-test` conformance target (see @@ -26,7 +26,7 @@ use videre::value_flow::types::{Asset, AssetAmount}; struct EchoVenue; -#[nexum_venue_sdk::venue] +#[videre_sdk::venue] impl EchoVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(()) diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml index 5237feb0..bcbd4c95 100644 --- a/modules/fixtures/flaky-venue/Cargo.toml +++ b/modules/fixtures/flaky-venue/Cargo.toml @@ -13,5 +13,5 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs index af1c9d9b..0970a63c 100644 --- a/modules/fixtures/flaky-venue/src/lib.rs +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -25,7 +25,7 @@ const POISON_HEAD: &str = "0xdead"; struct FlakyVenue; -#[nexum_venue_sdk::venue] +#[videre_sdk::venue] impl FlakyVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(())