Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
- uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: nextest
# Build all 16 guest module wasms ONCE (release/wasm32-wasip2): the single
# Build all 17 guest module wasms ONCE (release/wasm32-wasip2): the single
# source of truth for guest buildability and the artifacts the integration
# tests load. Replaces the deleted 9-way build-module matrix, which recompiled
# the shared wasm dependency graph ~9x cold. Per-module size report folded in;
Expand All @@ -88,7 +88,7 @@ jobs:
cargo build --release --target wasm32-wasip2 --locked \
-p example -p twap-monitor -p ethflow-watcher -p price-alert \
-p balance-tracker -p stop-loss -p http-probe -p echo-venue \
-p echo-client -p clock-reader -p flaky-bomb -p flaky-venue \
-p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \
-p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host
{
echo "### module .wasm sizes"
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ members = [
"modules/example",
"modules/examples/balance-tracker",
"modules/examples/echo-client",
"modules/examples/echo-keeper",
"modules/examples/echo-venue",
"modules/examples/http-probe",
"modules/examples/price-alert",
Expand Down
99 changes: 40 additions & 59 deletions crates/cow-venue/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,65 +1,40 @@
//! The typed CoW intent client.
//! The CoW venue as a keeper types it.
//!
//! [`CowClient`] binds the keeper-facing [`IntentClient`] to the CoW
//! venue id and speaks the venue's own [`CowIntentBody`] over it, so
//! keeper code submits a typed CoW body without naming the venue on
//! every call or handling wire bytes. The classification API
//! [`CowVenue`] names the venue once - the id its adapter registers
//! under and the [`CowIntentBody`] schema it decodes - so keeper code
//! drives it through [`VenueClient`] with typed bodies, never 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 videre_sdk::client::{ClientError, IntentClient, VenueClient, VenueId};
use videre_sdk::{IntentStatus, SubmitOutcome};
use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId};

use crate::body::CowIntentBody;

/// The venue id the CoW adapter registers under and the registry resolves.
/// Every [`CowClient`] call routes here.
pub const VENUE: &str = "cow";
/// The CoW venue marker: every [`CowClient`] call routes to
/// [`Venue::ID`] and encodes a [`CowIntentBody`].
#[derive(Clone, Copy, Debug)]
pub struct CowVenue;

/// A typed intent client pre-bound to the CoW venue. A thin newtype over
/// [`IntentClient`] that fixes the venue id and the body type so callers
/// cannot mis-route or submit a foreign body.
#[derive(Clone, Debug)]
pub struct CowClient<P> {
inner: IntentClient<P>,
impl Venue for CowVenue {
const ID: VenueId = VenueId::from_static("cow");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a second, unenforced source of venue identity. #[videre_sdk::venue] derives an adapter's registration id from its own module.toml, but Venue::ID on the keeper side is a hand-typed string literal in a completely separate crate, with no macro, build script, or type-level link back to the adapter's manifest. If the CoW adapter's registered id in module.toml ever changes (or a second deployment registers under a different id), CowVenue::ID keeps compiling and silently routes to a venue id that no longer resolves — failing only at runtime with a routing fault, not a compile error. Worth generating Venue impls (or at least ID) from the same manifest source #[videre_sdk::venue] reads, or having the venue macro emit a shared marker type the keeper side imports instead of hand-typing the string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed still valid at HEAD. Both sources are still there and still unlinked:

  • cow-venue/module.toml -> [module] name = "cow", whose own comment says "The manifest name is the venue id the registry installs", and which #[videre_sdk::venue] reads.
  • cow-venue/src/client.rs -> const ID: VenueId = VenueId::from_static("cow"), hand-typed.

Tracked in #543 (M3), to land pre-cleave while the macro (L2) and the adapter (L3) are still one repo.

One framing correction: at the tip both sources live in the same crate (shepherd/crates/cow-venue/), in adjacent files, rather than "a completely separate crate". That narrows the drift window but does not close it, since there is still no compile-time link, and your failure mode is exactly right: a renamed [module] name leaves CowVenue::ID compiling and routing to an id that no longer resolves, surfacing only as a runtime routing fault.

Because they are colocated and the venue macro already parses the manifest, there is a cheaper fix than codegen: have #[videre_sdk::venue] emit a compile-time assertion that the crate's Venue::ID equals the manifest-derived name. Drift becomes a compile error while Venue::ID stays explicit and greppable. #543 records that as the preferred option, with full generation as the stronger alternative.

Unrelated to this thread but worth closing the loop on your opening comparison: #453's block_on has since been brought in line with rt::complete. It no longer loops, it polls once and fails loud on Pending (a named panic rather than a silent spin), so the two paths now share the same single-poll posture.

type Body = CowIntentBody;
}

impl<P: VenueClient> CowClient<P> {
/// Bind a client handle to the CoW venue.
pub fn new(venues: P) -> Self {
Self {
inner: IntentClient::new(venues, VENUE),
}
}

/// The venue id every call routes to (always [`VENUE`]).
pub fn venue(&self) -> &VenueId {
self.inner.venue()
}

/// Encode a typed CoW body and submit it to the venue.
pub fn submit(&self, body: &CowIntentBody) -> Result<SubmitOutcome, ClientError> {
self.inner.submit(body)
}

/// Report where a previously submitted intent is in its life.
pub fn status(&self, receipt: &[u8]) -> Result<IntentStatus, ClientError> {
self.inner.status(receipt)
}

/// Ask the venue to withdraw an intent.
pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> {
self.inner.cancel(receipt)
}
}
/// A typed client pre-bound to the CoW venue: callers cannot mis-route
/// or submit a foreign body.
pub type CowClient<T = HostVenues> = VenueClient<CowVenue, T>;

#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
use videre_sdk::VenueFault;

use videre_sdk::client::VenueTransport;
use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault};

use super::*;

/// One recorded submit: the venue it routed to and the wire bytes.
type SubmitLog = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
Expand All @@ -72,27 +47,31 @@ mod tests {
submitted: SubmitLog,
}

impl VenueClient for SpyClient {
fn quote(
&self,
_venue: &VenueId,
_body: Vec<u8>,
) -> Result<videre_sdk::Quotation, VenueFault> {
impl VenueTransport for SpyClient {
async fn quote(&self, _venue: &VenueId, _body: Vec<u8>) -> Result<Quotation, VenueFault> {
unreachable!("quote not exercised")
}

fn submit(&self, venue: &VenueId, body: Vec<u8>) -> Result<SubmitOutcome, VenueFault> {
async fn submit(
&self,
venue: &VenueId,
body: Vec<u8>,
) -> Result<SubmitOutcome, VenueFault> {
self.submitted
.borrow_mut()
.push((venue.to_string(), body.clone()));
Ok(SubmitOutcome::Accepted(body))
}

fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<IntentStatus, VenueFault> {
async fn status(
&self,
_venue: &VenueId,
_receipt: &[u8],
) -> Result<IntentStatus, VenueFault> {
unreachable!("status not exercised")
}

fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> {
async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> {
unreachable!("cancel not exercised")
}
}
Expand Down Expand Up @@ -124,13 +103,15 @@ mod tests {
let body = sample_body();
let expected = body.to_bytes().expect("body encodes");

let client = CowClient::new(spy.clone());
assert_eq!(client.venue().as_str(), VENUE);
client.submit(&body).expect("submit succeeds");
let client = CowClient::with_transport(spy.clone());
assert_eq!(client.venue(), CowVenue::ID);
videre_sdk::rt::complete(client.submit(&body))
.expect("guest futures complete in one poll")
.expect("submit succeeds");

let calls = spy.submitted.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, VENUE);
assert_eq!(calls[0].0, CowVenue::ID.as_str());
assert_eq!(calls[0].1, expected);
}
}
2 changes: 1 addition & 1 deletion crates/cow-venue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,4 @@ pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource};
#[cfg(feature = "client")]
pub use classification::{ClassificationTable, classify, is_already_submitted};
#[cfg(feature = "client")]
pub use client::{CowClient, VENUE};
pub use client::{CowClient, CowVenue};
88 changes: 88 additions & 0 deletions crates/videre-host/tests/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,94 @@ async fn e2e_echo_module_registry_adapter_round_trip() {
);
}

/// The blessed keeper path over the same two real components: the
/// echo-keeper module (built by `#[videre_sdk::keeper]`) drives the
/// echo-venue adapter through the typed `VenueClient<EchoVenue>` -
/// quote, submit, status, cancel, all with a typed body - and receives
/// the fulfilled `intent-status` the registry polls back. Proves the
/// macro-emitted worker and the typed client end to end, with no
/// hand-written byte marshalling on the keeper side.
#[tokio::test]
async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() {
let (Some(adapter_wasm), Some(module_wasm)) = (
module_wasm_or_skip("echo-venue"),
module_wasm_or_skip("echo-keeper"),
) else {
return;
};

let chain = MockChainProvider::new();
chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\"");
let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new());
let logs = components.logs.clone();

let engine = make_wasmtime_engine();
let config = EngineConfig {
adapters: vec![AdapterEntry {
path: adapter_wasm,
manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")),
http_allow: Vec::new(),
messaging_topics: Vec::new(),
}],
modules: vec![ModuleEntry {
path: module_wasm,
manifest: Some(workspace_path("modules/examples/echo-keeper/module.toml")),
}],
..Default::default()
};
let videre = Arc::new(platform(&config));
let extensions = videre_assembly(&videre);
let linker = make_linker(&engine, &extensions);

let mut supervisor =
Supervisor::boot(&engine, &linker, &config, &components, &extensions, None)
.await
.expect("boot");
assert_eq!(
supervisor.adapter_alive_count(),
1,
"echo-venue is routable"
);
assert_eq!(supervisor.alive_count(), 1, "echo-keeper is alive");

// One block drives the keeper's async on_block: quote, submit,
// status, cancel, all through the typed client.
assert_eq!(supervisor.dispatch_block(block(1)).await, 1);

// The accepted receipt is under status watch; echo settles
// instantly, so the first poll fans the terminal status back.
let registry = registry_of(&supervisor);
let mut delivered = 0;
for _ in 0..2 {
for update in registry.poll_status_transitions().await {
assert_eq!(update.venue, "echo-venue");
delivered += supervisor
.dispatch_extension_event(status_event(update))
.await;
}
}
assert_eq!(delivered, 1, "one terminal status delivered to the keeper");
assert_eq!(supervisor.alive_count(), 1, "keeper must remain alive");

// Every typed verb observably ran.
let runs = logs.list_runs("echo-keeper");
assert_eq!(runs.len(), 1, "one run recorded for echo-keeper");
let page = logs.read(&runs[0].run, 0);
let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect();
for needle in [
"quoted at echo-venue",
"submitted to echo-venue",
"status at echo-venue",
"cancelled at echo-venue",
"intent status from venue echo-venue",
] {
assert!(
messages.iter().any(|m| m.contains(needle)),
"missing `{needle}`; records were: {messages:?}",
);
}
}

/// The body-version handshake refuses a mismatched pair: an adapter
/// decoding only v1 against a keeper encoding v2 fails the boot at the
/// keeper's install, before instantiation, naming both sides' versions.
Expand Down
2 changes: 1 addition & 1 deletion crates/videre-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Proc-macro glue for videre venue adapters: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; derive(IntentBody) emits the versioned body codec."
description = "Proc-macro glue for the videre personas: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; #[keeper] emits the worker world wired to the typed venue client; derive(IntentBody) emits the versioned body codec."

[lib]
proc-macro = true
Expand Down
Loading
Loading