diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9246fdc..9e0f3b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: # Dockerfile build, and the local host sccache — one silo, zero egress cost. # Set at the workflow env level (not the composite) because composite actions # cannot read the `secrets` context. Keys are content-addressed on the full - # compiler input, and the sole build.rs (cow-venue) + nexum-macros are + # compiler input, and the sole build.rs (cow-venue) + the macro crates are # deterministic, so PR builds writing to the shared bucket write correct objects # under correct keys (no poisoning); bound storage with an R2 lifecycle-expiry # rule on the bucket (sccache does not evict cloud backends itself). @@ -78,7 +78,8 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 15 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 18 guest wasms ONCE (17 modules + the cow adapter, + # 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; @@ -88,8 +89,13 @@ 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 fuel-bomb \ - -p memory-bomb -p panic-bomb -p slow-host + -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 + # Separate invocation on purpose: unifying `cow-venue/adapter` + # into the module build would link the adapter's component + # export glue into every keeper module wasm. + cargo build --release --target wasm32-wasip2 --locked \ + -p cow-venue --features cow-venue/adapter { echo "### module .wasm sizes" echo "| module | bytes |" @@ -143,3 +149,30 @@ jobs: CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu + + # Blocking zero-leak gate: host-layer crate graphs stay venue-free, the + # runtime Rust sources carry no charter symbol and no privileged router + # field, and nexum:host names no foreign WIT package and resolves as a + # leaf (scripts/check-venue-agnostic.sh). + venue-agnostic: + name: venue-agnostic + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/rust-setup + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: wasm-tools,ripgrep + - run: ./scripts/check-venue-agnostic.sh + + # Blocking orderbook-only gate: the CoW venue crate carries no + # composable symbol (scripts/check-cow-orderbook-only.sh). + cow-orderbook-only: + name: cow-orderbook-only + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: ripgrep + - run: ./scripts/check-cow-orderbook-only.sh diff --git a/Cargo.lock b/Cargo.lock index b7ab634f..2e31d334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,6 +1483,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "composable-cow" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "borsh", + "cowprotocol", + "nexum-sdk", + "proptest", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -1557,12 +1569,20 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "cow-venue" version = "0.1.0" dependencies = [ + "alloy-primitives", + "alloy-sol-types", "borsh", + "cowprotocol", + "http", "nexum-sdk", - "nexum-venue-sdk", "serde", + "serde_json", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "url", + "videre-sdk", + "videre-test", + "wit-bindgen 0.59.0", ] [[package]] @@ -2158,13 +2178,22 @@ dependencies = [ "wit-bindgen 0.58.0", ] +[[package]] +name = "echo-keeper" +version = "0.1.0" +dependencies = [ + "nexum-sdk", + "videre-sdk", + "wit-bindgen 0.59.0", +] + [[package]] name = "echo-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", - "nexum-venue-test", - "wit-bindgen 0.58.0", + "videre-sdk", + "videre-test", + "wit-bindgen 0.59.0", ] [[package]] @@ -2271,12 +2300,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -2377,6 +2406,14 @@ dependencies = [ "wit-bindgen 0.59.0", ] +[[package]] +name = "flaky-venue" +version = "0.1.0" +dependencies = [ + "videre-sdk", + "wit-bindgen 0.59.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3567,22 +3604,30 @@ name = "nexum-cli" version = "0.2.0" dependencies = [ "anyhow", - "clap", + "nexum-launch", "nexum-runtime", - "shepherd-cow-host", "tokio", +] + +[[package]] +name = "nexum-launch" +version = "0.2.0" +dependencies = [ + "anyhow", + "clap", + "nexum-runtime", "tracing", "tracing-subscriber", ] [[package]] -name = "nexum-macros" +name = "nexum-module-macros" version = "0.1.0" dependencies = [ + "nexum-world", "proc-macro2", "quote", "syn 2.0.118", - "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -3597,6 +3642,7 @@ dependencies = [ "alloy-transport", "alloy-transport-ws", "anyhow", + "async-trait", "bytes", "futures", "http", @@ -3606,6 +3652,7 @@ dependencies = [ "metrics-exporter-prometheus", "nexum-runtime", "nexum-tasks", + "nexum-world", "redb", "serde", "serde_json", @@ -3628,16 +3675,23 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ + "alloy-json-rpc", "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-sol-types", + "alloy-transport", "http", - "nexum-macros", + "nexum-module-macros", "nexum-sdk-test", + "nexum-status-body", + "nexum-world", "proptest", "serde_json", "strum", "thiserror 2.0.18", + "tower", "tracing", "tracing-core", "wstd", @@ -3651,6 +3705,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + [[package]] name = "nexum-tasks" version = "0.1.0" @@ -3661,31 +3723,18 @@ dependencies = [ ] [[package]] -name = "nexum-venue-sdk" +name = "nexum-world" version = "0.1.0" dependencies = [ - "borsh", - "nexum-macros", - "nexum-sdk", - "strum", - "thiserror 2.0.18", - "wit-bindgen 0.59.0", + "tempfile", + "toml 1.1.2+spec-1.1.0", ] [[package]] -name = "nexum-venue-test" +name = "no-std-probe" version = "0.1.0" dependencies = [ - "borsh", - "hex", - "http", - "nexum-sdk", - "nexum-sdk-test", - "nexum-venue-sdk", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", + "videre-sdk", ] [[package]] @@ -5131,18 +5180,32 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shepherd" +version = "0.2.0" +dependencies = [ + "anyhow", + "nexum-launch", + "nexum-runtime", + "shepherd-cow-host", + "tokio", + "videre-host", +] + [[package]] name = "shepherd-backtest" version = "0.1.0" dependencies = [ "anyhow", "clap", + "cow-venue", "ethflow-watcher", "hex", "nexum-sdk", + "nexum-sdk-test", "serde", "serde_json", - "shepherd-sdk-test", + "videre-sdk", ] [[package]] @@ -5151,7 +5214,6 @@ version = "0.2.0" dependencies = [ "alloy-chains", "alloy-primitives", - "alloy-rpc-types-eth", "anyhow", "cowprotocol", "http", @@ -5177,6 +5239,7 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "composable-cow", "cow-venue", "cowprotocol", "nexum-sdk", @@ -5187,6 +5250,7 @@ dependencies = [ "strum", "thiserror 2.0.18", "tracing", + "videre-sdk", ] [[package]] @@ -5194,6 +5258,7 @@ name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ "alloy-primitives", + "composable-cow", "cowprotocol", "nexum-sdk", "nexum-sdk-test", @@ -5865,13 +5930,15 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "composable-cow", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5994,6 +6061,65 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "videre-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "nexum-runtime", + "nexum-status-body", + "nexum-tasks", + "serde", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 1.1.2+spec-1.1.0", + "tracing", + "wasmtime", +] + +[[package]] +name = "videre-macros" +version = "0.1.0" +dependencies = [ + "nexum-world", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "videre-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "http", + "nexum-sdk", + "nexum-sdk-test", + "strum", + "thiserror 2.0.18", + "videre-macros", + "wit-bindgen 0.59.0", +] + +[[package]] +name = "videre-test" +version = "0.1.0" +dependencies = [ + "borsh", + "hex", + "http", + "nexum-sdk", + "nexum-sdk-test", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "videre-sdk", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6ec91e60..4de3f985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,28 +1,38 @@ [workspace] members = [ + "crates/composable-cow", "crates/cow-venue", "crates/nexum-cli", - "crates/nexum-macros", + "crates/nexum-launch", + "crates/nexum-module-macros", "crates/nexum-runtime", "crates/nexum-sdk", "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", + "crates/shepherd", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", "crates/shepherd-sdk-test", + "crates/videre-host", + "crates/videre-macros", + "crates/videre-sdk", + "crates/videre-test", "modules/ethflow-watcher", "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", "modules/examples/stop-loss", "modules/fixtures/clock-reader", "modules/fixtures/flaky-bomb", + "modules/fixtures/flaky-venue", "modules/fixtures/fuel-bomb", "modules/fixtures/memory-bomb", "modules/fixtures/panic-bomb", @@ -54,6 +64,9 @@ anyhow = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } futures = "0.3" +# Cold `dyn` boot paths only (`ProviderKind::install`); hot guest traits +# use native async-fn-in-trait. +async-trait = "0.1" # Serde + config. serde = { version = "1", features = ["derive"] } @@ -101,7 +114,9 @@ clap = { version = "4", features = ["derive"] } # moves every consumer at once. alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +# Featureless here so the guest SDK's wasm build stays transport-free; +# the engine and tooling add ws/ipc/pubsub/reqwest at their call sites. +alloy-provider = { version = "2.1", default-features = false } alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } alloy-transport-ws = { version = "2.1", default-features = false } # Typed EIP-155 chain ids for config keys, provider/orderbook pools, and @@ -129,9 +144,9 @@ http-body = "1" http-body-util = "0.1" bytes = "1" -# Proc-macro toolkit backing `nexum-macros`. Host-side only: the -# proc-macro crate always builds for the host, even when the module -# consuming it targets wasm. +# Proc-macro toolkit backing `nexum-module-macros` and `videre-macros`. +# Host-side only: a proc-macro crate always builds for the host, even +# when the module consuming it targets wasm. proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } @@ -163,7 +178,9 @@ toml = "1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } -# alloy JSON-RPC client + transport (engine chain backend). +# alloy JSON-RPC client + transport (engine chain backend and the +# guest SDK's host-backed transport). +alloy-json-rpc = { version = "2.1", default-features = false } alloy-rpc-client = { version = "2.1", default-features = false } alloy-transport = { version = "2.1", default-features = false } diff --git a/Dockerfile b/Dockerfile index eb7fa3f0..bc53f0fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # syntax=docker/dockerfile:1.6 # -# Multi-stage build for `nexum` (Shepherd) - the engine binary -# plus the five production WASM modules baked into a single image. +# Multi-stage build for `shepherd` - the cow composition-root engine +# binary plus the five production WASM modules and the bundled cow +# venue adapter baked into a single image. # # Stage 1 (`build`): full Rust toolchain + wasm32-wasip2 target, builds # the engine in release mode + each module to a Component Model wasm @@ -65,10 +66,12 @@ FROM chef AS build # changed workspace crate. `--locked` stays on the real builds below, which # validate the committed Cargo.lock verbatim. COPY --from=planner /src/recipe.json recipe.json -RUN cargo chef cook --release -p nexum-cli --recipe-path recipe.json \ +RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss --recipe-path recipe.json + -p balance-tracker -p stop-loss --recipe-path recipe.json \ + && cargo chef cook --release --target wasm32-wasip2 \ + -p cow-venue --features cow-venue/adapter --recipe-path recipe.json # Now the workspace sources. `.dockerignore` keeps the context lean # (no `target/`, no `data/`, no large baseline / backtest fixtures). @@ -77,15 +80,17 @@ COPY . . # Engine binary in release. --locked ensures the committed Cargo.lock # is used verbatim so builds are reproducible. -RUN cargo build -p nexum-cli --release --locked +RUN cargo build -p shepherd --release --locked -# Five production modules. The wasm artefacts land under +# Five production modules plus the bundled cow venue adapter. The wasm +# artefacts land under # `target/wasm32-wasip2/release/.wasm`. RUN cargo build -p twap-monitor --target wasm32-wasip2 --release --locked \ && cargo build -p ethflow-watcher --target wasm32-wasip2 --release --locked \ && cargo build -p price-alert --target wasm32-wasip2 --release --locked \ && cargo build -p balance-tracker --target wasm32-wasip2 --release --locked \ - && cargo build -p stop-loss --target wasm32-wasip2 --release --locked + && cargo build -p stop-loss --target wasm32-wasip2 --release --locked \ + && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter # ----------------------------------------------------------------- runtime @@ -108,7 +113,7 @@ RUN apt-get update \ && install -d -o root -g root -m 0755 /etc/shepherd # Engine binary. -COPY --from=build /src/target/release/nexum /usr/local/bin/nexum +COPY --from=build /src/target/release/shepherd /usr/local/bin/shepherd # Module .wasm artefacts. The Component Model wasm files are loaded # by the engine at boot via the `[[modules]]` entries in engine.toml. @@ -123,6 +128,13 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml +# The bundled cow venue adapter's manifests; installed via the +# engine.toml [[adapters]] stanza, never compiled into the engine. +# One manifest per chain: mainnet (cow-venue.toml) and Sepolia +# (cow-venue.sepolia.toml); pick the one matching the run's chain. +COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml + # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and # binds 127.0.0.1:9100 inside the container. @@ -138,5 +150,5 @@ EXPOSE 9100 # `--engine-config /etc/shepherd/engine.toml` matches the production # guide's expected mount point. Operators override via # `docker run ... -v /path/to/engine.toml:/etc/shepherd/engine.toml:ro`. -ENTRYPOINT ["/usr/bin/tini", "--", "nexum"] +ENTRYPOINT ["/usr/bin/tini", "--", "shepherd"] CMD ["--engine-config", "/etc/shepherd/engine.toml"] diff --git a/README.md b/README.md index 91026dd0..8c94e3ac 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs | Path | Purpose | | --- | --- | | `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | -| `crates/nexum-cli/` | The `nexum` binary - a thin CLI over the runtime library. | +| `crates/nexum-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | +| `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | +| `crates/shepherd/` | The `shepherd` binary - the cow composition root wiring the cow-api extension. | | `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | | `crates/shepherd-sdk/` | CoW-domain guest SDK - the cow-api trait and CoW Protocol helpers on top of `nexum-sdk`. | | `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml new file mode 100644 index 00000000..ceb66cc6 --- /dev/null +++ b/crates/composable-cow/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "composable-cow" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "ComposableCoW keeper machinery: the conditional-order body and the structured poll Verdict, with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter." + +[lib] +# Plain library, keeper-side only. The CoW venue crate is orderbook-only +# and never links this. + +[lints] +workspace = true + +[dependencies] +alloy-primitives.workspace = true +alloy-sol-types.workspace = true +borsh.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } +nexum-sdk = { path = "../nexum-sdk" } + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/cow-venue/src/composable.rs b/crates/composable-cow/src/body.rs similarity index 73% rename from crates/cow-venue/src/composable.rs rename to crates/composable-cow/src/body.rs index c3535865..b5f6bf02 100644 --- a/crates/cow-venue/src/composable.rs +++ b/crates/composable-cow/src/body.rs @@ -1,26 +1,24 @@ -//! The venue-neutral composable (conditional) order body. +//! The composable (conditional) order body. //! //! ComposableCoW expresses a conditional order as the //! `ConditionalOrderParams` tuple: the handler contract that mints the //! tradeable order, a salt that distinguishes otherwise-identical -//! conditional orders, and the opaque handler-specific static input. This -//! body type is that tuple in wire form. The one non-obvious invariant: -//! `static_input` is opaque to the venue; only the named handler parses +//! conditional orders, and the opaque handler-specific static input. +//! This body type is that tuple in wire form. The one non-obvious +//! invariant: `static_input` is opaque; only the named handler parses //! it, so this crate never inspects its bytes. use borsh::{BorshDeserialize, BorshSerialize}; -use crate::order::Address; - -/// The venue-neutral conditional order body: `ConditionalOrderParams` in -/// wire form. +/// The conditional order body: `ConditionalOrderParams` in wire form. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub struct ComposableBody { /// The `IConditionalOrder` handler that mints the tradeable order. - pub handler: Address, + pub handler: [u8; 20], /// Salt distinguishing otherwise-identical conditional orders. pub salt: [u8; 32], - /// Handler-specific static input; opaque to the venue. + /// Handler-specific static input; opaque to everything but the + /// named handler. pub static_input: Vec, } @@ -51,6 +49,9 @@ mod tests { let mut body = sample(); body.static_input = Vec::new(); let bytes = borsh::to_vec(&body).expect("encode"); - assert_eq!(ComposableBody::try_from_slice(&bytes).unwrap(), body); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); } } diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs new file mode 100644 index 00000000..505fa115 --- /dev/null +++ b/crates/composable-cow/src/lib.rs @@ -0,0 +1,15 @@ +//! # composable-cow +//! +//! ComposableCoW keeper machinery, kept out of the CoW venue: the +//! conditional-order body ([`ComposableBody`]) and the structured poll +//! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined +//! behind [`LegacyRevertAdapter`]. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod body; +pub mod poll; + +pub use body::ComposableBody; +pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/composable-cow/src/poll.rs similarity index 95% rename from crates/shepherd-sdk/src/cow/composable.rs rename to crates/composable-cow/src/poll.rs index a7a4674e..590549cc 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/composable-cow/src/poll.rs @@ -181,7 +181,9 @@ impl LegacyRevertAdapter { } _ => Verdict::TryNextBlock { reason: [0; 4] }, }, - ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, + // `ChainError` is `#[non_exhaustive]`: transport faults and + // any future case are payload-free, so they stay retryable. + _ => Verdict::TryNextBlock { reason: [0; 4] }, } } } @@ -353,4 +355,18 @@ mod tests { Verdict::TryNextBlock { .. } )); } + + use proptest::prelude::*; + + proptest! { + /// `decode` on arbitrary revert bytes never panics and returns + /// `None` for inputs shorter than the 4-byte EVM selector. + #[test] + fn decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { + let outcome = LegacyRevertAdapter::decode(&bytes); + if bytes.len() < 4 { + prop_assert!(outcome.is_none()); + } + } + } } diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 975ea7a9..d718ad35 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -4,12 +4,14 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "CoW venue slices. The default `body` slice carries the venue-neutral order/composable intent body types and their borsh IntentBody codec, linkable by adapters and modules." +description = "CoW venue slices, orderbook-only. The default `body` slice carries the venue-neutral order intent body types and their borsh IntentBody codec, linkable by adapters and modules." [lib] -# Plain library. The default `body` slice is dependency-light so a venue -# adapter component or a strategy module can link the intent body types -# and codec without the host-side CoW machinery. +# The default `body` slice is dependency-light so a venue adapter +# component or a strategy module can link the intent body types and +# codec without the host-side CoW machinery. The cdylib is the +# `adapter` slice's component build (wasm32-wasip2). +crate-type = ["lib", "cdylib"] [lints] workspace = true @@ -21,12 +23,25 @@ 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 # reach a guest that links this slice. nexum-sdk = { path = "../nexum-sdk", optional = true } +# `assembly` slice: the chain-edge order projections and orderbook +# submission bodies. Express-declared (not workspace-inherited) so the +# guest build never inherits the native `http-client` feature. +cowprotocol = { version = "0.2.0", default-features = false, optional = true } +alloy-primitives = { workspace = true, optional = true } +alloy-sol-types = { workspace = true, optional = true } +# `adapter` slice: the orderbook REST speaker over the scoped +# wasi:http transport. +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +http = { workspace = true, optional = true } +url = { workspace = true, optional = true } +wit-bindgen = { workspace = true, optional = true } # `build.rs` parses `data/classification.toml` and emits the static # lookup table; the same parse is shared with the parity tests below. @@ -39,13 +54,36 @@ thiserror = { workspace = true } serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } +# The conformance kit: holds the body codec to its published vector set. +videre-test = { path = "../videre-test" } +# Parity tests only: the upstream errorType enum and `retry_hint()` the +# shipped table is reconciled against. Never a runtime dependency. +cowprotocol = { version = "0.2.0", default-features = false } [features] # The body-type + codec slice ships by default; the `client` slice layers # the typed client and the table-driven retry classification on top. -# `--no-default-features` drops both to an empty crate so downstream can -# depend on a future `adapter` slice without pulling the codec or the -# keeper transitively. +# `--no-default-features` drops everything so downstream can depend on a +# single 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"] +# Chain-edge order assembly, shared by the adapter's submit and the +# keeper's legacy submit path. Carries no component glue, so a keeper +# module can link it without exporting the adapter face. +assembly = ["body", "dep:cowprotocol", "dep:alloy-primitives", "dep:alloy-sol-types"] +# The venue-adapter component slice: the `#[videre_sdk::venue]` export +# and the orderbook transport. Only the cdylib wasm build enables it. +adapter = [ + "assembly", + "client", + "dep:serde", + "dep:serde_json", + "dep:http", + "dep:url", + "dep:wit-bindgen", +] + +[[test]] +name = "conformance" +required-features = ["adapter"] diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index 46507d48..f7910590 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -33,17 +33,26 @@ # # Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream # `cowprotocol` crate (a shepherd-sdk dependency) also classifies -# orderbook `errorType`s, via `RetryHint`. This table is deliberately -# NOT delegated to it: it is shepherd's own, more conservative retry -# policy, kept as data of record here so a non-Rust author owns it and -# so the guest `client` slice stays free of the upstream error module. -# The two intentionally diverge on several types - e.g. this table drops -# `InvalidEip1271Signature`, `InsufficientBalance`, `InsufficientAllowance` -# and `InvalidAppData` where upstream retries or backs off, and backs off -# `TooManyLimitOrders` for 30s rather than an hour. These are ratified -# shepherd decisions (a permanent-looking contract rejection is dropped -# rather than retried every block); revisit them here, not by switching -# the source of truth to `RetryHint`. +# orderbook `errorType`s, via `RetryHint`. Ratified: this table, not +# `RetryHint`, is shepherd's classification source of truth. It is +# shepherd's own, more conservative retry policy, kept as data of record +# here so a non-Rust author owns it and so the guest `client` slice +# stays free of the upstream error module. The ratified divergences +# (a permanent-looking contract rejection is dropped rather than +# retried, and the limit-order backoff is shorter) are exactly: +# +# InvalidEip1271Signature drop upstream: retry next block +# InsufficientBalance drop upstream: backoff 10 min +# InsufficientAllowance drop upstream: backoff 10 min +# InvalidAppData drop upstream: backoff 60 s +# TooManyLimitOrders backoff 30 s upstream: backoff 1 h +# +# Every `error-type` below must name a member of the upstream orderbook +# errorType enum (`cowprotocol::OrderbookApiErrorType`). Parity tests +# reject phantom types and pin the divergence set to the list above, so +# both a data edit and an upstream policy change force re-ratification. +# Revisit policy here, not by switching the source of truth to +# `RetryHint`. # --- Transient: retry on the next block ------------------------------ @@ -51,10 +60,6 @@ error-type = "InsufficientFee" action = "try-next-block" -[[entry]] -error-type = "PriceExceedsMarketPrice" -action = "try-next-block" - # --- Throttle: wait, then retry -------------------------------------- # The account already holds the maximum number of open limit orders. A diff --git a/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml new file mode 100644 index 00000000..b28b4a4a --- /dev/null +++ b/crates/cow-venue/module.load.toml @@ -0,0 +1,24 @@ +# Load-test variant of the cow adapter manifest: Sepolia chain id with +# the orderbook re-pointed at tools/orderbook-mock (no live cow.fi). + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["localhost"] + +[config] +chain = "11155111" +orderbook-url = "http://localhost:9999" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml new file mode 100644 index 00000000..35635f09 --- /dev/null +++ b/crates/cow-venue/module.sepolia.toml @@ -0,0 +1,24 @@ +# Sepolia variant of the cow adapter manifest: same component, chain +# 11155111, so submits land on the Sepolia orderbook. Wire this from +# any engine config whose watchers index Sepolia. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +[config] +chain = "11155111" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml new file mode 100644 index 00000000..0e5b555c --- /dev/null +++ b/crates/cow-venue/module.toml @@ -0,0 +1,30 @@ +# cow adapter manifest - the CoW venue's `#[videre_sdk::venue]` +# component. The manifest name is the venue id the registry installs +# the adapter under. Outbound orderbook HTTP is the only transport; +# the operator's `[[adapters]].http_allow` grant scopes it at install. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +# One adapter instance speaks one chain's orderbook. `orderbook-url`, +# `owner` (enables the pre-sign path), and `http-timeout-ms` are +# optional overrides. Sepolia and load-mock variants sit alongside +# (`module.sepolia.toml`, `module.load.toml`). +[config] +chain = "1" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs new file mode 100644 index 00000000..7a7bb633 --- /dev/null +++ b/crates/cow-venue/src/adapter.rs @@ -0,0 +1,928 @@ +//! The CoW venue adapter: the `venue-adapter` component slice. +//! +//! [`CowAdapter`] decodes [`CowIntentBody`], assembles the orderbook +//! wire bodies through [`crate::assembly`], and speaks the +//! orderbook REST API over the scoped wasi:http transport, every +//! request bounded by the configured per-request timeout +//! ([`TimedFetch`](videre_sdk::transport::TimedFetch)). Orderbook +//! `errorType` rejections project onto +//! `venue-error` through the shipped classification table so the retry +//! hint survives the collapse: transient rows are `unavailable`, +//! throttles are `rate-limited`, permanent rows are `denied` (never +//! retried). An unsigned order submits as pre-sign: success is +//! `requires-signing` carrying the `setPreSignature` call the host +//! signs and sends. +//! +//! `[config]` keys: `chain` (required, decimal chain id), and optional +//! `orderbook-url` (base URL override), `owner` (hex address enabling +//! the pre-sign path), `http-timeout-ms` (per-request bound, default +//! 30000). + +use core::time::Duration; +use std::sync::{PoisonError, RwLock}; + +use alloy_primitives::{Address, U256}; +use cowprotocol::{ + ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest, +}; +use nexum_sdk::keeper::RetryAction; +use serde::Deserialize; +use url::Url; +use videre_sdk::transport::http::Fetch; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::{ + AuthScheme, IntentBody as _, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, + SubmitOutcome, UnsignedTx, VenueError, +}; + +use crate::assembly; +use crate::body::{CowIntent, CowIntentBody}; +use crate::classification; +use crate::order::OrderUid; + +/// The CoW venue's protocol speaker: the `venue-adapter` export type. +/// The component face is supplied by `#[videre_sdk::venue]` on the +/// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. +pub struct CowAdapter; + +/// Default per-request timeout bound in milliseconds. +const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +/// Parsed `[config]`: one adapter instance speaks one chain's orderbook. +#[derive(Clone, Debug)] +pub(crate) struct AdapterConfig { + pub(crate) chain: Chain, + pub(crate) base: Url, + pub(crate) owner: Option
, + pub(crate) timeout: Duration, +} + +impl AdapterConfig { + /// Parse the wire config table. Unknown keys are ignored; a + /// malformed value fails init typedly. + pub(crate) fn parse(config: &[(String, String)]) -> Result { + let invalid = |key: &str, value: &str| { + videre_sdk::Fault::InvalidInput(format!("config {key} is invalid: {value}")) + }; + let mut chain = None; + let mut base = None; + let mut owner = None; + let mut timeout = Duration::from_millis(DEFAULT_TIMEOUT_MS); + for (key, value) in config { + match key.as_str() { + "chain" => { + let id: u64 = value.parse().map_err(|_| invalid(key, value))?; + chain = Some(Chain::try_from(id).map_err(|_| invalid(key, value))?); + } + "orderbook-url" => { + let mut url: Url = value.parse().map_err(|_| invalid(key, value))?; + // Path joining relies on a trailing slash. + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + base = Some(url); + } + "owner" => { + owner = Some(value.parse::
().map_err(|_| invalid(key, value))?); + } + "http-timeout-ms" => { + let ms: u64 = value.parse().map_err(|_| invalid(key, value))?; + timeout = Duration::from_millis(ms.max(1)); + } + _ => {} + } + } + let chain = chain.ok_or_else(|| { + videre_sdk::Fault::InvalidInput("config requires a chain id".to_owned()) + })?; + Ok(Self { + chain, + base: base.unwrap_or_else(|| chain.orderbook_base_url()), + owner, + timeout, + }) + } +} + +/// The configured adapter state; `init` replaces it whole, so a +/// supervisor restart re-configures cleanly. +static CONFIG: RwLock> = RwLock::new(None); + +pub(crate) fn store_config(config: AdapterConfig) { + *CONFIG.write().unwrap_or_else(PoisonError::into_inner) = Some(config); +} + +/// The stored config, or the typed refusal for an uninitialised call. +/// The world contract calls `init` before any submission, so this is +/// only reachable on a host driving the exports out of order. +pub(crate) fn config() -> Result { + CONFIG + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + .ok_or_else(|| VenueError::Unavailable("adapter not initialised".to_owned())) +} + +// ── intent functions, transport-injected for host-free tests ───────── + +/// Decode the versioned wire body into its single published intent sum. +fn decode(body: &[u8]) -> Result { + let CowIntentBody::V1(intent) = CowIntentBody::from_bytes(body)?; + Ok(intent) +} + +/// Pure header derivation for `chain`: gives the sell side, wants the +/// buy side, authorisation by intent kind (a signed order carries its +/// EIP-1271 signature; an unsigned order is authorised by host-held +/// keys through the pre-sign flow). +pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result { + let intent = decode(body)?; + let (order, authorisation) = match &intent { + CowIntent::Order(order) => (order, AuthScheme::Eip712), + CowIntent::Signed(signed) => (&signed.order, AuthScheme::Eip1271), + }; + Ok(IntentHeader { + gives: erc20(order.sell_token, minimal_be(&order.sell_amount)), + wants: erc20(order.buy_token, minimal_be(&order.buy_amount)), + settlement: Settlement { chain }, + authorisation, + }) +} + +/// Submit one intent to the orderbook. A signed order posts EIP-1271 +/// and its receipt is the canonical 56-byte UID; an unsigned order +/// posts pre-sign and success is `requires-signing` carrying the +/// `setPreSignature` call. Either way an already-held rejection is +/// success wearing an error status: the UID is derived client-side, so +/// the outcome is identical to a fresh accept. +pub(crate) fn submit_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + match decode(body)? { + CowIntent::Signed(signed) => { + let order = assembly::body_to_order_data(&signed.order); + let owner = Address::from(signed.owner); + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => uid, + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::Accepted(uid.as_slice().to_vec())) + } + CowIntent::Order(wire) => { + // Pre-sign needs an owner for `from` and the on-chain call; + // an unconfigured deployment refuses rather than guesses. + let owner = config.owner.ok_or(VenueError::Unsupported)?; + let order = assembly::body_to_order_data(&wire); + let creation = assembly::build_presign_creation(&order, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => uid, + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: config.chain.id(), + to: config.chain.settlement().as_slice().to_vec(), + value: Vec::new(), + data: assembly::set_pre_signature_calldata(&uid), + })) + } + } +} + +/// Poll one receipt's orderbook lifecycle state. +pub(crate) fn status_with( + fetch: &impl Fetch, + config: &AdapterConfig, + receipt: &[u8], +) -> Result { + let uid = OrderUid::try_from(receipt) + .map_err(|_| VenueError::InvalidBody("receipt is not a 56-byte order uid".to_owned()))?; + let url = join(config, &format!("api/v1/orders/{uid}"))?; + let response = call(fetch, http::Method::GET, url, None)?; + if response.status() == http::StatusCode::NOT_FOUND { + // A just-accepted order can lag the read path; not-found stays + // retryable rather than killing the watch. + return Err(VenueError::Unavailable("order not found".to_owned())); + } + if !response.status().is_success() { + return Err(refusal(&response).into_error()); + } + /// The one server field the lifecycle projection reads. + #[derive(Deserialize)] + struct OrderStatusView { + status: OrderStatus, + } + let view: OrderStatusView = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("order decode failed: {e}")))?; + Ok(match view.status { + OrderStatus::PresignaturePending => IntentStatus::Pending, + OrderStatus::Open => IntentStatus::Open, + OrderStatus::Fulfilled => IntentStatus::Fulfilled, + OrderStatus::Cancelled => IntentStatus::Cancelled, + OrderStatus::Expired => IntentStatus::Expired, + }) +} + +/// Price one intent body: an indicative orderbook quote. +pub(crate) fn quote_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + let intent = decode(body)?; + let (wire, from) = match &intent { + CowIntent::Order(order) => (order, config.owner.ok_or(VenueError::Unsupported)?), + CowIntent::Signed(signed) => (&signed.order, Address::from(signed.owner)), + }; + let order = assembly::body_to_order_data(wire); + let request = serde_json::to_vec("e_request(&order, from)) + .map_err(|e| VenueError::Unavailable(format!("quote encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/quote")?, + Some(request), + )?; + if !response.status().is_success() { + return Err(refusal(&response).into_error()); + } + let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; + Ok(Quotation { + gives: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.sell_amount), + ), + wants: erc20( + order.buy_token.into_array(), + minimal_be_u256(quoted.quote.buy_amount), + ), + fee: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.fee_amount), + ), + valid_until_ms: u64::from(quoted.quote.valid_to).saturating_mul(1000), + }) +} + +/// The quote request pinned to the body's own terms, so the indicative +/// price answers for exactly the order the keeper would place. +fn quote_request(order: &OrderData, from: Address) -> QuoteRequest { + let mut request = match order.kind { + OrderKind::Sell => QuoteRequest::sell_before_fee( + order.sell_token, + order.buy_token, + from, + order.sell_amount, + ), + OrderKind::Buy => { + QuoteRequest::buy_after_fee(order.sell_token, order.buy_token, from, order.buy_amount) + } + }; + request.receiver = order.receiver; + request.valid_to = Some(order.valid_to); + request.app_data = Some(QuoteAppData::Hash(order.app_data)); + request.partially_fillable = Some(order.partially_fillable); + request.sell_token_balance = Some(order.sell_token_balance); + request.buy_token_balance = Some(order.buy_token_balance); + request +} + +// ── orderbook wire plumbing ────────────────────────────────────────── + +/// What a `POST /api/v1/orders` produced. +enum Posted { + /// The orderbook accepted and assigned this UID. + Accepted(cowprotocol::OrderUid), + /// The orderbook already holds this exact order. + AlreadyHeld, +} + +fn post_order( + fetch: &impl Fetch, + config: &AdapterConfig, + creation: &OrderCreation, +) -> Result { + let body = serde_json::to_vec(creation) + .map_err(|e| VenueError::Unavailable(format!("order encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/orders")?, + Some(body), + )?; + if response.status().is_success() { + let uid: cowprotocol::OrderUid = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("uid decode failed: {e}")))?; + return Ok(Posted::Accepted(uid)); + } + match refusal(&response) { + Refusal::AlreadyHeld => Ok(Posted::AlreadyHeld), + Refusal::Error(err) => Err(err), + } +} + +fn join(config: &AdapterConfig, path: &str) -> Result { + config + .base + .join(path) + .map_err(|e| VenueError::Unavailable(format!("orderbook url: {e}"))) +} + +/// One bounded request; every transport failure arrives as a typed +/// [`VenueError`] through the fetch conversion. +fn call( + fetch: &impl Fetch, + method: http::Method, + url: Url, + json: Option>, +) -> Result>, VenueError> { + let mut builder = http::Request::builder().method(method).uri(url.as_str()); + if json.is_some() { + builder = builder.header(http::header::CONTENT_TYPE, "application/json"); + } + let request = builder + .body(json.unwrap_or_default()) + .map_err(|e| VenueError::Unavailable(format!("request build failed: {e}")))?; + Ok(fetch.fetch(request)?) +} + +/// A non-2xx orderbook reply, projected for the wire. +enum Refusal { + /// The structured already-held rejection: success wearing an error + /// status. + AlreadyHeld, + /// Everything else, as the `venue-error` the caller reports. + Error(VenueError), +} + +impl Refusal { + /// Collapse for call sites where already-held is not a success + /// shape (reads); the orderbook only emits it on submission. + fn into_error(self) -> VenueError { + match self { + Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), + Refusal::Error(err) => err, + } + } +} + +/// Project a non-2xx reply: throttles first, server failures stay +/// retryable, and only a structured 4xx envelope reaches the +/// classification table. +fn refusal(response: &http::Response>) -> Refusal { + let status = response.status(); + if status == http::StatusCode::TOO_MANY_REQUESTS { + return Refusal::Error(VenueError::RateLimited(RateLimit { + retry_after_ms: retry_after_ms(response), + })); + } + if status.is_server_error() { + return Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))); + } + match serde_json::from_slice::(response.body()) { + Ok(api) if classification::is_already_submitted(&api.error_type) => Refusal::AlreadyHeld, + Ok(api) => Refusal::Error(classified(&api)), + Err(_) => Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))), + } +} + +/// `Retry-After` in milliseconds, when the reply carries the +/// delta-seconds form. +fn retry_after_ms(response: &http::Response>) -> Option { + response + .headers() + .get(http::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .map(|seconds| seconds.saturating_mul(1000)) +} + +/// Fold a structured rejection through the shipped table: transient +/// rows retry as `unavailable`, throttle rows carry their backoff as +/// `rate-limited`, permanent rows (and any future action) are `denied`. +fn classified(api: &ApiError) -> VenueError { + let detail = format!("{}: {}", api.error_type, api.description); + match classification::classify(&api.error_type) { + RetryAction::TryNextBlock => VenueError::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }), + RetryAction::Drop => VenueError::Denied(detail), + _ => VenueError::Denied(detail), + } +} + +// ── value projections ──────────────────────────────────────────────── + +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(bytes: &[u8; 32]) -> Vec { + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +fn minimal_be_u256(value: U256) -> Vec { + minimal_be(&value.to_be_bytes::<32>()) +} + +fn erc20(token: [u8; 20], amount: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: token.to_vec(), + }), + amount, + } +} + +// The component-ABI export glue only exists on the wasm build; the +// native build keeps the same trait impl (for conformance suites) +// without export symbols no native linker accepts. +#[cfg(not(target_arch = "wasm32"))] +use wit_bindgen as _; + +/// The component face: `#[videre_sdk::venue]` derives the world from +/// `module.toml` and wires the trait through it. The real transport is +/// wasi:http behind the configured +/// [`TimedFetch`](videre_sdk::transport::TimedFetch) bound. +mod export { + use videre_sdk::VenueAdapter; + use videre_sdk::transport::TimedFetch; + use videre_sdk::transport::http::WasiFetch; + #[cfg(not(target_arch = "wasm32"))] + use videre_sdk::{Config, Fault}; + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; + + use super::{AdapterConfig, CowAdapter}; + + #[cfg_attr(target_arch = "wasm32", videre_sdk::venue)] + impl VenueAdapter for CowAdapter { + fn init(config: Config) -> Result<(), Fault> { + AdapterConfig::parse(&config).map(super::store_config) + } + + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + + fn derive_header(body: Vec) -> Result { + super::derive_header_with(super::config()?.chain.id(), &body) + } + + fn quote(body: Vec) -> Result { + let config = super::config()?; + super::quote_with(&TimedFetch::new(WasiFetch, config.timeout), &config, &body) + } + + fn submit(body: Vec) -> Result { + let config = super::config()?; + super::submit_with(&TimedFetch::new(WasiFetch, config.timeout), &config, &body) + } + + fn status(receipt: Vec) -> Result { + let config = super::config()?; + super::status_with( + &TimedFetch::new(WasiFetch, config.timeout), + &config, + &receipt, + ) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + // Off-chain cancellation is an owner-signed request; the + // adapter structurally holds no keys. + Err(VenueError::Unsupported) + } + } +} + +#[cfg(test)] +mod tests { + use videre_sdk::IntentBody as _; + use videre_sdk::transport::TimedFetch; + use videre_sdk::transport::http::FetchError; + use videre_test::MockFetch; + + use super::*; + use crate::body::{CowIntent, CowIntentBody}; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + const SEPOLIA: u64 = 11_155_111; + const ORDERS: &str = "https://orderbook.test/api/v1/orders"; + const QUOTE: &str = "https://orderbook.test/api/v1/quote"; + + fn config() -> AdapterConfig { + AdapterConfig { + chain: Chain::try_from(SEPOLIA).expect("sepolia is supported"), + base: Url::parse("https://orderbook.test/").expect("test url parses"), + owner: None, + timeout: Duration::from_secs(5), + } + } + + fn with_owner(owner: Address) -> AdapterConfig { + AdapterConfig { + owner: Some(owner), + ..config() + } + } + + fn owner() -> Address { + Address::repeat_byte(0x55) + } + + fn order_body() -> OrderBody { + OrderBody::sell(SellToken([0x11; 20]), amount(42)) + .for_at_least(BuyToken([0x22; 20]), amount(41)) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .build() + } + + fn amount(value: u8) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[31] = value; + bytes + } + + fn signed_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: owner().into_array(), + signature: vec![0xC0, 0xFF, 0xEE], + })) + .to_bytes() + .expect("body encodes") + } + + fn order_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .expect("body encodes") + } + + fn expected_uid(config: &AdapterConfig) -> cowprotocol::OrderUid { + let order = assembly::body_to_order_data(&order_body()); + assembly::order_uid(config.chain, &order, owner()) + } + + fn reject(fetch: &MockFetch, error_type: &str) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 400, + format!(r#"{{"errorType":"{error_type}","description":"d"}}"#), + ); + } + + // ── config ─────────────────────────────────────────────────────── + + #[test] + fn config_defaults_resolve_from_the_chain() { + let pairs = [("chain".to_owned(), "1".to_owned())]; + let parsed = AdapterConfig::parse(&pairs).expect("chain alone suffices"); + assert_eq!(parsed.chain.id(), 1); + assert_eq!(parsed.base.as_str(), "https://api.cow.fi/mainnet/"); + assert_eq!(parsed.owner, None); + assert_eq!(parsed.timeout, Duration::from_millis(DEFAULT_TIMEOUT_MS)); + } + + #[test] + fn config_overrides_parse_and_the_base_gains_its_slash() { + let pairs = [ + ("chain".to_owned(), SEPOLIA.to_string()), + ( + "orderbook-url".to_owned(), + "https://barn.test/sepolia".to_owned(), + ), + ("owner".to_owned(), format!("{:#x}", owner())), + ("http-timeout-ms".to_owned(), "1500".to_owned()), + ("name".to_owned(), "cow".to_owned()), + ]; + let parsed = AdapterConfig::parse(&pairs).expect("overrides parse"); + assert_eq!(parsed.base.as_str(), "https://barn.test/sepolia/"); + assert_eq!(parsed.owner, Some(owner())); + assert_eq!(parsed.timeout, Duration::from_millis(1500)); + } + + #[test] + fn config_refuses_a_missing_or_malformed_chain() { + assert!(matches!( + AdapterConfig::parse(&[]), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + for bad in ["x", "0"] { + let pairs = [("chain".to_owned(), bad.to_owned())]; + assert!(matches!( + AdapterConfig::parse(&pairs), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + } + } + + // ── derive-header ──────────────────────────────────────────────── + + #[test] + fn header_projects_sides_minimally_and_auth_by_kind() { + let header = derive_header_with(SEPOLIA, &signed_bytes()).expect("valid body"); + assert_eq!( + header.gives.asset, + Asset::Erc20(Erc20 { + token: vec![0x11; 20] + }) + ); + assert_eq!(header.gives.amount, vec![42]); + assert_eq!( + header.wants.asset, + Asset::Erc20(Erc20 { + token: vec![0x22; 20] + }) + ); + assert_eq!(header.wants.amount, vec![41]); + assert_eq!(header.settlement.chain, SEPOLIA); + assert!(matches!(header.authorisation, AuthScheme::Eip1271)); + + let presign = derive_header_with(SEPOLIA, &order_bytes()).expect("valid body"); + assert!(matches!(presign.authorisation, AuthScheme::Eip712)); + } + + #[test] + fn header_refuses_a_malformed_body() { + assert!(matches!( + derive_header_with(SEPOLIA, &[9, 9, 9]), + Err(VenueError::InvalidBody(_)) + )); + } + + // ── submit ─────────────────────────────────────────────────────── + + #[test] + fn signed_submit_posts_eip1271_and_returns_the_uid_receipt() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("accepted"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("signed submit must accept"); + }; + assert_eq!(receipt, uid.as_slice()); + + let request = fetch.last_request().expect("one request"); + assert_eq!(request.uri, ORDERS); + let posted: serde_json::Value = serde_json::from_slice(&request.body).expect("posted JSON"); + assert_eq!(posted["signingScheme"], "eip1271"); + assert_eq!( + posted["from"].as_str().map(str::to_lowercase), + Some(format!("{:#x}", owner())), + ); + assert_eq!(posted["appData"], format!("0x{}", "44".repeat(32))); + } + + #[test] + fn already_held_is_success_with_the_derived_uid() { + let config = config(); + let fetch = MockFetch::default(); + reject(&fetch, "DuplicatedOrder"); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("held is success"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("already-held must accept"); + }; + assert_eq!(receipt, expected_uid(&config).as_slice()); + } + + #[test] + fn unsigned_submit_requires_signing_the_presign_call() { + let config = with_owner(owner()); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &order_bytes()).expect("accepted"); + let SubmitOutcome::RequiresSigning(tx) = outcome else { + panic!("unsigned submit must require signing"); + }; + assert_eq!(tx.chain, SEPOLIA); + assert_eq!(tx.to, config.chain.settlement().as_slice()); + assert!(tx.value.is_empty()); + assert_eq!(tx.data, assembly::set_pre_signature_calldata(&uid)); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["signingScheme"], "presign"); + } + + #[test] + fn unsigned_submit_without_an_owner_is_unsupported() { + let fetch = MockFetch::default(); + assert!(matches!( + submit_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } + + #[test] + fn rejections_project_through_the_classification_table() { + let config = config(); + let fetch = MockFetch::default(); + + reject(&fetch, "InvalidSignature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) if detail.contains("InvalidSignature") + )); + + reject(&fetch, "TooManyLimitOrders"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms == Some(30_000) + )); + + reject(&fetch, "InsufficientFee"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(detail)) if detail.contains("InsufficientFee") + )); + } + + #[test] + fn transport_shapes_stay_typed() { + let config = config(); + let fetch = MockFetch::default(); + + fetch.respond_to(http::Method::POST, ORDERS, 429, "slow down"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms.is_none() + )); + + fetch.respond_to(http::Method::POST, ORDERS, 503, "maintenance"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(_)) + )); + + fetch.fail_with( + http::Method::POST, + ORDERS, + FetchError::Timeout("first byte".to_owned()), + ); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Timeout) + )); + + fetch.fail_with(http::Method::POST, ORDERS, FetchError::Denied); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(_)) + )); + } + + #[test] + fn requests_ride_the_configured_timeout_bound() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let timed = TimedFetch::new(&fetch, config.timeout); + submit_with(&timed, &config, &signed_bytes()).expect("accepted"); + let options = fetch.last_request().expect("one request").options; + assert_eq!(options.connect_timeout, config.timeout); + assert_eq!(options.first_byte_timeout, config.timeout); + assert_eq!(options.between_bytes_timeout, config.timeout); + } + + #[test] + fn retry_after_header_survives_as_milliseconds() { + let response = http::Response::builder() + .status(429) + .header(http::header::RETRY_AFTER, "7") + .body(Vec::new()) + .expect("test response builds"); + let Refusal::Error(VenueError::RateLimited(rl)) = refusal(&response) else { + panic!("429 must rate-limit"); + }; + assert_eq!(rl.retry_after_ms, Some(7_000)); + } + + // ── status ─────────────────────────────────────────────────────── + + fn status_url(uid: &OrderUid) -> String { + format!("https://orderbook.test/api/v1/orders/{uid}") + } + + #[test] + fn status_maps_the_orderbook_lifecycle() { + let config = config(); + let uid = OrderUid([0xAB; 56]); + let fetch = MockFetch::default(); + for (wire, status) in [ + ("presignaturePending", IntentStatus::Pending), + ("open", IntentStatus::Open), + ("fulfilled", IntentStatus::Fulfilled), + ("cancelled", IntentStatus::Cancelled), + ("expired", IntentStatus::Expired), + ] { + fetch.respond_to( + http::Method::GET, + status_url(&uid), + 200, + format!(r#"{{"status":"{wire}","uid":"{uid}"}}"#), + ); + assert_eq!( + status_with(&fetch, &config, uid.as_bytes()).expect("known status"), + status, + ); + } + } + + #[test] + fn status_refuses_a_short_receipt_and_retries_not_found() { + let config = config(); + let fetch = MockFetch::default(); + assert!(matches!( + status_with(&fetch, &config, &[0xAB; 3]), + Err(VenueError::InvalidBody(_)) + )); + + let uid = OrderUid([0xAB; 56]); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + assert!(matches!( + status_with(&fetch, &config, uid.as_bytes()), + Err(VenueError::Unavailable(_)) + )); + } + + // ── quote ──────────────────────────────────────────────────────── + + #[test] + fn quote_prices_the_body_and_pins_its_terms() { + let config = config(); + let fetch = MockFetch::default(); + let quote = serde_json::json!({ + "quote": { + "sellToken": format!("0x{}", "11".repeat(20)), + "buyToken": format!("0x{}", "22".repeat(20)), + "receiver": null, + "sellAmount": "42", + "buyAmount": "40", + "validTo": 1_700_000_000u32, + "appData": format!("0x{}", "44".repeat(32)), + "feeAmount": "2", + "kind": "sell", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "signingScheme": "eip1271", + }, + "from": format!("{:#x}", owner()), + "expiration": "2026-01-01T00:00:00Z", + "id": 7, + "verified": true, + }); + fetch.respond_to(http::Method::POST, QUOTE, 200, quote.to_string()); + + let quotation = quote_with(&fetch, &config, &signed_bytes()).expect("quoted"); + assert_eq!(quotation.gives.amount, vec![42]); + assert_eq!(quotation.wants.amount, vec![40]); + assert_eq!(quotation.fee.amount, vec![2]); + assert_eq!(quotation.valid_until_ms, 1_700_000_000_000); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["kind"], "sell"); + assert_eq!(posted["sellAmountBeforeFee"], "42"); + assert_eq!(posted["validTo"], 1_700_000_000u32); + } + + #[test] + fn quote_for_an_unsigned_body_needs_the_configured_owner() { + let fetch = MockFetch::default(); + assert!(matches!( + quote_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } +} diff --git a/crates/cow-venue/src/assembly.rs b/crates/cow-venue/src/assembly.rs new file mode 100644 index 00000000..6e04db1c --- /dev/null +++ b/crates/cow-venue/src/assembly.rs @@ -0,0 +1,386 @@ +//! Chain-edge order assembly: the projections between the on-chain +//! `GPv2OrderData` tuple, the typed `OrderData`, and the venue wire +//! [`OrderBody`], plus the orderbook submission bodies built from them. +//! +//! This is the venue's protocol knowledge: `kind` / +//! `sellTokenBalance` / `buyTokenBalance` ride the chain as 32-byte +//! keccak markers and the orderbook signs against the typed shape, so +//! the adapter, not the keeper, owns every projection across that +//! edge. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolCall; +use cowprotocol::{ + BuyTokenDestination, Chain, GPv2OrderData, GPv2Settlement, OrderCreation, OrderData, OrderKind, + SellTokenSource, Signature, +}; + +use crate::order::OrderBody; + +/// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the +/// typed [`OrderData`] shape `OrderCreation::new` expects. +/// +/// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride +/// the wire as `bytes32` markers (the `keccak256` of the lowercase +/// variant name). This helper hands them off to cowprotocol's +/// `from_contract_bytes` classifiers and returns `None` when the on- +/// chain payload carries a marker the SDK doesn't recognise - the +/// caller skips the order rather than ship a malformed body. +/// +/// `receiver = Address::ZERO` is normalised to `None`; +/// `OrderCreation::new` does the same downstream, but doing it here +/// keeps the EIP-712 hash inputs verbatim if a caller bypasses that +/// constructor later. +/// +/// # Example +/// +/// ``` +/// use cow_venue::assembly::gpv2_to_order_data; +/// use cowprotocol::{ +/// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, +/// }; +/// use alloy_primitives::{Address, U256}; +/// +/// let gpv2 = GPv2OrderData { +/// sellToken: Address::repeat_byte(1), +/// buyToken: Address::repeat_byte(2), +/// receiver: Address::ZERO, // normalised to None +/// sellAmount: U256::from(1_000u64), +/// buyAmount: U256::from(999u64), +/// validTo: u32::MAX, +/// appData: cowprotocol::EMPTY_APP_DATA_HASH, +/// feeAmount: U256::ZERO, +/// kind: OrderKind::SELL, +/// partiallyFillable: false, +/// sellTokenBalance: SellTokenSource::ERC20, +/// buyTokenBalance: BuyTokenDestination::ERC20, +/// }; +/// +/// let order = gpv2_to_order_data(&gpv2).expect("known markers"); +/// assert_eq!(order.sell_amount, U256::from(1_000u64)); +/// assert_eq!(order.receiver, None); +/// ``` +#[must_use] +pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { + Some(OrderData { + sell_token: gpv2.sellToken, + buy_token: gpv2.buyToken, + receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), + sell_amount: gpv2.sellAmount, + buy_amount: gpv2.buyAmount, + valid_to: gpv2.validTo, + app_data: gpv2.appData, + fee_amount: gpv2.feeAmount, + kind: OrderKind::from_contract_bytes(gpv2.kind)?, + partially_fillable: gpv2.partiallyFillable, + sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, + buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, + }) +} + +/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain +/// (order, owner, chain) tuple - the same value the orderbook derives +/// server-side from the signed payload, so a client can key +/// idempotency state before any network work. +/// +/// `None` when the chain id has no settlement domain or the order +/// carries an unknown enum marker. Only the unknown-marker case also +/// stops the submit path downstream ([`gpv2_to_order_data`] fails the +/// same way there); an unsupported chain id does not, so a caller +/// keying idempotency on this value alone re-submits until `validTo` +/// on such a chain - bounded, but callers adding new chains should +/// teach `cowprotocol::Chain` about them first. +#[must_use] +pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { + let chain = Chain::try_from(chain_id).ok()?; + let order_data = gpv2_to_order_data(order)?; + Some(format!("{}", order_uid(chain, &order_data, owner))) +} + +/// The canonical 56-byte orderbook UID for `order` under `chain`'s +/// settlement domain: what an accepted submit's receipt carries. +#[must_use] +pub fn order_uid(chain: Chain, order: &OrderData, owner: Address) -> cowprotocol::OrderUid { + order.uid(&chain.settlement_domain(), owner) +} + +/// Project a typed [`OrderData`] into the venue wire [`OrderBody`] a +/// keeper emits. Total: every typed field has exactly one wire form. +#[must_use] +pub fn order_data_to_body(order: &OrderData) -> OrderBody { + OrderBody { + sell_token: order.sell_token.into_array(), + buy_token: order.buy_token.into_array(), + receiver: order.receiver.map(Address::into_array), + sell_amount: order.sell_amount.to_be_bytes(), + buy_amount: order.buy_amount.to_be_bytes(), + valid_to: order.valid_to, + app_data: order.app_data.0, + fee_amount: order.fee_amount.to_be_bytes(), + kind: match order.kind { + OrderKind::Sell => crate::order::OrderKind::Sell, + OrderKind::Buy => crate::order::OrderKind::Buy, + }, + partially_fillable: order.partially_fillable, + sell_token_balance: match order.sell_token_balance { + SellTokenSource::Erc20 => crate::order::SellTokenSource::Erc20, + SellTokenSource::External => crate::order::SellTokenSource::External, + SellTokenSource::Internal => crate::order::SellTokenSource::Internal, + }, + buy_token_balance: match order.buy_token_balance { + BuyTokenDestination::Erc20 => crate::order::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => crate::order::BuyTokenDestination::Internal, + }, + } +} + +/// Project a venue wire [`OrderBody`] back onto the typed [`OrderData`] +/// the orderbook signs against: [`order_data_to_body`]'s total inverse. +#[must_use] +pub fn body_to_order_data(body: &OrderBody) -> OrderData { + OrderData { + sell_token: Address::from(body.sell_token), + buy_token: Address::from(body.buy_token), + receiver: body.receiver.map(Address::from), + sell_amount: alloy_primitives::U256::from_be_bytes(body.sell_amount), + buy_amount: alloy_primitives::U256::from_be_bytes(body.buy_amount), + valid_to: body.valid_to, + app_data: body.app_data.into(), + fee_amount: alloy_primitives::U256::from_be_bytes(body.fee_amount), + kind: match body.kind { + crate::order::OrderKind::Sell => OrderKind::Sell, + crate::order::OrderKind::Buy => OrderKind::Buy, + }, + partially_fillable: body.partially_fillable, + sell_token_balance: match body.sell_token_balance { + crate::order::SellTokenSource::Erc20 => SellTokenSource::Erc20, + crate::order::SellTokenSource::External => SellTokenSource::External, + crate::order::SellTokenSource::Internal => SellTokenSource::Internal, + }, + buy_token_balance: match body.buy_token_balance { + crate::order::BuyTokenDestination::Erc20 => BuyTokenDestination::Erc20, + crate::order::BuyTokenDestination::Internal => BuyTokenDestination::Internal, + }, + } +} + +/// Assemble the `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +/// +/// An `Err` is a client-side precondition failure that would recur on +/// every retry of the same payload; the caller drops the watch. +pub fn build_order_creation( + order_data: &OrderData, + signature: &[u8], + from: Address, +) -> Result { + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) +} + +/// Assemble the pre-sign `OrderCreation` for an unsigned order: the +/// orderbook holds it as signature-pending until `from` settles the +/// authorisation on chain via [`set_pre_signature_calldata`]. +pub fn build_presign_creation( + order_data: &OrderData, + from: Address, +) -> Result { + OrderCreation::new_app_data_hash_only(order_data, Signature::PreSign, from, None) +} + +/// ABI-encoded `GPv2Settlement::setPreSignature(uid, true)` calldata: +/// the call the order's owner must sign and send to activate a +/// pre-sign order. +#[must_use] +pub fn set_pre_signature_calldata(uid: &cowprotocol::OrderUid) -> Vec { + GPv2Settlement::setPreSignatureCall { + orderUid: Bytes::copy_from_slice(uid.as_slice()), + signed: true, + } + .abi_encode() +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{B256, U256, address, keccak256}; + use cowprotocol::GPV2_SETTLEMENT; + + use super::*; + + fn submittable_gpv2() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: 0xffff_ffff, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + #[test] + fn happy_path_round_trips_markers() { + let g = submittable_gpv2(); + let od = gpv2_to_order_data(&g).expect("known markers"); + assert_eq!(od.sell_token, g.sellToken); + assert_eq!(od.buy_token, g.buyToken); + assert_eq!(od.kind, OrderKind::Sell); + assert_eq!(od.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(od.buy_token_balance, BuyTokenDestination::Erc20); + } + + #[test] + fn zero_receiver_normalises_to_none() { + let mut g = submittable_gpv2(); + g.receiver = Address::ZERO; + assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, None); + } + + #[test] + fn non_zero_receiver_preserved() { + let g = submittable_gpv2(); + assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, Some(g.receiver)); + } + + #[test] + fn unknown_kind_marker_returns_none() { + let mut g = submittable_gpv2(); + g.kind = B256::repeat_byte(0x42); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn unknown_sell_token_balance_returns_none() { + let mut g = submittable_gpv2(); + g.sellTokenBalance = B256::repeat_byte(0x99); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn unknown_buy_token_balance_returns_none() { + let mut g = submittable_gpv2(); + g.buyTokenBalance = B256::repeat_byte(0x55); + assert!(gpv2_to_order_data(&g).is_none()); + } + + // ---- order_data_to_body / body_to_order_data ---- + + #[test] + fn order_data_to_body_projects_every_field() { + let g = submittable_gpv2(); + let order = gpv2_to_order_data(&g).expect("known markers"); + let body = order_data_to_body(&order); + assert_eq!(body.sell_token, g.sellToken.into_array()); + assert_eq!(body.buy_token, g.buyToken.into_array()); + assert_eq!(body.receiver, Some(g.receiver.into_array())); + assert_eq!(body.sell_amount, g.sellAmount.to_be_bytes::<32>()); + assert_eq!(body.buy_amount, g.buyAmount.to_be_bytes::<32>()); + assert_eq!(body.valid_to, g.validTo); + assert_eq!(body.app_data, g.appData.0); + assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); + assert_eq!(body.kind, crate::order::OrderKind::Sell); + assert!(!body.partially_fillable); + assert_eq!( + body.sell_token_balance, + crate::order::SellTokenSource::Erc20 + ); + assert_eq!( + body.buy_token_balance, + crate::order::BuyTokenDestination::Erc20 + ); + } + + #[test] + fn body_round_trips_back_to_order_data() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + assert_eq!(body_to_order_data(&order_data_to_body(&order)), order); + + for (kind, sell, buy) in [ + ( + OrderKind::Buy, + SellTokenSource::External, + BuyTokenDestination::Internal, + ), + ( + OrderKind::Sell, + SellTokenSource::Internal, + BuyTokenDestination::Erc20, + ), + ] { + let mut varied = order; + varied.kind = kind; + varied.sell_token_balance = sell; + varied.buy_token_balance = buy; + varied.receiver = None; + assert_eq!(body_to_order_data(&order_data_to_body(&varied)), varied); + } + } + + // ---- order_uid_hex ---- + + const SEPOLIA: u64 = 11_155_111; + + #[test] + fn uid_hex_is_deterministic_and_canonical_shape() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); + // 56 bytes: 32 digest + 20 owner + 4 validTo. + assert_eq!(uid.len(), 2 + 112); + assert!(uid.starts_with("0x")); + assert!( + uid.to_lowercase() + .contains("00112233445566778899aabbccddeeff00112233",) + ); + assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); + } + + #[test] + fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); + + let mut bad = submittable_gpv2(); + bad.kind = B256::repeat_byte(0x42); + assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); + } + + // ---- submission bodies ---- + + #[test] + fn presign_creation_carries_the_presign_scheme() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let creation = build_presign_creation(&order, owner).expect("valid order"); + assert_eq!(creation.signature, Signature::PreSign); + assert_eq!(creation.from, owner); + + assert!(build_presign_creation(&order, Address::ZERO).is_err()); + } + + #[test] + fn set_pre_signature_calldata_encodes_the_selector_and_uid() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid(Chain::Mainnet, &order, owner); + let data = set_pre_signature_calldata(&uid); + assert_eq!(&data[..4], &keccak256("setPreSignature(bytes,bool)")[..4]); + assert!(data.windows(56).any(|w| w == uid.as_slice())); + // The call targets the deterministic settlement deployment. + assert_ne!(GPV2_SETTLEMENT, Address::ZERO); + } +} diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 3cad2c1e..6bb39165 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,28 +1,28 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is either a direct order or a composable (conditional) -//! order; [`CowIntent`] is that sum. [`CowIntentBody`] is the outer -//! 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 +//! A CoW intent is an order for the orderbook; [`CowIntent`] is +//! that sum, open for future intent kinds. [`CowIntentBody`] is the +//! outer 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`](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; +use crate::order::{OrderBody, SignedOrder}; -/// What the CoW venue accepts: a direct order or a conditional order. +/// What the CoW venue accepts: an order for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), - /// A ComposableCoW conditional order that mints tradeable orders. - Composable(ComposableBody), + /// An owner-signed order with its EIP-1271 signature: what a + /// conditional-order keeper emits after a poll. + Signed(SignedOrder), } /// The outer per-venue version enum: the schema the CoW venue publishes. @@ -36,45 +36,69 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { use super::*; - use nexum_venue_sdk::BodyError; + use videre_test::{CodecVectors, Expectation}; - use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; + use crate::order::{BuyToken, SellToken}; fn order_body() -> OrderBody { - OrderBody { - sell_token: [0x11; 20], - buy_token: [0x22; 20], - receiver: None, - sell_amount: [0x01; 32], - buy_amount: [0x02; 32], - valid_to: 1_700_000_000, - app_data: [0x44; 32], - fee_amount: [0u8; 32], - kind: OrderKind::Sell, - partially_fillable: true, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - } + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build() } - fn composable_body() -> ComposableBody { - ComposableBody { - handler: [0xab; 20], - salt: [0xcd; 32], - static_input: vec![9, 8, 7], - } + /// The codec conformance set: the v1 intent as a round-trip vector + /// plus the typed failure contract, in the kit's published form. + fn vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors + .push_round_trip( + "v1-order", + &CowIntentBody::V1(CowIntent::Order(order_body())), + ) + .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + })), + ) + .expect("signed order encodes"); + + let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); + let mut unknown = bytes(CowIntent::Order(order_body())); + unknown[0] = 9; + vectors.push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + let mut truncated = bytes(CowIntent::Order(order_body())); + truncated.truncate(truncated.len() - 1); + vectors.push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 0 }, + ); + let mut trailing = bytes(CowIntent::Order(order_body())); + trailing.push(0); + vectors.push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ); + vectors } #[test] - fn version_body_round_trips_through_the_derive() { - for intent in [ - CowIntent::Order(order_body()), - CowIntent::Composable(composable_body()), - ] { - let body = CowIntentBody::V1(intent); - let bytes = body.to_bytes().expect("derived payload encodes"); - assert_eq!(CowIntentBody::from_bytes(&bytes).unwrap(), body); - } + fn codec_conforms_to_its_vectors() { + vectors().assert_conforms::(); } #[test] @@ -86,37 +110,15 @@ mod tests { } #[test] - fn unknown_version_fails_typedly() { - let mut bytes = CowIntentBody::V1(CowIntent::Order(order_body())) - .to_bytes() - .unwrap(); - bytes[0] = 9; - assert_eq!( - CowIntentBody::from_bytes(&bytes), - Err(BodyError::UnknownVersion { version: 9 }) + fn divergent_codec_is_caught_by_the_vectors() { + // A vector claiming a different typed failure must fail the + // check, proving it has teeth on this schema. + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors.push_failure( + "empty", + Vec::new(), + Expectation::UnknownVersion { version: 1 }, ); - } - - #[test] - fn empty_and_malformed_bodies_fail_typedly() { - assert_eq!(CowIntentBody::from_bytes(&[]), Err(BodyError::Empty)); - - let mut bytes = CowIntentBody::V1(CowIntent::Order(order_body())) - .to_bytes() - .unwrap(); - bytes.truncate(bytes.len() - 1); - assert!(matches!( - CowIntentBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 0, .. }) - )); - - let mut bytes = CowIntentBody::V1(CowIntent::Composable(composable_body())) - .to_bytes() - .unwrap(); - bytes.push(0); - assert!(matches!( - CowIntentBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 0, .. }) - )); + assert!(vectors.check::().is_err()); } } diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index 73d0e71f..aabda91f 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -235,6 +235,66 @@ mod tests { ); } + /// Every listed `error-type` names a member of the upstream + /// orderbook errorType enum, in its exact wire spelling: no phantom + /// rows. + #[test] + fn every_row_names_a_real_error_type() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + for entry in &entries { + let kind = cowprotocol::OrderbookApiErrorType::from(entry.error_type.as_str()); + assert!( + !matches!(kind, cowprotocol::OrderbookApiErrorType::Unknown(_)), + "phantom errorType {}", + entry.error_type, + ); + assert_eq!(kind.as_str(), entry.error_type, "wire spelling"); + } + } + + /// The table's divergence from upstream `retry_hint()` is exactly + /// the ratified set in the data header. A data edit or an upstream + /// policy change lands here and forces re-ratification. + #[test] + fn divergence_from_upstream_is_exactly_the_ratified_set() { + const RATIFIED: [&str; 5] = [ + "InsufficientAllowance", + "InsufficientBalance", + "InvalidAppData", + "InvalidEip1271Signature", + "TooManyLimitOrders", + ]; + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + let mut divergent: Vec<&str> = Vec::new(); + for entry in &entries { + let api = cowprotocol::ApiError { + error_type: entry.error_type.clone(), + description: String::new(), + data: None, + }; + // Project the upstream hint into the table's model; a hint + // variant this projection does not know is a divergence. + let upstream = match api.retry_hint() { + cowprotocol::RetryHint::Retry => Some((RetryAction::TryNextBlock, false)), + cowprotocol::RetryHint::Backoff { seconds } => { + Some((RetryAction::Backoff { seconds }, false)) + } + cowprotocol::RetryHint::Drop => Some((RetryAction::Drop, false)), + cowprotocol::RetryHint::AlreadySubmitted => Some((RetryAction::TryNextBlock, true)), + _ => None, + }; + let shepherd = ( + classify(&entry.error_type), + is_already_submitted(&entry.error_type), + ); + if upstream != Some(shepherd) { + divergent.push(&entry.error_type); + } + } + divergent.sort_unstable(); + assert_eq!(divergent, RATIFIED); + } + /// A non-Rust reader sees the same file as plain data: parsing it /// with the untyped TOML value model (no Rust schema) exposes the /// entries and their fields, proving any TOML library reads it. diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs index 3938eddd..49162e0b 100644 --- a/crates/cow-venue/src/classification_data.rs +++ b/crates/cow-venue/src/classification_data.rs @@ -46,6 +46,7 @@ struct Document { /// Why the shipped classification data could not be turned into a table. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ClassificationError { /// The TOML did not parse or a field had the wrong type. #[error("classification data is not valid TOML: {0}")] diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 81bd4e5c..7d13353c 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -1,128 +1,154 @@ -//! The typed CoW intent client. +//! The CoW venue as a keeper types it. //! -//! [`CowClient`] binds the strategy-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 -//! 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 nexum_venue_sdk::client::{ClientError, IntentClient, IntentPool}; -use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; +use alloc::string::String; + +use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{BodyError, IntentBody as _}; use crate::body::CowIntentBody; -/// The venue id the CoW adapter registers under and the router 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`]. An accepted submit's +/// receipt is the canonical [`OrderUid`](crate::OrderUid) in wire form. +#[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

{ - inner: IntentClient

, +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; } -impl CowClient

{ - /// Bind a pool handle to the CoW venue. - pub fn new(pool: P) -> Self { - Self { - inner: IntentClient::new(pool, VENUE), - } - } - - /// The venue id every call routes to (always [`VENUE`]). - pub fn venue(&self) -> &str { - self.inner.venue() - } - - /// Encode a typed CoW body and submit it to the venue. - pub fn submit(&self, body: &CowIntentBody) -> Result { - self.inner.submit(body) - } - - /// Report where a previously submitted intent is in its life. - pub fn status(&self, receipt: &[u8]) -> Result { - self.inner.status(receipt) - } +/// A typed client pre-bound to the CoW venue: callers cannot mis-route +/// or submit a foreign body. +pub type CowClient = VenueClient; - /// Ask the venue to withdraw an intent. - pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - self.inner.cancel(receipt) - } +/// Deterministic intent-id for `body`: the sweep's +/// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any +/// network work, so a keeper journals the same key whether it submits +/// through the sweep or directly. +pub fn intent_id(body: &CowIntentBody) -> Result { + Ok(submission_key(&CowVenue::ID, &body.to_bytes()?)) } #[cfg(test)] mod tests { - use super::*; - use nexum_venue_sdk::VenueError; use std::cell::RefCell; use std::rc::Rc; + 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)>>>; /// 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 { - fn submit(&self, venue: &str, body: Vec) -> Result { + impl videre_sdk::client::sealed::SealedTransport for SpyClient {} + + impl VenueTransport for SpyClient { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async 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 { + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { unreachable!("status not exercised") } - fn cancel(&self, _venue: &str, _receipt: &[u8]) -> Result<(), VenueError> { + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { unreachable!("cancel not exercised") } } fn sample_body() -> CowIntentBody { use crate::body::CowIntent; - use crate::order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; - CowIntentBody::V1(CowIntent::Order(OrderBody { - sell_token: [0x11; 20], - buy_token: [0x22; 20], - receiver: None, - sell_amount: [0x01; 32], - buy_amount: [0x02; 32], - valid_to: 1_700_000_000, - app_data: [0x44; 32], - fee_amount: [0u8; 32], - kind: OrderKind::Sell, - partially_fillable: true, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - })) + use crate::order::{BuyToken, OrderBody, SellToken}; + CowIntentBody::V1(CowIntent::Order( + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build(), + )) + } + + #[test] + fn intent_id_is_deterministic_and_body_scoped() { + use videre_sdk::IntentBody; + + use crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + let body = sample_body(); + let id = intent_id(&body).expect("body encodes"); + assert_eq!(id, intent_id(&body.clone()).expect("body encodes")); + assert_eq!( + id, + submission_key(&CowVenue::ID, &body.to_bytes().expect("body encodes")), + "the id must be exactly the key the generic sweep journals", + ); + assert!(id.starts_with("cow:0x")); + + let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .build(), + owner: [0x55; 20], + signature: vec![0xC0], + })); + assert_ne!(id, intent_id(&other).expect("body encodes")); } #[test] fn submit_routes_to_the_cow_venue_with_encoded_body() { - use nexum_venue_sdk::IntentBody; + use videre_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()); - assert_eq!(client.venue(), 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 = pool.submitted.borrow(); + 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); } } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 1fa8a521..82ff5aaa 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -1,43 +1,61 @@ //! # cow-venue //! -//! The CoW venue, staged as a crate of feature slices. Today only the -//! default [`body`] slice exists: the venue-neutral order and composable -//! intent body types and the borsh `IntentBody` codec over them. The -//! typed client and the adapter component are later slices. +//! The CoW venue, staged as a crate of feature slices: the orderbook +//! and nothing else. The default [`body`] slice carries the +//! venue-neutral order intent body types and the borsh `IntentBody` +//! codec over them; conditional-order keeper machinery lives in its +//! own crate and never here. //! //! 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 one non-obvious constraint: the derive's generated code -//! names `::std`, so the slice links std and is not a bare-metal -//! `#![no_std]` crate; it is guest-consumable on the runtime's -//! std-bearing wasm target rather than target-free. +//! machinery. The crate is `#![no_std]` (tests and the `adapter` slice +//! aside): the derive's generated code reaches `alloc` through the +//! venue SDK re-export, never `::std`. //! //! With `--no-default-features` the slice drops out entirely and the -//! crate compiles empty, so a consumer can depend on a future slice +//! crate compiles empty, so a consumer can depend on a single slice //! without pulling the codec transitively. //! //! The `client` slice layers on top: a typed [`CowClient`] bound to the -//! CoW venue plus the table-driven retry [`classification`] generated at +//! CoW venue, the deterministic [`intent_id`] journal key, and the +//! table-driven retry [`classification`] generated at //! build time from the shipped `data/classification.toml` (the TOML //! parser stays a build-time dependency, off the guest). It links the //! strategy keeper (for the retry action type) and is off by default, //! so an adapter or a module that wants only the body types stays //! dependency-light. +//! +//! The `assembly` slice carries the chain-edge order projections and +//! orderbook submission bodies; the `adapter` slice on top of it is +//! the venue-adapter component itself ([`CowAdapter`] under +//! `#[videre_sdk::venue]`), built as the cdylib for wasm32-wasip2 and +//! never linked by a keeper module (linking it would export the +//! adapter face). +#![cfg_attr(not(any(test, feature = "adapter")), no_std)] #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] +// wit_bindgen::generate! expands to host-import shims whose arity can +// exceed clippy's too-many-arguments threshold. +#![cfg_attr(feature = "adapter", allow(clippy::too_many_arguments))] #[cfg(feature = "body")] -pub mod body; +extern crate alloc; #[cfg(feature = "body")] -pub mod composable; +pub mod body; #[cfg(feature = "body")] pub mod order; +#[cfg(feature = "adapter")] +pub mod adapter; + +#[cfg(feature = "assembly")] +pub mod assembly; + #[cfg(feature = "client")] pub mod classification; @@ -54,11 +72,15 @@ pub mod client; #[cfg(feature = "body")] pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] -pub use composable::ComposableBody; -#[cfg(feature = "body")] -pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; +pub use order::{ + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, OrderUid, SellToken, + SellTokenSource, SignedOrder, +}; + +#[cfg(feature = "adapter")] +pub use adapter::CowAdapter; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] -pub use client::{CowClient, VENUE}; +pub use client::{CowClient, CowVenue, intent_id}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index cc31b934..8bb1d725 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -9,6 +9,10 @@ //! marker enums are canonical wire forms, not on-chain keccak markers, //! so the adapter, not this type, owns the projection to and from chain. +use alloc::vec::Vec; +use core::fmt; +use core::marker::PhantomData; + use borsh::{BorshDeserialize, BorshSerialize}; /// A 20-byte EVM address in wire form. @@ -17,6 +21,28 @@ pub type Address = [u8; 20]; /// A 256-bit amount as its 32-byte big-endian representation. pub type U256 = [u8; 32]; +/// The token an order sells, typed so a builder call cannot swap +/// sides with the buy token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SellToken(pub Address); + +impl From

for SellToken { + fn from(address: Address) -> Self { + Self(address) + } +} + +/// The token an order buys, typed so a builder call cannot swap sides +/// with the sell token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BuyToken(pub Address); + +impl From
for BuyToken { + fn from(address: Address) -> Self { + Self(address) + } +} + /// Which side of the trade is fixed. #[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] pub enum OrderKind { @@ -79,6 +105,226 @@ pub struct OrderBody { pub buy_token_balance: BuyTokenDestination, } +impl OrderBody { + /// Start a sell order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32]) + } + + /// Start a buy order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount) + } +} + +/// Builder state: the buy-side limit is unset. +pub enum NeedsBuy {} +/// Builder state: the sell-side limit is unset. +pub enum NeedsSell {} +/// Builder state: the expiry is unset. +pub enum NeedsValidTo {} +/// Builder state: every required field is set. +pub enum Ready {} + +/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or +/// [`OrderBody::buy`] fixes the kind and its side, the counter-side +/// limit and the expiry are compile-time required, and the optionals +/// default (self-receive, zero `app_data` and `fee_amount`, +/// fill-or-kill, ERC-20 balances). +#[derive(Clone, Debug)] +pub struct OrderBuilder { + body: OrderBody, + state: PhantomData, +} + +impl OrderBuilder { + const fn start( + kind: OrderKind, + sell_token: Address, + sell_amount: U256, + buy_token: Address, + buy_amount: U256, + ) -> Self { + Self { + body: OrderBody { + sell_token, + buy_token, + receiver: None, + sell_amount, + buy_amount, + valid_to: 0, + app_data: [0; 32], + fee_amount: [0; 32], + kind, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }, + state: PhantomData, + } + } + + const fn into_state(self) -> OrderBuilder { + OrderBuilder { + body: self.body, + state: PhantomData, + } + } +} + +impl OrderBuilder { + /// Demand at least `amount` of `token` in return. + #[must_use] + pub const fn for_at_least( + mut self, + token: BuyToken, + amount: U256, + ) -> OrderBuilder { + self.body.buy_token = token.0; + self.body.buy_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Spend at most `amount` of `token`. + #[must_use] + pub const fn for_at_most( + mut self, + token: SellToken, + amount: U256, + ) -> OrderBuilder { + self.body.sell_token = token.0; + self.body.sell_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Expire at `valid_to` (Unix seconds). + #[must_use] + pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder { + self.body.valid_to = valid_to; + self.into_state() + } +} + +impl OrderBuilder { + /// Deliver the buy token to `receiver` instead of the owner. + #[must_use] + pub const fn receiver(mut self, receiver: Address) -> Self { + self.body.receiver = Some(receiver); + self + } + + /// Set the 32-byte on-chain app-data hash. + #[must_use] + pub const fn app_data(mut self, app_data: [u8; 32]) -> Self { + self.body.app_data = app_data; + self + } + + /// Set the fee taken in the sell token. + #[must_use] + pub const fn fee_amount(mut self, fee_amount: U256) -> Self { + self.body.fee_amount = fee_amount; + self + } + + /// Allow the order to fill partially. + #[must_use] + pub const fn partially_fillable(mut self) -> Self { + self.body.partially_fillable = true; + self + } + + /// Source the sell token from `source`. + #[must_use] + pub const fn sell_token_balance(mut self, source: SellTokenSource) -> Self { + self.body.sell_token_balance = source; + self + } + + /// Deliver the buy token to `destination`. + #[must_use] + pub const fn buy_token_balance(mut self, destination: BuyTokenDestination) -> Self { + self.body.buy_token_balance = destination; + self + } + + /// The finished body. + #[must_use] + pub const fn build(self) -> OrderBody { + self.body + } +} + +/// An owner-signed order ready for the orderbook: what a +/// conditional-order keeper emits after a poll. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct SignedOrder { + /// The order to place. + pub order: OrderBody, + /// Order owner: the EIP-1271 verifier and the `from` of the + /// orderbook submission. + pub owner: Address, + /// Raw EIP-1271 signature bytes; the settlement verifies them + /// against `owner`. + pub signature: Vec, +} + +/// Canonical 56-byte orderbook order UID (order digest, owner, +/// `valid_to`) in wire form: the receipt bytes an accepted CoW submit +/// carries. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OrderUid(pub [u8; 56]); + +impl OrderUid { + /// The raw 56 bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 56] { + &self.0 + } +} + +impl From<[u8; 56]> for OrderUid { + fn from(bytes: [u8; 56]) -> Self { + Self(bytes) + } +} + +impl TryFrom<&[u8]> for OrderUid { + type Error = core::array::TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self(<[u8; 56]>::try_from(bytes)?)) + } +} + +impl From for Vec { + fn from(uid: OrderUid) -> Self { + uid.0.to_vec() + } +} + +impl fmt::Display for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("0x")?; + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + #[cfg(test)] mod tests { use super::*; @@ -104,6 +350,55 @@ mod tests { } } + #[test] + fn sell_builder_matches_the_literal() { + let built = OrderBody::sell(SellToken([0x11; 20]), sample().sell_amount) + .for_at_least(BuyToken([0x22; 20]), [0xff; 32]) + .valid_to(0xffff_ffff) + .receiver([0x33; 20]) + .app_data([0x44; 32]) + .build(); + assert_eq!(built, sample()); + } + + #[test] + fn buy_builder_fixes_the_buy_side() { + let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32]) + .for_at_most(SellToken([0x11; 20]), [0x01; 32]) + .valid_to(100) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount([0x05; 32]) + .build(); + assert_eq!(built.kind, OrderKind::Buy); + assert_eq!(built.sell_token, [0x11; 20]); + assert_eq!(built.buy_token, [0x22; 20]); + assert_eq!(built.sell_amount, [0x01; 32]); + assert_eq!(built.buy_amount, [0xff; 32]); + assert_eq!(built.valid_to, 100); + assert!(built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::External); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal); + assert_eq!(built.fee_amount, [0x05; 32]); + assert_eq!(built.receiver, None); + } + + #[test] + fn builder_defaults_are_the_wire_defaults() { + let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1) + .build(); + assert_eq!(built.receiver, None); + assert_eq!(built.app_data, [0; 32]); + assert_eq!(built.fee_amount, [0; 32]); + assert!(!built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20); + assert_eq!(built.kind, OrderKind::Sell); + } + #[test] fn order_body_borsh_round_trips() { let body = sample(); @@ -138,4 +433,36 @@ mod tests { } } } + + #[test] + fn signed_order_borsh_round_trips() { + let signed = SignedOrder { + order: sample(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + }; + let bytes = borsh::to_vec(&signed).expect("encode"); + assert_eq!(SignedOrder::try_from_slice(&bytes).expect("decode"), signed); + } + + #[test] + fn order_uid_converts_only_from_56_bytes() { + let uid = OrderUid([0xAB; 56]); + assert_eq!(OrderUid::try_from(&uid.0[..]).expect("56 bytes"), uid); + assert!(OrderUid::try_from(&uid.0[..55]).is_err()); + assert_eq!(Vec::from(uid), vec![0xAB; 56]); + } + + #[test] + fn order_uid_displays_as_prefixed_hex() { + let mut bytes = [0u8; 56]; + bytes[0] = 0x01; + bytes[55] = 0xFF; + let uid = OrderUid(bytes); + let hex = uid.to_string(); + assert_eq!(hex.len(), 2 + 56 * 2); + assert!(hex.starts_with("0x01")); + assert!(hex.ends_with("ff")); + assert_eq!(format!("{uid:?}"), hex); + } } diff --git a/crates/cow-venue/tests/conformance.rs b/crates/cow-venue/tests/conformance.rs new file mode 100644 index 00000000..4c176300 --- /dev/null +++ b/crates/cow-venue/tests/conformance.rs @@ -0,0 +1,29 @@ +//! Published-fixture conformance: the codec vectors and header goldens +//! under `tests/vectors/` replayed through the shipped body codec and +//! the adapter's own derivation. The files are the contract a non-Rust +//! adapter author reads. + +use cow_venue::{CowAdapter, CowIntentBody}; +use videre_sdk::VenueAdapter; +use videre_test::{CodecVectors, HeaderGoldens}; + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/vectors") + .join(name) +} + +#[test] +fn codec_conforms_to_the_published_vectors() { + let vectors = CodecVectors::load(fixture("cow-intent-body.json")).expect("vectors parse"); + vectors.assert_conforms::(); +} + +#[test] +fn derive_header_conforms_to_the_published_goldens() { + // The goldens pin mainnet; init drives the same configured path + // the host boots the component through. + CowAdapter::init(vec![("chain".to_owned(), "1".to_owned())]).expect("config parses"); + let goldens = HeaderGoldens::load(fixture("cow-header-goldens.json")).expect("goldens parse"); + goldens.assert_conforms(CowAdapter::derive_header); +} diff --git a/crates/cow-venue/tests/vectors/cow-header-goldens.json b/crates/cow-venue/tests/vectors/cow-header-goldens.json new file mode 100644 index 00000000..d482f6a2 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-header-goldens.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "venue": "cow", + "goldens": [ + { + "name": "v1-order-presign", + "body": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "unsigned order: authorised by host-held keys (pre-sign)" + }, + { + "name": "v1-signed", + "body": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip1271" + }, + "notes": "owner-signed order: EIP-1271" + } + ] +} diff --git a/crates/cow-venue/tests/vectors/cow-intent-body.json b/crates/cow-venue/tests/vectors/cow-intent-body.json new file mode 100644 index 00000000..651231e7 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-intent-body.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "schema": "cow-venue/cow-intent-body", + "vectors": [ + { + "name": "v1-order", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": "round-trip" + }, + { + "name": "v1-signed", + "bytes": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "expect": "round-trip" + }, + { + "name": "unknown-version", + "bytes": "09001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "unknown-version": { + "version": 9 + } + } + }, + { + "name": "empty", + "bytes": "", + "expect": "empty" + }, + { + "name": "truncated-payload", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + }, + { + "name": "trailing-bytes", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + } + ] +} diff --git a/crates/nexum-cli/Cargo.toml b/crates/nexum-cli/Cargo.toml index cc727db9..05bab22d 100644 --- a/crates/nexum-cli/Cargo.toml +++ b/crates/nexum-cli/Cargo.toml @@ -13,13 +13,8 @@ name = "nexum" path = "src/main.rs" [dependencies] +nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } -# Composition root wires the cow-api host extension into the reference -# lattice; the runtime itself carries no cow dependency. -shepherd-cow-host = { path = "../shepherd-cow-host" } anyhow.workspace = true -clap.workspace = true tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true diff --git a/crates/nexum-cli/src/cli.rs b/crates/nexum-cli/src/cli.rs deleted file mode 100644 index 82b7739f..00000000 --- a/crates/nexum-cli/src/cli.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! CLI surface for the `nexum` binary, derived via clap. -//! -//! The 0.2 binary accepts either a positional ` []` -//! shortcut that synthesises a one-module engine config, or a -//! `--engine-config ` flag that points at a TOML declaring -//! multiple modules. Production deployments use the second form; the -//! positional shortcut stays for parity with the M1 reference CLI and -//! for smoke tests. - -use std::path::PathBuf; - -use clap::Parser; - -/// Parsed CLI surface. -/// -/// `nexum [ []] [--engine-config ] [--pretty-logs]` -/// -/// Positional `` is a backwards-compat shortcut that -/// synthesises a one-module engine config. Production deployments pass -/// `--engine-config` and declare modules in TOML. -/// -/// `--pretty-logs` selects the human-readable tracing formatter (the -/// historical 0.1 default). Without the flag the engine emits JSON -/// log lines per the structured-logging contract: a single -/// `jq` / Loki / Grafana stream reconstructs the full timeline of -/// any dispatch, host call, or order submission. -#[derive(Parser, Debug, Default)] -#[command( - name = "nexum", - about = "Run one or more Wasm Component modules under the Shepherd supervisor", - long_about = None, - version, -)] -pub struct Cli { - /// Optional positional path to a Wasm Component file. Synthesises - /// a one-module engine config when no `--engine-config` is given. - pub wasm: Option, - - /// Optional positional path to the module's `module.toml` manifest. - /// Only consulted alongside the positional `wasm` shortcut. - pub manifest: Option, - - /// Optional explicit path to the engine-wide `engine.toml` config. - /// When omitted, the engine resolves the default search path - /// documented in `engine_config::load_or_default`. - #[arg(long = "engine-config")] - pub engine_config: Option, - - /// Use the human-readable tracing formatter instead of the - /// default JSON formatter (structured-logging contract). - #[arg(long = "pretty-logs")] - pub pretty_logs: bool, - - /// Override the chain-log poller's per-block `eth_getLogs` - /// concurrency during backfill. Higher catches up faster at more - /// node load. Overrides `[engine] log_backfill_concurrency` when - /// set. - #[arg(long = "log-backfill-concurrency")] - pub log_backfill_concurrency: Option, -} diff --git a/crates/nexum-cli/src/launch.rs b/crates/nexum-cli/src/launch.rs deleted file mode 100644 index dd67890c..00000000 --- a/crates/nexum-cli/src/launch.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Composition root: bind the reference lattice (core backends plus the -//! cow-api extension in the `Ext` slot), build the shared backends and the -//! extension list, then hand off to the generic runtime launch. - -use std::path::Path; - -use nexum_runtime::addons::{PrometheusAddOn, RuntimeAddOn}; -use nexum_runtime::builder::RuntimeBuilder; -use nexum_runtime::engine_config::EngineConfig; -use nexum_runtime::host::component::{ - ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, -}; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; - -/// The backends the reference engine ships: the core seams plus the -/// cow-api extension payload in the [`Ext`](RuntimeTypes::Ext) slot. -#[derive(Debug, Clone, Copy, Default)] -struct ReferenceTypes; - -impl RuntimeTypes for ReferenceTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -/// Build the reference backends and extension list, then run until shutdown. -pub async fn run_from_config( - engine_cfg: &EngineConfig, - wasm: Option<&Path>, - manifest: Option<&Path>, -) -> anyhow::Result<()> { - // Attach the reference add-on set. The binary ships the Prometheus - // exporter; an embedder omits or replaces it by choosing a different - // list here. - let add_ons: [&dyn RuntimeAddOn; 1] = [&PrometheusAddOn]; - - // Assemble and launch over the type-state builder: bind the reference - // lattice, wire cow-api as an extension (linker hook plus capability - // namespace; the core runtime knows nothing of cow, it plugs in here), - // name the component builders, then drive to a running handle and block - // until the event loop returns on shutdown. - RuntimeBuilder::new(engine_cfg) - .with_types::() - .with_extensions([extension::()]) - .with_module_source(wasm.map(Path::to_path_buf), manifest.map(Path::to_path_buf)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - ReferenceExtBuilder, - )) - .with_add_ons(&add_ons) - .launch() - .await? - .wait() - .await -} diff --git a/crates/nexum-cli/src/main.rs b/crates/nexum-cli/src/main.rs index f2ce5f46..c7be67c3 100644 --- a/crates/nexum-cli/src/main.rs +++ b/crates/nexum-cli/src/main.rs @@ -1,48 +1,11 @@ -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod cli; -mod launch; +//! The bare `nexum` engine binary: the core lattice with no extension +//! payload, composed over the generic launcher. -use clap::Parser; -use tracing::info; -use tracing_subscriber::EnvFilter; +#![cfg_attr(not(test), warn(unused_crate_dependencies))] -use crate::cli::Cli; -use nexum_runtime::engine_config; +use nexum_runtime::preset::CoreRuntime; #[tokio::main] async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; - if let Some(n) = cli.log_backfill_concurrency { - engine_cfg.engine.log_backfill_concurrency = n; - } - - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) - .unwrap_or_else(|_| EnvFilter::new("info")); - // Structured logging: JSON by default (machine-readable - // for production; one `jq` query reconstructs any dispatch - // timeline); `--pretty-logs` opts back into the 0.1 human-readable - // formatter for local dev. The same `EnvFilter` applies to both - // so `RUST_LOG=debug` works identically. - if cli.pretty_logs { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .init(); - } else { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .json() - .flatten_event(true) - .with_current_span(false) - .init(); - } - - info!("nexum starting"); - - launch::run_from_config(&engine_cfg, cli.wasm.as_deref(), cli.manifest.as_deref()).await + nexum_launch::run("nexum", CoreRuntime).await } diff --git a/crates/nexum-launch/Cargo.toml b/crates/nexum-launch/Cargo.toml new file mode 100644 index 00000000..aff18adc --- /dev/null +++ b/crates/nexum-launch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nexum-launch" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +nexum-runtime = { path = "../nexum-runtime" } + +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/nexum-launch/src/cli.rs b/crates/nexum-launch/src/cli.rs new file mode 100644 index 00000000..b9af9baa --- /dev/null +++ b/crates/nexum-launch/src/cli.rs @@ -0,0 +1,85 @@ +//! Shared CLI surface for engine binaries, derived via clap. + +use std::path::PathBuf; + +use clap::{CommandFactory, FromArgMatches, Parser}; + +/// Parsed CLI surface. +/// +/// ` [ []] [--engine-config ] [--pretty-logs]` +/// +/// Positional `` synthesises a one-module engine config. +/// Production deployments pass `--engine-config` and declare modules in +/// TOML. +/// +/// `--pretty-logs` selects the human-readable tracing formatter; without +/// it the engine emits JSON log lines per the structured-logging contract. +#[derive(Parser, Debug, Default)] +#[command( + about = "Run one or more Wasm Component modules under the engine supervisor", + long_about = None, + version, +)] +pub struct Cli { + /// Optional positional path to a Wasm Component file. Synthesises + /// a one-module engine config when no `--engine-config` is given. + pub wasm: Option, + + /// Optional positional path to the module's `module.toml` manifest. + /// Only consulted alongside the positional `wasm` shortcut. + pub manifest: Option, + + /// Optional explicit path to the engine-wide `engine.toml` config. + /// When omitted, the engine resolves the default search path + /// documented in `engine_config::load_or_default`. + #[arg(long = "engine-config")] + pub engine_config: Option, + + /// Use the human-readable tracing formatter instead of the + /// default JSON formatter (structured-logging contract). + #[arg(long = "pretty-logs")] + pub pretty_logs: bool, + + /// Override the chain-log poller's per-block `eth_getLogs` + /// concurrency during backfill. Higher catches up faster at more + /// node load. Overrides `[engine] log_backfill_concurrency` when + /// set. + #[arg(long = "log-backfill-concurrency")] + pub log_backfill_concurrency: Option, +} + +impl Cli { + /// Parse the process arguments under the binary's `name`, exiting on + /// `--help`/`--version` or a usage error. + #[must_use] + pub fn parse_as(name: &'static str) -> Self { + let matches = Self::command().name(name).get_matches(); + Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The flags land on the parsed surface under a caller-supplied name. + #[test] + fn flags_parse_under_a_supplied_name() { + let matches = Cli::command() + .name("nexum") + .try_get_matches_from([ + "nexum", + "--engine-config", + "engine.toml", + "--pretty-logs", + "--log-backfill-concurrency", + "8", + ]) + .expect("valid arguments parse"); + let cli = Cli::from_arg_matches(&matches).expect("matches destructure"); + assert_eq!(cli.engine_config, Some(PathBuf::from("engine.toml"))); + assert!(cli.pretty_logs); + assert_eq!(cli.log_backfill_concurrency, Some(8)); + assert!(cli.wasm.is_none()); + } +} diff --git a/crates/nexum-launch/src/lib.rs b/crates/nexum-launch/src/lib.rs new file mode 100644 index 00000000..6994ca29 --- /dev/null +++ b/crates/nexum-launch/src/lib.rs @@ -0,0 +1,66 @@ +//! Generic engine launcher: parse the shared CLI, load the engine config, +//! initialise tracing, and drive a [`Runtime`] preset until shutdown. +//! +//! A binary is one line: `nexum_launch::run("nexum", CoreRuntime)`. The +//! preset supplies the lattice, backends, extension list, and add-ons; +//! this crate knows nothing beyond the runtime seam. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod cli; + +pub use cli::Cli; + +use nexum_runtime::builder::RuntimeBuilder; +use nexum_runtime::engine_config::{self, EngineConfig}; +use nexum_runtime::preset::Runtime; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Parse the process arguments as `name`, then [`launch`] the preset. +pub async fn run(name: &'static str, preset: R) -> anyhow::Result<()> { + launch(name, preset, Cli::parse_as(name)).await +} + +/// Load the config, initialise tracing, and run the preset until shutdown. +pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Result<()> { + let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; + if let Some(n) = cli.log_backfill_concurrency { + engine_cfg.engine.log_backfill_concurrency = n; + } + + init_tracing(cli.pretty_logs, &engine_cfg); + + info!("{name} starting"); + + RuntimeBuilder::new(&engine_cfg) + .with_runtime(preset) + .with_module_source(cli.wasm, cli.manifest) + .launch() + .await? + .wait() + .await +} + +/// Install the global tracing subscriber: JSON by default (machine-readable +/// for production), the human-readable formatter behind `--pretty-logs`. +/// The same [`EnvFilter`] applies to both, so `RUST_LOG` works identically. +fn init_tracing(pretty: bool, engine_cfg: &EngineConfig) { + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + if pretty { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .json() + .flatten_event(true) + .with_current_span(false) + .init(); + } +} diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs deleted file mode 100644 index c760b37f..00000000 --- a/crates/nexum-macros/src/lib.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! Proc-macro glue for nexum runtime modules. -//! -//! [`module`] turns an `impl` block of named handlers into a complete -//! per-cdylib module: it emits the `wit_bindgen::generate!` call for a -//! per-module world derived from the crate's `module.toml` -//! `[capabilities]` declarations, the host adapter (via -//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation -//! whose `on-event` dispatches to the handlers present, and `export!`. -//! -//! [`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 -//! the manifest's declared scoped transport. -//! -//! [`derive@IntentBody`] implements the venue SDK's versioned body codec -//! 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 -//! depending on this crate directly. - -mod intent_body; -mod world; - -use std::path::Path; - -use proc_macro::TokenStream; -use quote::quote; -use syn::{DeriveInput, ImplItem, ItemImpl, Type}; - -/// Derive the venue SDK's `IntentBody` codec on the outer per-venue -/// version enum: one newtype variant per published body version, each -/// payload a borsh type. -/// -/// The wire form is the borsh enum layout (a one-byte tag, the variant's -/// declaration index, then the borsh payload), so the tag order is the -/// schema: append new versions, never reorder. Decoding an unknown tag -/// 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 -/// direct dependency. -#[proc_macro_derive(IntentBody)] -pub fn derive_intent_body(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as DeriveInput); - intent_body::expand(&input) - .unwrap_or_else(syn::Error::into_compile_error) - .into() -} - -/// The handler names recognised on a `#[module]` impl. Any method not in -/// this set is left untouched on the type, except that names starting -/// with `on_` are rejected at compile time (a typo'd handler would -/// otherwise silently never fire); any handler in the set that is absent -/// is treated as a no-op in the generated `on-event` dispatch. -const HANDLERS: [&str; 6] = [ - "init", - "on_block", - "on_chain_logs", - "on_tick", - "on_message", - "on_intent_status", -]; - -/// Generate the per-cdylib glue for a nexum module. -/// -/// Apply to an `impl` block whose associated functions are the event -/// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, -/// `on_message`, `on_intent_status`). Each handler takes the wit-bindgen -/// payload for its event and returns `Result<(), Fault>`; `init` takes -/// the config table. -/// Handlers left undefined are ignored (their events become no-ops). The -/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` -/// impl, and `export!` around the untouched impl. -/// -/// The world is per module, not shared: the macro reads the crate's -/// `module.toml` and synthesizes a world whose imports are exactly the -/// `[capabilities].required` and `optional` declarations, so the built -/// component imports what the manifest declares and nothing else - the -/// runtime's load-time capability check passes by construction instead -/// of relying on the toolchain eliding unused imports. Corollaries: the -/// manifest must sit at the crate root and carry a `[capabilities]` -/// section, an undeclared capability's bindings simply do not exist -/// (using one is a compile error, the cue to declare it), and only the -/// host-adapter pieces for declared capabilities are emitted. -/// -/// The other non-obvious invariant: the wit-bindgen output (`Guest`, -/// `Fault`, the `nexum::host::*` modules) lands at the module crate -/// root, so the emitted glue and the handler bodies resolve those names -/// there; the WIT package directories are located by walking up from -/// `CARGO_MANIFEST_DIR`. Two corollaries: the consuming crate must -/// declare `wit-bindgen` as a direct dependency (the emitted -/// `wit_bindgen::generate!` call resolves against the consumer's -/// namespace), and the crate root must not shadow std prelude names -/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` -/// trait refers to them unqualified). -#[proc_macro_attribute] -pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { - if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[nexum_sdk::module] takes no arguments", - ) - .to_compile_error() - .into(); - } - - let input = syn::parse_macro_input!(item as ItemImpl); - - let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { - return syn::Error::new_spanned( - self_ty, - "#[nexum_sdk::module] must be applied to an inherent impl of a named type", - ) - .to_compile_error() - .into(); - } - if let Some((_, trait_path, _)) = &input.trait_ { - return syn::Error::new_spanned( - trait_path, - "#[nexum_sdk::module] must be applied to an inherent impl, not a trait impl", - ) - .to_compile_error() - .into(); - } - if !input.generics.params.is_empty() { - return syn::Error::new_spanned( - &input.generics, - "#[nexum_sdk::module] must be applied to a non-generic impl", - ) - .to_compile_error() - .into(); - } - - // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise - // compile as an ordinary helper while its event silently no-ops, so - // reserve the `on_` prefix for the recognised handler set. - for item in &input.items { - if let ImplItem::Fn(f) = item { - let name = f.sig.ident.to_string(); - if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { - return syn::Error::new_spanned( - &f.sig.ident, - format!( - "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ - of {HANDLERS:?} (rename helpers so they do not start with `on_`)" - ), - ) - .to_compile_error() - .into(); - } - } - } - - let present: Vec<&str> = input - .items - .iter() - .filter_map(|item| match item { - ImplItem::Fn(f) => { - let name = f.sig.ident.to_string(); - HANDLERS.into_iter().find(|h| *h == name) - } - _ => None, - }) - .collect(); - if present.is_empty() { - return syn::Error::new_spanned( - self_ty, - "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ - of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", - ) - .to_compile_error() - .into(); - } - let has = |name: &str| present.contains(&name); - - let (manifest_path, module_world) = match derive_module_world() { - Ok(parts) => parts, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let wit_paths = match resolve_wit_packages(&module_world.packages) { - Ok(paths) => paths, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let inline_world = &module_world.wit; - let adapter_caps: Vec = module_world - .adapters - .iter() - .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) - .collect(); - - // `init` is a required export; when the handler is absent the config - // is bound but unused, so drop it to keep the module warning-clean. - let init_impl = if has("init") { - quote! { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - <#self_ty>::init(config) - } - } - } else { - quote! { - fn init( - _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - ::core::result::Result::Ok(()) - } - } - }; - - let arm = |handler: &str, variant| -> proc_macro2::TokenStream { - let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); - if has(handler) { - let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); - quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } - } else { - quote! { nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), } - } - }; - let block_arm = arm("on_block", "Block"); - let logs_arm = arm("on_chain_logs", "ChainLogs"); - let tick_arm = arm("on_tick", "Tick"); - let message_arm = arm("on_message", "Message"); - let intent_status_arm = arm("on_intent_status", "IntentStatus"); - - quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the module. - const _: &[u8] = ::core::include_bytes!(#manifest_path); - - wit_bindgen::generate!({ - inline: #inline_world, - path: [#(#wit_paths),*], - world: "nexum:module-world/module", - generate_all, - }); - - ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); - - #input - - #[doc(hidden)] - struct __NexumModuleExport; - - impl Guest for __NexumModuleExport { - #init_impl - - fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { - match event { - #block_arm - #logs_arm - #tick_arm - #message_arm - #intent_status_arm - } - } - } - - export!(__NexumModuleExport); - } - .into() -} - -/// The associated functions the `nexum:intent/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"]; - -/// Generate the per-cdylib glue for a venue adapter. -/// -/// 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` -/// (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 -/// adapter face and importing exactly the manifest's declared scoped -/// transport, then emits `wit_bindgen::generate!`, the `Guest` impls -/// wiring the world to the adapter's functions, and `export!` around the -/// untouched impl. So the built component imports what the manifest -/// declares and nothing else, retiring the toolchain-elision dependency -/// on the venue side. -/// -/// A venue's capabilities are scoped transport only: an undeclared -/// capability's bindings do not exist (using one is a compile error), -/// and a capability outside the venue-permitted set (`chain`, -/// `messaging`, `http`) is rejected at expansion. -/// -/// 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 -/// 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] -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", - ) - .to_compile_error() - .into(); - } - - let input = syn::parse_macro_input!(item as ItemImpl); - - let self_ty = &input.self_ty; - 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", - ) - .to_compile_error() - .into(); - } - 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", - ) - .to_compile_error() - .into(); - } - 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", - ) - .to_compile_error() - .into(); - } - - let defines = |name: &str| { - input - .items - .iter() - .any(|item| matches!(item, ImplItem::Fn(f) if f.sig.ident == name)) - }; - let missing: Vec<&str> = VENUE_EXPORTS - .into_iter() - .filter(|name| !defines(name)) - .collect(); - if !missing.is_empty() { - return syn::Error::new_spanned( - self_ty, - format!( - "#[nexum_venue_sdk::venue] requires the adapter face; this impl is missing {:?}. \ - Define all of `derive_header`, `submit`, `status`, `cancel` (plus an optional \ - `init`)", - missing - ), - ) - .to_compile_error() - .into(); - } - - let (manifest_path, venue_world) = match derive_venue_world() { - Ok(parts) => parts, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let wit_paths = match resolve_wit_packages(&venue_world.packages) { - Ok(paths) => paths, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let inline_world = &venue_world.wit; - - // `init` is a required world export; when the adapter omits it the - // config is bound but unused, so drop it to stay warning-clean. - let init_impl = if defines("init") { - quote! { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - <#self_ty>::init(config) - } - } - } else { - quote! { - fn init( - _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - ::core::result::Result::Ok(()) - } - } - }; - - quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the adapter. - const _: &[u8] = ::core::include_bytes!(#manifest_path); - - wit_bindgen::generate!({ - inline: #inline_world, - path: [#(#wit_paths),*], - world: "nexum:venue-world/venue-adapter", - generate_all, - }); - - #input - - #[doc(hidden)] - struct __NexumVenueAdapterExport; - - impl Guest for __NexumVenueAdapterExport { - #init_impl - } - - impl exports::nexum::intent::adapter::Guest for __NexumVenueAdapterExport { - fn derive_header( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - nexum::intent::types::IntentHeader, - nexum::intent::types::VenueError, - > { - <#self_ty>::derive_header(body) - } - - fn submit( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - nexum::intent::types::SubmitOutcome, - nexum::intent::types::VenueError, - > { - <#self_ty>::submit(body) - } - - fn status( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result< - nexum::intent::types::IntentStatus, - nexum::intent::types::VenueError, - > { - <#self_ty>::status(receipt) - } - - fn cancel( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result<(), nexum::intent::types::VenueError> { - <#self_ty>::cancel(receipt) - } - } - - export!(__NexumVenueAdapterExport); - } - .into() -} - -/// Whether a type is a plain named path (`Foo`), the only shape a module -/// export type may take. -fn is_plain_type(ty: &Type) -> bool { - matches!(ty, Type::Path(tp) if tp.qself.is_none()) -} - -/// Read the consuming crate's `module.toml` and return its declared -/// capability names alongside the manifest path (for the rebuild -/// anchor). Shared by the module and venue worlds, which differ only in -/// how they turn the declarations into a world. -fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), String> { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let manifest_path = Path::new(&manifest_dir).join("module.toml"); - let text = std::fs::read_to_string(&manifest_path).map_err(|e| { - format!( - "could not read {} ({e}); {attribute} derives the component's WIT world from the \ - manifest's [capabilities] section, so the manifest must sit next to Cargo.toml", - manifest_path.display() - ) - })?; - let declared = world::manifest_capabilities(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))?; - Ok((manifest_path.to_string_lossy().into_owned(), declared)) -} - -/// Read the consuming crate's `module.toml` and synthesize the -/// per-module world from its `[capabilities]` declarations. Returns the -/// manifest path (for the rebuild anchor) alongside the world. -fn derive_module_world() -> Result<(String, world::ModuleWorld), String> { - let (manifest_path, declared) = read_manifest_capabilities("#[nexum_sdk::module]")?; - let module_world = world::synthesize(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, module_world)) -} - -/// Read the consuming crate's `module.toml` and synthesize the -/// per-component venue-adapter world from its `[capabilities]` -/// 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 venue_world = - world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, venue_world)) -} - -/// Locate the workspace `wit/` root (the ancestor directory whose `wit/` -/// contains the `nexum-host` package) and resolve each needed package -/// directory under it. -fn resolve_wit_packages(packages: &[&str]) -> Result, String> { - let manifest = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let mut dir: Option<&Path> = Some(Path::new(&manifest)); - let root = loop { - let Some(cur) = dir else { - return Err(format!( - "could not find a `wit/` directory containing `nexum-host` in any ancestor \ - of {manifest}" - )); - }; - let wit = cur.join("wit"); - if wit.join("nexum-host").is_dir() { - break wit; - } - dir = cur.parent(); - }; - packages - .iter() - .map(|package| { - let path = root.join(package); - if path.is_dir() { - Ok(path.to_string_lossy().into_owned()) - } else { - Err(format!( - "declared capabilities need the `{package}` WIT package, but {} is not \ - a directory", - path.display() - )) - } - }) - .collect() -} diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs deleted file mode 100644 index d8791708..00000000 --- a/crates/nexum-macros/src/world.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Per-module world synthesis: turn the manifest's `[capabilities]` -//! declarations into an inline WIT world whose imports are exactly the -//! declared capability interfaces. -//! -//! The one non-obvious invariant: the capability table here must agree -//! with the runtime's capability registry (`nexum-runtime`'s manifest -//! enforcement) on both the capability names and the WIT interfaces they -//! map to. The runtime cross-checks a component's imports against the -//! manifest at load time; because this module derives the imports from -//! the same manifest, a component built through `#[nexum_sdk::module]` -//! passes that check by construction rather than by relying on the -//! toolchain eliding unused imports. - -use std::fmt::Write as _; - -/// One manifest capability and its world wiring. -struct Capability { - /// The name declared under `[capabilities].required` / `optional`. - name: &'static str, - /// The WIT import the declaration turns into, or `None` for - /// capabilities with no world import (`http` is granted through the - /// SDK's wasi:http client and the host allowlist, not the world). - import: Option<&'static str>, - /// WIT package directories (under the workspace `wit/` root) the - /// import needs on the resolve path, beyond `nexum-host`. - packages: &'static [&'static str], - /// The `bind_host_via_wit_bindgen!` capability ident carrying this - /// capability's host-adapter pieces, if the SDK has a trait seam - /// for it. - adapter: Option<&'static str>, -} - -/// 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`). -const KNOWN: &[Capability] = &[ - Capability { - name: "chain", - import: Some("nexum:host/chain@0.2.0"), - packages: &[], - adapter: Some("chain"), - }, - Capability { - name: "identity", - import: Some("nexum:host/identity@0.2.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "local-store", - import: Some("nexum:host/local-store@0.2.0"), - packages: &[], - adapter: Some("local_store"), - }, - Capability { - name: "remote-store", - import: Some("nexum:host/remote-store@0.2.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "messaging", - import: Some("nexum:host/messaging@0.2.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "logging", - import: Some("nexum:host/logging@0.2.0"), - packages: &[], - adapter: Some("logging"), - }, - Capability { - name: "pool", - import: Some("nexum:intent/pool@0.1.0"), - packages: &["nexum-intent", "nexum-value-flow"], - adapter: None, - }, - Capability { - name: "cow-api", - import: Some("shepherd:cow/cow-api@0.2.0"), - packages: &["shepherd-cow"], - adapter: None, - }, - Capability { - name: "http", - import: None, - packages: &[], - adapter: None, - }, -]; - -/// The synthesized world plus what the `generate!` call and the host -/// adapter need to go with it. -#[derive(Debug)] -pub struct ModuleWorld { - /// Inline WIT text defining `nexum:module-world/module`. - pub wit: String, - /// WIT package directories (relative to the workspace `wit/` root) - /// the resolve path must carry, in dependency order (a package - /// precedes its dependants). Always starts with the base set the - /// host `event` variant needs. - pub packages: Vec<&'static str>, - /// Capability idents to pass to `bind_host_via_wit_bindgen!`. - pub adapters: Vec<&'static str>, -} - -/// Extract the declared capability names (`required` then `optional`) -/// from the manifest text. A missing or malformed `[capabilities]` -/// section is an error: the emitted world is derived from it, so the -/// macro has nothing to build from without one. -pub fn manifest_capabilities(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - let caps = value.get("capabilities").ok_or_else(|| { - "module.toml has no [capabilities] section; the module/adapter macro derives the \ - component's WIT world from [capabilities].required/optional, so declare it (an empty \ - `required = []` is valid)" - .to_string() - })?; - let list = |key: &str| -> Result, String> { - match caps.get(key) { - None => Ok(Vec::new()), - Some(v) => v - .as_array() - .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) - }) - .collect(), - } - }; - let mut names = list("required")?; - names.extend(list("optional")?); - Ok(names) -} - -/// Capabilities a venue adapter may import. A venue speaks one venue's -/// protocol over scoped transport and nothing else: chain RPC, -/// messaging, and outbound HTTP (granted through the SDK's wasi:http -/// client, so no world import). It structurally cannot reach host key -/// material or persistent state, so `local-store`, `remote-store`, -/// `identity`, and `logging` are refused rather than silently imported. -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 -/// 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 -/// persistent state is rejected at expansion, not at boot. -pub fn synthesize_venue(declared: &[String]) -> Result { - for name in declared { - if !VENUE_CAPABILITIES.contains(&name.as_str()) { - let permitted = VENUE_CAPABILITIES.join(", "); - return Err(format!( - "capability `{name}` is not available to a venue adapter; a venue may import \ - only scoped transport ({permitted}) and structurally cannot touch local-store, \ - remote-store, identity, or logging" - )); - } - } - - let mut imports = String::new(); - // The export face (`nexum:intent/adapter`, its types, and the - // value-flow vocabulary they are expressed in) resolves against the - // same base package set every module world carries, in dependency - // order: a package precedes its dependants. - let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; - for cap in KNOWN { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); - } - // Accumulate any extra WIT packages a venue capability needs, exactly - // as `synthesize` does. All venue-permitted capabilities are - // packageless today, so this leaves the base set untouched; mirroring - // the loop keeps a future venue capability from silently failing to - // reach its package onto the resolve path. - for package in cap.packages { - if !packages.contains(package) { - packages.push(package); - } - } - } - - let mut wit = String::from( - "package nexum:venue-world;\n\nworld venue-adapter {\n \ - use nexum:host/types@0.2.0.{config, fault};\n\n", - ); - 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", - ); - - Ok(ModuleWorld { - wit, - packages, - // The venue export glue wires the adapter's associated functions - // to the world's Guest traits directly; there is no host-trait - // adapter to bind, so no capability idents to pass on. - adapters: Vec::new(), - }) -} - -/// Build the per-module world from the declared capability names -/// (required and optional alike: an optional capability must still be -/// importable, the host decides at load time whether to back or stub -/// it). Unknown names are a compile error so a typo cannot silently -/// drop an import. -pub fn synthesize(declared: &[String]) -> Result { - for name in declared { - if !KNOWN.iter().any(|c| c.name == name.as_str()) { - let known = KNOWN.iter().map(|c| c.name).collect::>().join(", "); - return Err(format!( - "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ - {known}" - )); - } - } - - let mut imports = String::new(); - // The host `event` variant carries the intent vocabulary (the - // `intent-status` case), so every module world resolves against the - // intent and value-flow packages regardless of declared capabilities. - // Dependency order: each directory is parsed against the packages - // before it, so a package precedes its dependants. - let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; - let mut adapters = Vec::new(); - for cap in KNOWN { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); - } - for package in cap.packages { - if !packages.contains(package) { - packages.push(package); - } - } - if let Some(adapter) = cap.adapter { - adapters.push(adapter); - } - } - - let mut wit = String::from( - "package nexum:module-world;\n\nworld module {\n \ - use nexum:host/types@0.2.0.{config, event, fault};\n\n", - ); - wit.push_str(&imports); - wit.push_str( - "\n export init: func(config: config) -> result<_, fault>;\n \ - export on-event: func(event: event) -> result<_, fault>;\n}\n", - ); - - Ok(ModuleWorld { - wit, - packages, - adapters, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The base package set every module world resolves against: the host - /// package plus the intent vocabulary its `event` variant carries, in - /// dependency order. - const BASE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; - - #[test] - fn logging_only_world_imports_logging_alone() { - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/logging@0.2.0;")); - assert!(!world.wit.contains("import nexum:host/chain")); - assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, BASE_PACKAGES); - assert_eq!(world.adapters, vec!["logging"]); - } - - #[test] - fn cow_api_pulls_the_shepherd_cow_package() { - let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); - assert!(world.wit.contains("import shepherd:cow/cow-api@0.2.0;")); - assert_eq!( - world.packages, - vec![ - "nexum-value-flow", - "nexum-intent", - "nexum-host", - "shepherd-cow" - ] - ); - } - - #[test] - fn pool_adds_no_packages_beyond_the_base_set() { - let world = synthesize(&["pool".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); - assert_eq!(world.packages, BASE_PACKAGES); - assert!(world.adapters.is_empty()); - } - - #[test] - fn http_declares_no_world_import() { - let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, BASE_PACKAGES); - } - - #[test] - fn duplicate_declarations_emit_one_import() { - let world = synthesize(&["chain".to_string(), "chain".to_string()]).unwrap(); - assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); - assert_eq!(world.adapters, vec!["chain"]); - } - - #[test] - fn unknown_capability_is_rejected_with_the_known_list() { - let err = synthesize(&["telepathy".to_string()]).unwrap_err(); - assert!(err.contains("unknown capability `telepathy`")); - assert!(err.contains("logging")); - } - - #[test] - fn venue_world_exports_the_adapter_face() { - let world = synthesize_venue(&["chain".to_string()]).unwrap(); - assert!(world.wit.starts_with("package nexum:venue-world;")); - assert!(world.wit.contains("world venue-adapter {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); - assert_eq!(world.packages, BASE_PACKAGES); - assert!(world.adapters.is_empty()); - } - - #[test] - fn venue_world_imports_only_declared_transport() { - let world = synthesize_venue(&["chain".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/chain@0.2.0;")); - assert!(!world.wit.contains("import nexum:host/messaging")); - - let both = synthesize_venue(&["chain".to_string(), "messaging".to_string()]).unwrap(); - assert!(both.wit.contains("import nexum:host/chain@0.2.0;")); - assert!(both.wit.contains("import nexum:host/messaging@0.2.0;")); - } - - #[test] - fn venue_world_grants_http_without_a_world_import() { - let world = synthesize_venue(&["http".to_string()]).unwrap(); - assert!(!world.wit.contains("import")); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, BASE_PACKAGES); - } - - #[test] - 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;")); - } - - #[test] - fn venue_world_refuses_non_transport_capabilities() { - for cap in ["local-store", "remote-store", "identity", "logging", "pool"] { - let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); - assert!(err.contains(cap), "message was: {err}"); - assert!(err.contains("venue adapter"), "message was: {err}"); - } - } - - #[test] - fn manifest_capabilities_reads_required_and_optional() { - let caps = manifest_capabilities( - r#" -[capabilities] -required = ["logging", "chain"] -optional = ["remote-store"] - -[capabilities.http] -allow = [] -"#, - ) - .unwrap(); - assert_eq!(caps, vec!["logging", "chain", "remote-store"]); - } - - #[test] - fn manifest_without_capabilities_section_is_an_error() { - let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); - assert!(err.contains("[capabilities]")); - } - - #[test] - fn manifest_with_non_string_capability_is_an_error() { - let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); - assert!(err.contains("only strings")); - } - - #[test] - fn world_is_valid_wit_shape() { - // Not a full WIT parse (that is the module build's job); pin the - // structural pieces the runtime contract depends on. - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.starts_with("package nexum:module-world;")); - assert!(world.wit.contains("world module {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!( - world - .wit - .contains("export on-event: func(event: event) -> result<_, fault>;") - ); - } -} diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-module-macros/Cargo.toml similarity index 76% rename from crates/nexum-macros/Cargo.toml rename to crates/nexum-module-macros/Cargo.toml index ad74099f..4bef46f5 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-module-macros/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "nexum-macros" +name = "nexum-module-macros" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export; derive(IntentBody) emits the venue SDK's versioned body codec." +description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." [lib] proc-macro = true @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] +nexum-world = { path = "../nexum-world" } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } -toml.workspace = true diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs new file mode 100644 index 00000000..bb90eb72 --- /dev/null +++ b/crates/nexum-module-macros/src/lib.rs @@ -0,0 +1,305 @@ +//! Proc-macro glue for nexum runtime modules. +//! +//! [`module`] turns an `impl` block of named handlers into a complete +//! per-cdylib module: it emits the `wit_bindgen::generate!` call for a +//! per-module world derived from the crate's `module.toml` +//! `[capabilities]` declarations, the host adapter (via +//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation +//! whose `on-event` dispatches to the handlers present, and `export!`. +//! +//! The venue-side macros (`#[venue]`, `derive(IntentBody)`) live in +//! `videre-macros`. +//! +//! Consumers reach this through the SDK re-export (`nexum_sdk::module`) +//! rather than depending on this crate directly. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl, Type}; + +/// The handler names recognised on a `#[module]` impl. Any method not in +/// this set is left untouched on the type, except that names starting +/// with `on_` are rejected at compile time (a typo'd handler would +/// otherwise silently never fire); any handler in the set that is absent +/// is treated as a no-op in the generated `on-event` dispatch. +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_intent_status", +]; + +/// Generate the per-cdylib glue for a nexum module. +/// +/// Apply to an `impl` block whose associated functions are the event +/// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, +/// `on_message`, `on_intent_status`). Each handler takes the wit-bindgen +/// payload for its event and returns `Result<(), Fault>`; `init` takes +/// the config table. +/// Handlers left undefined are ignored (their events become no-ops). The +/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` +/// impl, and `export!` around the untouched impl. +/// +/// The world is per module, not shared: the macro reads the crate's +/// `module.toml` and synthesizes a world whose imports are exactly the +/// `[capabilities].required` and `optional` declarations, so the built +/// component imports what the manifest declares and nothing else - the +/// runtime's load-time capability check passes by construction instead +/// of relying on the toolchain eliding unused imports. Corollaries: the +/// manifest must sit at the crate root and carry a `[capabilities]` +/// section, an undeclared capability's bindings simply do not exist +/// (using one is a compile error, the cue to declare it), and only the +/// host-adapter pieces for declared capabilities are emitted. +/// +/// The other non-obvious invariant: the wit-bindgen output (`Guest`, +/// `Fault`, the `nexum::host::*` modules) lands at the module crate +/// root, so the emitted glue and the handler bodies resolve those names +/// there; the WIT package directories resolve against the crate's own +/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the +/// package. Two corollaries: the consuming crate must +/// declare `wit-bindgen` as a direct dependency (the emitted +/// `wit_bindgen::generate!` call resolves against the consumer's +/// namespace), and the crate root must not shadow std prelude names +/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` +/// trait refers to them unqualified). +#[proc_macro_attribute] +pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[nexum_sdk::module] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[nexum_sdk::module] must be applied to an inherent impl, not a trait impl", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[nexum_sdk::module] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise + // compile as an ordinary helper while its event silently no-ops, so + // reserve the `on_` prefix for the recognised handler set. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + ) + .to_compile_error() + .into(); + } + } + } + + let present: Vec<&str> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS.into_iter().find(|h| *h == name) + } + _ => None, + }) + .collect(); + if present.is_empty() { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", + ) + .to_compile_error() + .into(); + } + let has = |name: &str| present.contains(&name); + + let (anchors, module_world) = match derive_module_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match resolve_wit_packages(&module_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &module_world.wit; + let adapter_caps: Vec = module_world + .adapters + .iter() + .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) + .collect(); + + // `init` is a required export; when the handler is absent the config + // is bound but unused, so drop it to keep the module warning-clean. + let init_impl = if has("init") { + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + <#self_ty>::init(config) + } + } + } else { + quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + } + }; + + let arm = |handler: &str, variant| -> proc_macro2::TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + if has(handler) { + let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); + quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } + } else { + quote! { nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), } + } + }; + let block_arm = arm("on_block", "Block"); + let logs_arm = arm("on_chain_logs", "ChainLogs"); + let tick_arm = arm("on_tick", "Tick"); + let message_arm = arm("on_message", "Message"); + let intent_status_arm = arm("on_intent_status", "IntentStatus"); + + quote! { + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them, so an edit to either + // must recompile the module. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", + generate_all, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + + #input + + #[doc(hidden)] + struct __NexumModuleExport; + + impl Guest for __NexumModuleExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + #intent_status_arm + } + } + } + + export!(__NexumModuleExport); + } + .into() +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +fn is_plain_type(ty: &Type) -> bool { + matches!(ty, Type::Path(tp) if tp.qself.is_none()) +} + +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + +/// Read the consuming crate's `module.toml` and synthesize the +/// per-module world from its `[capabilities]` declarations plus the +/// extension rows registered in the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths (the manifest, then the registry +/// when one exists) alongside the world. +fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { + let manifest_path = manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[nexum_sdk::module] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let manifest_path = manifest_path.to_string_lossy().into_owned(); + + let mut anchors = vec![manifest_path.clone()]; + let extensions = match nexum_world::find_extensions_manifest(&manifest_dir()?) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = nexum_world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + let module_world = nexum_world::synthesize(&declared, &extensions) + .map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) +} + +/// Resolve each needed WIT package directory crate-locally (vendored +/// `wit/deps/`, then own `wit/`), falling back through +/// ancestors for the transitional monorepo layout. +fn resolve_wit_packages(packages: &[String]) -> Result, String> { + Ok( + nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) +} diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index a4ddab3e..c867cbd3 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -22,6 +22,7 @@ wasmtime-wasi-http.workspace = true # Async + error plumbing. anyhow.workspace = true thiserror.workspace = true +async-trait.workspace = true # `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`) # free via a snake_case `&'static str` for every variant. Used at # `tracing::warn!(error_kind = .into(), ...)` sites and @@ -35,6 +36,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Single-source capability and fault-label vocabularies; the registry's +# core interface set is emitted from its table. +nexum-world = { path = "../nexum-world" } # Manifest parsing. serde.workspace = true @@ -66,7 +70,7 @@ bytes.workspace = true # from a `WsConnect`/`Http` transport so the host's `request` / # `request-batch` impls can hand a raw `(method, params)` pair to # alloy's JSON-RPC layer without reimplementing the codec. -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws", "ipc", "pubsub", "reqwest"] } alloy-rpc-client.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index c166e006..feba0440 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -3,9 +3,9 @@ //! //! [`CoreRuntime`] is the domain-free preset: it bundles the reference core //! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability such as cow-api is added by +//! the Prometheus add-on. A domain capability is added by //! writing a preset that names its extension builder in the `Ext` slot and -//! its linker hook via `with_extensions`, or by dropping to the explicit +//! returns its linker extensions, or by dropping to the explicit //! `with_components` builder path. The returned [`RuntimeHandle`] carries the //! in-process log read side; clone it to keep reading after `wait` consumes //! the handle. diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 927861bf..46fb2424 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -3,245 +3,23 @@ //! The core host binds the `nexum:host/event-module` world: the six core //! primitives. Outbound HTTP is not a `nexum:host` interface: it is //! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions such as cow-api bind their own world and wire themselves in -//! at the composition root; they are not part of this core surface. +//! extensions bind their own world and wire themselves in at the +//! composition root; they are not part of this core surface. //! //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. //! -//! The `nexum:intent` and `nexum:value-flow` packages sit on the core -//! resolve path because the host `event` variant carries the intent -//! vocabulary (the `intent-status` case). Their types therefore generate -//! here first, and the adapter and pool bindgens below remap onto them -//! with `with`, so one Rust type serves the event payload, the router, -//! and the adapter face alike. `PartialEq` is derived so the router can -//! compare a polled status against the last delivered one. +//! `nexum:host` is a leaf package: the host `event` variant carries a +//! status transition as opaque bytes, so the core world resolves against +//! `wit/nexum-host` alone. An extension's bindgen remaps onto the shared +//! interfaces here with `with`, so the `Host` impls and the `fault` type +//! its components see are the very ones the core host constructs. +//! `PartialEq` is derived so extension services can compare event payloads. wasmtime::component::bindgen!({ - path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", - "../../wit/nexum-host", - ], + path: ["../../wit/nexum-host"], world: "nexum:host/event-module", imports: { default: async }, exports: { default: async }, additional_derives: [PartialEq], }); - -/// WIT bindings for the second component kind: the -/// `nexum:adapter/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`, -/// `nexum:intent`, and `nexum:value-flow` interfaces are reused from the -/// `event-module` bindings above via `with`, so the `chain`/`messaging` -/// `Host` impls, the `fault` type, and the intent vocabulary an adapter -/// sees are the very ones the core host constructs. -mod venue_adapter { - wasmtime::component::bindgen!({ - path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", - "../../wit/nexum-host", - "../../wit/nexum-adapter", - ], - world: "nexum:adapter/venue-adapter", - imports: { default: async }, - exports: { default: async }, - with: { - "nexum:host/types": super::nexum::host::types, - "nexum:host/chain": super::nexum::host::chain, - "nexum:host/messaging": super::nexum::host::messaging, - "nexum:intent/types": super::nexum::intent::types, - "nexum:value-flow/types": super::nexum::value_flow::types, - }, - }); -} - -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 core bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the router 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 { - wasmtime::component::bindgen!({ - inline: " - package nexum:pool-host; - world pool-host { - import nexum:intent/pool@0.1.0; - } - ", - path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], - imports: { default: async }, - with: { - "nexum:value-flow/types": super::nexum::value_flow::types, - "nexum:intent/types": super::nexum::intent::types, - }, - }); -} - -/// The router-observed status transition delivered through the `event` -/// variant, re-exported at the plain spelling the router names. -pub use nexum::host::types::IntentStatusUpdate; -/// The shared intent ontology, re-exported at the plain spellings the router -/// and the `pool::Host` impl name. -pub use nexum::intent::types::{AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; -/// The value-flow vocabulary the header is expressed in. -pub use nexum::value_flow::types as value_flow; -/// 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; - -/// 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. -#[cfg(test)] -mod value_flow_smoke { - wasmtime::component::bindgen!({ - inline: " - package nexum:value-flow-smoke; - world smoke { - import nexum:value-flow/types@0.1.0; - } - ", - path: ["../../wit/nexum-value-flow"], - }); - - #[test] - fn identifiers_bind_unescaped() { - use nexum::value_flow::types::{Asset, AssetAmount, OffchainDesc, ServiceDesc, Settlement}; - - 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 _ = 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 amount = AssetAmount { - asset, - amount: Vec::new(), - }; - assert!(amount.amount.is_empty()); - } -} - -/// 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 -/// 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 { - wasmtime::component::bindgen!({ - inline: " - package nexum:intent-smoke; - world smoke { - import nexum:intent/pool@0.1.0; - } - ", - path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], - }); - - use nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, - }; - use nexum::value_flow::types::Settlement; - - struct DummyPool; - - impl nexum::intent::pool::Host for DummyPool { - fn submit(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn status( - &mut self, - _venue: String, - _receipt: Vec, - ) -> Result { - Err(VenueError::UnknownVenue) - } - - fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { - Err(VenueError::UnknownVenue) - } - } - - #[test] - fn identifiers_bind_unescaped() { - use nexum::intent::pool::Host; - - let _ = AuthScheme::Eip712; - let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Presign; - let _ = AuthScheme::OffchainSig; - let _ = AuthScheme::Unsigned; - - let header = IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), - authorisation: AuthScheme::Eip712, - }; - assert!(header.gives.is_empty() && header.wants.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::Cancelled; - - let tx = UnsignedTx { - chain_id: 1, - to: Vec::new(), - value: Vec::new(), - input: Vec::new(), - }; - let _ = SubmitOutcome::Accepted(Vec::new()); - let _ = SubmitOutcome::RequiresSigning(tx); - - let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::InvalidReceipt; - let _ = VenueError::Rejected(String::new()); - let _ = VenueError::Denied(String::new()); - let _ = VenueError::Unsupported(String::new()); - let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::InternalError(String::new()); - - 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()); - } -} diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 532e5582..9ae57a94 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -3,13 +3,14 @@ //! //! Parameterised over the [`RuntimeTypes`] lattice. The composition root //! builds the concrete [`Components`] and the extension list (including any -//! domain extension such as cow-api) and hands them here; this thin wrapper +//! domain extension) and hands them here; this thin wrapper //! forwards to the [`builder`](crate::builder) launcher and blocks until the //! event loop returns. A launcher that wants the //! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives //! [`LaunchRuntime`] directly. use std::path::Path; +use std::sync::Arc; use crate::addons::RuntimeAddOn; use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime}; @@ -34,7 +35,7 @@ pub async fn run( wasm: Option<&Path>, manifest: Option<&Path>, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], add_ons: &[&dyn RuntimeAddOn], ) -> anyhow::Result<()> { let runtime = AssembledRuntime { diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 262e5939..fda2b575 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -9,15 +9,18 @@ //! loop - and returns a [`RuntimeHandle`] owning the manager and the //! running tasks. //! -//! The reference binary reaches this through its `run_from_config` one-liner; +//! The engine binaries reach this through the `nexum-launch` preset run; //! an embedder holding pre-built backends constructs an [`AssembledRuntime`] //! and calls [`LaunchRuntime::launch`] directly. For the common case, //! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the -//! lattice, component builders, and add-ons in one call. +//! lattice, component builders, extensions, and add-ons in one call; +//! [`RuntimeBuilder::with_runtime`] binds a preset value carrying pre-built +//! backends. use std::future::{Future, IntoFuture}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Duration; use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; @@ -29,7 +32,7 @@ use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, }; -use crate::host::extension::Extension; +use crate::host::extension::{EventSources, Extension}; use crate::host::logs::LogPipeline; use crate::preset::Runtime; use crate::runtime::event_loop; @@ -127,8 +130,9 @@ fn finish_wait(joined: Option) -> anyhow::Result<()> { pub struct AssembledRuntime<'a, T: RuntimeTypes> { /// Shared backends threaded into every module store. pub components: Components, - /// Linker hooks and capability namespaces. - pub extensions: Vec>, + /// Extensions: namespaces, capabilities, linker hooks, services, and + /// provider kinds. + pub extensions: Vec>>, /// Cross-cutting facilities installed before the engine boots. pub add_ons: &'a [&'a dyn RuntimeAddOn], /// Single-module source override; `None` runs `[[modules]]`. @@ -264,15 +268,29 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // the components. let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); - // Status polling runs only when it can produce something a module - // 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; + // Extension event sources open only for subscription kinds some + // loaded module declares; each extension gates further on its own + // service state and returns no stream when it has nothing to + // observe. + let subscribed = supervisor.extension_subscription_kinds(); + let mut reconnect_tasks = TaskSet::new(); + let mut extension_streams = Vec::new(); + { + let mut sources = EventSources::new( + engine_cfg, + supervisor.services(), + &subscribed, + &executor, + &mut reconnect_tasks, + ); + for ext in &extensions { + extension_streams.extend(ext.events(&mut sources)?); + } + } // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() && !poll_statuses { + if block_chains.is_empty() && chain_log_subs.is_empty() && extension_streams.is_empty() { if supervisor.dead_modules_hold_subscriptions() { anyhow::bail!( "every declared [[subscription]] belongs to an init-failed module - \ @@ -293,7 +311,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // Open per-chain block subscriptions + per-module chain-log // subscriptions through the executor, then drive them in the event // loop until shutdown. - let mut reconnect_tasks = TaskSet::new(); let block_streams = event_loop::open_block_streams( &components.chain, &block_chains, @@ -306,15 +323,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - let intent_status_stream = poll_statuses.then(|| { - event_loop::open_intent_status_stream( - supervisor.pool_router(), - engine_cfg.limits.status_poll_interval(), - &executor, - &mut reconnect_tasks, - ) - }); - // The event-loop task holds the graceful guard until `run` returns // (after its final dispatch and cursor commit); shutdown ends the // loop between dispatches rather than cancelling it, so the drain @@ -325,7 +333,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, - intent_status_stream, + extension_streams, reconnect_tasks, graceful.into_future(), ) @@ -366,39 +374,45 @@ impl<'a> RuntimeBuilder<'a> { } } - /// Bind a [`Runtime`] preset that bundles the lattice, the component - /// builders, and the add-on set. Sugar over the type-state chain: an + /// Bind a [`Runtime`] preset by marker. Sugar over + /// [`with_runtime`](Self::with_runtime) for a `Default` preset: an /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. - pub fn runtime(self) -> PresetBuilder<'a, R> { + pub fn runtime(self) -> PresetBuilder<'a, R> { + self.with_runtime(R::default()) + } + + /// Bind a [`Runtime`] preset by value, so a preset can carry pre-built + /// backends and extensions into the launch. + pub fn with_runtime(self, preset: R) -> PresetBuilder<'a, R> { PresetBuilder { config: self.config, + preset, extensions: Vec::new(), wasm: None, manifest: None, clocks: None, - _r: PhantomData, } } } /// Terminal stage of the preset shortcut: the [`Runtime`] preset supplies the -/// lattice, the component builders, and the add-on set, leaving only the -/// optional extension hooks and module source before [`launch`](Self::launch). +/// lattice, the component builders, its extensions, and the add-on set, +/// leaving only the optional extension hooks and module source before +/// [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, - extensions: Vec>, + preset: R, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - _r: PhantomData R>, } impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Add extension linker hooks and capability namespaces on top of the - /// preset. The default preset carries none. + /// Append extensions on top of the preset's own. pub fn with_extensions( mut self, - extensions: impl IntoIterator>, + extensions: impl IntoIterator>>, ) -> Self { self.extensions.extend(extensions); self @@ -421,8 +435,9 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } /// Open the preset's backends and launch. Builds the [`Components`] bundle - /// from the preset's component builders, installs the preset's add-ons, - /// then drives [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. + /// from the preset's component builders, gathers the preset's extensions + /// (appended ones after), installs the preset's add-ons, then drives + /// [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -432,16 +447,21 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let components = R::components().build::(&build_ctx).await?; - + let mut extensions = self.preset.extensions(self.config); + extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. - let add_ons = R::add_ons(); + let add_ons = self.preset.add_ons(); let add_on_refs: Vec<&dyn RuntimeAddOn> = add_ons.iter().map(|a| &**a).collect(); + let components = self + .preset + .components() + .build::(&build_ctx) + .await?; let runtime = AssembledRuntime { components, - extensions: self.extensions, + extensions, add_ons: &add_on_refs, wasm: self.wasm.as_deref(), manifest: self.manifest.as_deref(), @@ -459,7 +479,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { /// may be added before the component builders. pub struct TypedBuilder<'a, T: RuntimeTypes> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -467,8 +487,11 @@ pub struct TypedBuilder<'a, T: RuntimeTypes> { } impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { - /// Add the extension linker hooks and capability namespaces. - pub fn with_extensions(mut self, extensions: impl IntoIterator>) -> Self { + /// Add the extensions. + pub fn with_extensions( + mut self, + extensions: impl IntoIterator>>, + ) -> Self { self.extensions.extend(extensions); self } @@ -490,10 +513,10 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// Bind the component builders that open the backends at launch. - pub fn with_components( + pub fn with_components( self, - components: ComponentsBuilder, - ) -> ComponentsStage<'a, T, C, S, E> { + components: ComponentsBuilder, + ) -> ComponentsStage<'a, T, C, S, E, L> { ComponentsStage { config: self.config, extensions: self.extensions, @@ -507,19 +530,22 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// The component builders are bound; the add-on set remains. -pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { +pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, _t: PhantomData T>, } -impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { +impl<'a, T: RuntimeTypes, C, S, E, L> ComponentsStage<'a, T, C, S, E, L> { /// Bind the cross-cutting add-on set installed before the engine boots. - pub fn with_add_ons(self, add_ons: &'a [&'a dyn RuntimeAddOn]) -> ReadyBuilder<'a, T, C, S, E> { + pub fn with_add_ons( + self, + add_ons: &'a [&'a dyn RuntimeAddOn], + ) -> ReadyBuilder<'a, T, C, S, E, L> { ReadyBuilder { config: self.config, extensions: self.extensions, @@ -534,22 +560,23 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// The assembly is complete; [`launch`](Self::launch) opens the backends and /// runs. -pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { +pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, add_ons: &'a [&'a dyn RuntimeAddOn], } -impl ReadyBuilder<'_, T, C, S, E> +impl ReadyBuilder<'_, T, C, S, E, L> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { /// Open the backends and launch. Builds the [`Components`] bundle from the /// bound builders, then drives [`LaunchRuntime::launch`] with a fresh @@ -587,9 +614,14 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; + use crate::addons::AddOns; use crate::engine_config::EngineConfig; - use crate::host::component::{LocalStoreBuilder, ProviderPoolBuilder}; - use crate::preset::CoreRuntime; + use crate::host::component::{LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder}; + use crate::host::state::HostState; + use crate::manifest::NamespaceCaps; + use crate::preset::{CoreRuntime, Runtime as RuntimePreset}; + use crate::test_utils::Prebuilt; + use wasmtime::component::Linker; /// The preset shortcut is exercised at runtime, not just compiled: the /// component builders open the backends, the add-ons install, and the @@ -613,6 +645,154 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } + /// Counts linker hook runs, so a test observes an extension reaching the + /// launch's linker build. + struct CountingExt { + namespace: &'static str, + prefix: &'static str, + linked: Arc, + } + + impl Extension for CountingExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: self.prefix, + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + self.linked.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + /// A value-bound preset carrying its own extension. + struct ExtPreset { + linked: Arc, + } + + impl crate::sealed::SealedRuntime for ExtPreset {} + + impl RuntimePreset for ExtPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + + fn extensions(&self, _config: &EngineConfig) -> Vec>> { + vec![Arc::new(CountingExt { + namespace: "alpha", + prefix: "alpha:ext/", + linked: self.linked.clone(), + })] + } + } + + /// The preset's own extensions and the appended ones both reach the + /// launch's linker build, each linked exactly once, before the boot + /// bails on the empty module set. + #[tokio::test] + async fn preset_extensions_and_appended_extensions_both_link() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let preset_linked = Arc::new(AtomicUsize::new(0)); + let appended_linked = Arc::new(AtomicUsize::new(0)); + let appended: Arc> = Arc::new(CountingExt { + namespace: "beta", + prefix: "beta:ext/", + linked: appended_linked.clone(), + }); + + let err = match RuntimeBuilder::new(&config) + .with_runtime(ExtPreset { + linked: preset_linked.clone(), + }) + .with_extensions([appended]) + .launch() + .await + { + Ok(_) => panic!("default config declares no modules; launch must bail"), + Err(err) => err, + }; + assert!(err.to_string().contains("no modules to run"), "{err}"); + assert_eq!(preset_linked.load(Ordering::SeqCst), 1, "preset extension"); + assert_eq!( + appended_linked.load(Ordering::SeqCst), + 1, + "appended extension" + ); + } + + /// A value-bound preset handing back an already-built backend. + struct PrebuiltLogsPreset { + logs: LogPipeline, + } + + impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {} + + impl RuntimePreset for PrebuiltLogsPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = Prebuilt; + + fn components( + self, + ) -> ComponentsBuilder> + { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(Prebuilt(self.logs)) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + } + + /// `components(self)` hands a pre-built instance through the preset seam: + /// the built bundle carries the exact pipeline the preset owned. + #[tokio::test] + async fn preset_hands_over_a_prebuilt_backend() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = TaskManager::new(); + let executor = tasks.executor(); + let build_ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = PrebuiltLogsPreset { + logs: custom.clone(), + } + .components() + .build::(&build_ctx) + .await + .expect("build from the preset's builders"); + + assert!( + Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the preset's pre-built pipeline", + ); + } + /// when every configured module fails `init`, launch must /// abort with an operator-facing error instead of idling behind an /// empty event loop. diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index ea278de3..5a2ea0a1 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,12 +26,77 @@ 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::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +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); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default lifetime of one status watch before it is evicted unreported. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); + +/// Per-caller submission quota toward installed providers. 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. +/// Resolved from `[limits.quota]`; the extension service that meters +/// callers consumes it. +#[derive(Debug, Clone, Copy)] +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 SubmitQuota { + /// Pair a budget with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for SubmitQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// Bounds on a provider status-watch set. The cap bounds the per-cadence +/// poll fan-out; the expiry evicts a watch whose provider has gone silent +/// for a whole window. Resolved from `[limits.watch]`. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// How long a watch survives without a successful poll before it is + /// evicted unreported. + pub expiry: Duration, +} + +impl WatchLimit { + /// Pair a cap with the per-entry expiry. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self { + max_entries, + expiry, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// Errors surfaced by [`load_or_default`]. /// /// Library-side modules must not propagate `anyhow::Error`; the rust @@ -86,11 +151,11 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, - /// Venue adapters the supervisor should boot alongside the modules. - /// Each entry resolves a `(component.wasm, module.toml)` pair like a - /// module, but the operator scopes its transport here rather than in - /// the adapter's own manifest: the installer of a venue adapter, not - /// the adapter author, decides which hosts and messaging topics it may + /// Provider components the supervisor should boot alongside the + /// modules. Each entry resolves a `(component.wasm, module.toml)` pair + /// like a module, but the operator scopes its transport here rather + /// than in the provider's own manifest: the installer of a provider, + /// not its author, decides which hosts and messaging topics it may /// reach. #[serde(default)] pub adapters: Vec, @@ -284,9 +349,9 @@ 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 -/// enough that a settling intent is observed within a block time or two, -/// slow enough that per-receipt venue calls stay negligible. +/// Default cadence for provider status polling (5 s). Fast enough that a +/// settling submission is observed within a block time or two, slow +/// enough that per-receipt provider calls stay negligible. const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: @@ -351,12 +416,15 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, - /// Per-caller intent submission quota. + /// Per-caller provider submission quota. #[serde(default)] pub quota: QuotaLimitsSection, - /// Router-driven intent status polling cadence. + /// Provider status polling cadence. #[serde(default)] pub status_poll: StatusPollSection, + /// Status-watch set bounds. + #[serde(default)] + pub watch: WatchLimitsSection, /// Per-module dispatch rate-limit thresholds. #[serde(default)] pub dispatch: DispatchLimitsSection, @@ -488,11 +556,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 - /// misconfigured budget still admits one submission rather than bricking - /// every venue. - pub fn quota(&self) -> PoolQuota { - PoolQuota::new( + /// `max_charges` is saturated up to 1 by the consuming service, so a + /// misconfigured budget still admits one submission rather than + /// bricking every provider. + pub fn quota(&self) -> SubmitQuota { + SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), self.quota .window_secs @@ -500,6 +568,23 @@ impl ModuleLimits { .unwrap_or(DEFAULT_QUOTA_WINDOW), ) } + + /// Resolved status-watch bounds (overrides or defaults). A zero + /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, + /// so a misconfigured bound still watches one receipt briefly rather + /// than nothing at all. + pub fn watch(&self) -> WatchLimit { + WatchLimit::new( + self.watch + .max_entries + .map(|n| n.max(1)) + .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES), + self.watch + .expiry_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_WATCH_EXPIRY), + ) + } } /// `[limits.http]` outbound wasi:http limits. Every field is optional; @@ -587,13 +672,13 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } -/// `[limits.quota]` per-caller intent submission budget. Both optional; -/// omitted values resolve to the router defaults via [`ModuleLimits::quota`]. +/// `[limits.quota]` per-caller provider submission budget. Both optional; +/// omitted values resolve to the 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 /// charged back to the caller counts the same, so a module feeding garbage -/// bodies exhausts its own budget rather than the adapter's fuel. +/// bodies exhausts its own budget rather than the provider's fuel. #[derive(Debug, Default, Deserialize)] pub struct QuotaLimitsSection { /// Maximum submissions (plus charged decode failures) per caller in the @@ -603,19 +688,35 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } -/// `[limits.status_poll]` intent status polling cadence. Optional; an +/// `[limits.status_poll]` provider status polling cadence. Optional; an /// 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 -/// `status` export for the receipts it watches; only observed transitions -/// fan out as `intent-status` events. +/// The cadence is how often the consuming service polls each installed +/// provider's `status` export for the receipts it watches; only observed +/// transitions fan out as events. #[derive(Debug, Default, Deserialize)] pub struct StatusPollSection { /// Milliseconds between status poll sweeps. pub interval_ms: Option, } +/// `[limits.watch]` status-watch set bounds. Both optional; omitted +/// values resolve to the defaults via [`ModuleLimits::watch`] and +/// degenerate zeroes saturate up to a usable minimum. +/// +/// The consuming service watches each accepted receipt until a terminal +/// status: the cap bounds the per-cadence poll fan-out, and the expiry +/// evicts a watch whose provider never reports one. At the cap a new +/// watch is refused and logged; live watches are never dropped. +#[derive(Debug, Default, Deserialize)] +pub struct WatchLimitsSection { + /// Maximum receipts under status watch at once. + pub max_entries: Option, + /// Seconds one watch stays live before it is evicted unreported. + pub expiry_secs: Option, +} + /// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both /// optional; omitted values resolve to the production defaults, and a /// degenerate zero saturates up to 1 via [`ModuleLimits::dispatch_rate`]. @@ -1039,9 +1140,9 @@ window_secs = 0 let cfg: EngineConfig = toml::from_str( r#" [[adapters]] -path = "adapters/cow/cow_adapter.wasm" -http_allow = ["api.cow.fi", "*.cow.fi"] -messaging_topics = ["/nexum/1/cow-orders/proto"] +path = "providers/acme/acme_provider.wasm" +http_allow = ["api.acme.example", "*.acme.example"] +messaging_topics = ["/nexum/1/acme-orders/proto"] [[adapters]] path = "adapters/bare/bare.wasm" @@ -1051,10 +1152,13 @@ manifest = "adapters/bare/module.toml" .expect("adapters parse"); assert_eq!(cfg.adapters.len(), 2); let first = &cfg.adapters[0]; - assert_eq!(first.path, PathBuf::from("adapters/cow/cow_adapter.wasm")); + assert_eq!( + first.path, + PathBuf::from("providers/acme/acme_provider.wasm") + ); assert!(first.manifest.is_none(), "manifest defaults to sibling"); - assert_eq!(first.http_allow, vec!["api.cow.fi", "*.cow.fi"]); - assert_eq!(first.messaging_topics, vec!["/nexum/1/cow-orders/proto"]); + assert_eq!(first.http_allow, vec!["api.acme.example", "*.acme.example"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/acme-orders/proto"]); let second = &cfg.adapters[1]; assert_eq!( second.manifest.as_deref(), @@ -1110,6 +1214,45 @@ refill_per_sec = 0 assert_eq!(policy.refill_per_sec, 1); } + #[test] + fn watch_limits_default_when_absent() { + let watch = ModuleLimits::default().watch(); + assert_eq!(watch.max_entries, DEFAULT_WATCH_MAX_ENTRIES); + assert_eq!(watch.expiry, DEFAULT_WATCH_EXPIRY); + } + + #[test] + fn watch_limits_parse_with_overrides() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 32 +expiry_secs = 900 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 32); + assert_eq!(watch.expiry, Duration::from_secs(900)); + } + + #[test] + fn watch_limits_saturate_zero_up_to_one() { + // A zero cap would refuse every watch; a zero expiry would evict + // each watch before its first poll. Both saturate. + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 0 +expiry_secs = 0 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 1); + assert_eq!(watch.expiry, Duration::from_secs(1)); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs new file mode 100644 index 00000000..c10f8e0a --- /dev/null +++ b/crates/nexum-runtime/src/host/actor.rs @@ -0,0 +1,108 @@ +//! The supervised host-actor primitive: one component instance the host +//! holds and others call. The store is refuelled before each guest call, +//! a trap is projected onto a typed fault instead of unwinding into the +//! caller, and each instance sits behind an [`ActorSlot`] async mutex held +//! across the guest await, so one store never runs two guest calls at once. + +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +use tokio::sync::Mutex as AsyncMutex; +use wasmtime::Store; + +use super::component::RuntimeTypes; +use super::state::HostState; + +/// One supervised actor behind its serialising mutex. A wasmtime `Store` +/// is not `Sync`; concurrent callers queue here. +pub type ActorSlot = Arc>; + +/// Shared liveness of one supervised component. The store marks it dead on +/// a trap, recording when, so the supervisor's restart sweep can count the +/// backoff from the death rather than from the sweep that observed it. +/// Cloning shares the flag. Starts alive. +#[derive(Clone, Debug, Default)] +pub struct Liveness(Arc>>); + +impl Liveness { + /// Whether the component is currently callable. + pub fn is_alive(&self) -> bool { + self.lock().is_none() + } + + /// When the component died, while it is dead. + pub fn dead_since(&self) -> Option { + *self.lock() + } + + /// Mark the component dead: its store trapped and is unusable. Keeps + /// the first death instant when already dead. + pub fn mark_dead(&self) { + let mut died_at = self.lock(); + if died_at.is_none() { + *died_at = Some(Instant::now()); + } + } + + /// Mark the component alive again after a restart. + pub fn mark_alive(&self) { + *self.lock() = None; + } + + /// The flag, recovered from a poisoned lock: the state is a bare + /// `Option`, valid under any interleaving. + fn lock(&self) -> MutexGuard<'_, Option> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// A guest call failed outside the component's typed error space. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ActorFault { + /// The pre-call refuel failed; the guest was never entered. + #[error("refuel failed: {0}")] + Refuel(wasmtime::Error), + /// The guest trapped. Carries the root cause only; the wasm frame + /// list stays out of the caller-facing message. + #[error("trapped: {}", .0.root_cause())] + Trap(wasmtime::Error), +} + +/// A supervised component store: refuelled before each guest call so every +/// invocation starts from a full budget, with traps projected onto +/// [`ActorFault`] and recorded on the shared [`Liveness`]. +pub struct SupervisedStore { + store: Store>, + fuel_per_call: u64, + liveness: Liveness, +} + +impl SupervisedStore { + /// Supervise an instantiated store with a per-call fuel budget, + /// reporting traps on `liveness`. + pub fn new(store: Store>, fuel_per_call: u64, liveness: Liveness) -> Self { + Self { + store, + fuel_per_call, + liveness, + } + } + + /// Refuel, then run one guest call against the store. A trap marks the + /// shared liveness dead: the store is poisoned until reinstantiated. + pub async fn call( + &mut self, + call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, + ) -> Result { + self.store + .set_fuel(self.fuel_per_call) + .map_err(ActorFault::Refuel)?; + call(&mut self.store).await.map_err(|trap| { + self.liveness.mark_dead(); + ActorFault::Trap(trap) + }) + } +} diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 705896bf..6b4111cc 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -3,8 +3,9 @@ //! //! Each core backend is wrapped as a [`ComponentBuilder`], and //! [`ComponentsBuilder`] assembles the core seams (plus the lattice `Ext` -//! payload) into a [`Components`] bundle. The composition root names the -//! concrete builders once; boot drives them through this trait. +//! payload and the log pipeline) into a [`Components`] bundle. The +//! composition root names the concrete builders once; boot drives them +//! through this trait. use std::future::Future; use std::path::Path; @@ -81,10 +82,23 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// Builds the default [`LogPipeline`]: the byte-bounded in-memory backend +/// sized from `[limits.logs]`. +pub struct LogPipelineBuilder; + +impl ComponentBuilder for LogPipelineBuilder { + type Output = LogPipeline; + + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + Ok(LogPipeline::in_memory(ctx.config.limits.logs())) + } +} + /// Names the component slot whose build failed. The leaf cause stays an /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum BuildError { /// The chain backend builder failed. #[error("build the chain backend: {0}")] @@ -95,6 +109,9 @@ pub enum BuildError { /// The extension payload builder failed. #[error("build the extension payload: {0}")] Ext(anyhow::Error), + /// The log pipeline builder failed. + #[error("build the log pipeline: {0}")] + Logs(anyhow::Error), } /// The empty extension payload: a no-op builder for a core-only lattice @@ -107,41 +124,62 @@ impl ComponentBuilder for () { } } -/// Assembles the core backend builders and the lattice `Ext` builder into -/// a [`Components`] bundle. The log pipeline is sized from `[limits.logs]` -/// and built here; the embedder retains its read handle by cloning -/// [`Components::logs`] after the build. -pub struct ComponentsBuilder { +/// Assembles the core backend builders, the lattice `Ext` builder, and the +/// log pipeline builder into a [`Components`] bundle. The logs slot defaults +/// to [`LogPipelineBuilder`]; the embedder retains the read handle by +/// cloning [`Components::logs`] after the build. +pub struct ComponentsBuilder { /// Builds the chain backend ([`RuntimeTypes::Chain`]). pub chain: C, /// Builds the store backend ([`RuntimeTypes::Store`]). pub store: S, /// Builds the extension payload ([`RuntimeTypes::Ext`]). pub ext: E, + /// Builds the shared [`LogPipeline`]. + pub logs: L, } impl ComponentsBuilder { - /// Create a new [`ComponentsBuilder`]. + /// Create a new [`ComponentsBuilder`] with the default log pipeline. pub fn new(chain: C, store: S, ext: E) -> Self { - Self { chain, store, ext } + Self { + chain, + store, + ext, + logs: LogPipelineBuilder, + } + } +} + +impl ComponentsBuilder { + /// Replace the log pipeline builder. + pub fn with_logs(self, logs: L2) -> ComponentsBuilder { + ComponentsBuilder { + chain: self.chain, + store: self.store, + ext: self.ext, + logs, + } } - /// Drive each builder against `ctx`, then bundle the backends with a - /// fresh log pipeline. The builder outputs must match the lattice - /// seams: chain to [`RuntimeTypes::Chain`], store to - /// [`RuntimeTypes::Store`], ext to [`RuntimeTypes::Ext`]. A failing - /// sub-build returns the [`BuildError`] variant naming that slot. + /// Drive each builder against `ctx` and bundle the backends. The + /// builder outputs must match the lattice seams: chain to + /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to + /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`]. A + /// failing sub-build returns the [`BuildError`] variant naming that + /// slot. pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; let store = self.store.build(ctx).await.map_err(BuildError::Store)?; let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; - let logs = LogPipeline::in_memory(ctx.config.limits.logs()); + let logs = self.logs.build(ctx).await.map_err(BuildError::Logs)?; Ok(Components { chain, store, @@ -189,4 +227,31 @@ mod tests { // The bundle carries a live in-memory log pipeline. let _ = &components.logs; } + + /// `with_logs` substitutes the log pipeline builder: the bundle carries + /// the exact pipeline the custom builder yields. + #[tokio::test] + async fn with_logs_substitutes_the_pipeline() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = nexum_tasks::TaskManager::new(); + let executor = tasks.executor(); + let ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(crate::test_utils::Prebuilt(custom.clone())) + .build::(&ctx) + .await + .expect("build with a custom log pipeline"); + + assert!( + std::sync::Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the substituted pipeline", + ); + } } diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 072ebfe8..687abf3a 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -16,6 +16,7 @@ use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, /// structural ceiling; an operator allowlist narrows within it and /// never widens it. #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { #[strum(serialize = "eth_blockNumber")] EthBlockNumber, diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index 0adb15fa..fd9d24e1 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -11,7 +11,7 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; @@ -52,6 +52,8 @@ mod tests { #[derive(Clone, Copy, Default)] struct CoreTypes; + impl crate::sealed::SealedRuntimeTypes for CoreTypes {} + impl RuntimeTypes for CoreTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index a96cbeb1..33741499 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -5,7 +5,7 @@ //! Time, randomness, and outbound HTTP are deliberately not members: all //! are WASI concerns serviced per store (WasiCtxBuilder for clocks and //! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends such as cow-api are not core seams: they live behind +//! Domain backends are not core seams: they live behind //! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. use crate::host::component::{ChainProvider, StateStore}; @@ -13,7 +13,9 @@ use crate::host::component::{ChainProvider, StateStore}; /// Names the core backend seams a runtime assembly provides, plus the /// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core /// backend an extension needs. -pub trait RuntimeTypes: 'static { +/// +/// Sealed: a lattice opts in by also implementing the sealing marker. +pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { /// JSON-RPC dispatch and subscriptions. type Chain: ChainProvider + Clone + Send + Sync + 'static; /// Process-wide store vending per-module handles. diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index b3213f7f..635ca0b1 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -29,6 +29,21 @@ pub trait StateHandle { fn delete(&self, key: &str) -> Result<(), StorageError>; /// Enumerate module-visible keys starting with `prefix`. fn list_keys(&self, prefix: &str) -> Result, StorageError>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, StorageError> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } impl StateStore for LocalStore { @@ -59,4 +74,16 @@ impl StateHandle for ModuleStore { fn list_keys(&self, prefix: &str) -> Result, StorageError> { ModuleStore::list_keys(self, prefix) } + + fn contains(&self, key: &str) -> Result { + ModuleStore::contains(self, key) + } + + fn len(&self, key: &str) -> Result, StorageError> { + ModuleStore::len(self, key) + } + + fn count(&self, prefix: &str) -> Result { + ModuleStore::count(self, prefix) + } } diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 8bdb4f9b..65fbe9d7 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -15,32 +15,39 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { } /// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Mirrors the SDK `HostFault::label` -/// vocabulary. -pub(crate) fn fault_label(fault: &Fault) -> &'static str { +/// structured-log `kind` field. Emitted from the single-source +/// `nexum_world::fault_labels` vocabulary the SDK `HostFault::label` +/// mirrors. +pub fn fault_label(fault: &Fault) -> &'static str { + use nexum_world::fault_labels as labels; match fault { - Fault::Unsupported(_) => "unsupported", - Fault::Unavailable(_) => "unavailable", - Fault::Denied(_) => "denied", - Fault::RateLimited(_) => "rate_limited", - Fault::Timeout => "timeout", - Fault::InvalidInput(_) => "invalid_input", - Fault::Internal(_) => "internal", + Fault::Unsupported(_) => labels::UNSUPPORTED, + Fault::Unavailable(_) => labels::UNAVAILABLE, + Fault::Denied(_) => labels::DENIED, + Fault::RateLimited(_) => labels::RATE_LIMITED, + Fault::Timeout => labels::TIMEOUT, + Fault::InvalidInput(_) => labels::INVALID_INPUT, + Fault::Internal(_) => labels::INTERNAL, } } /// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The payload-bearing cases carry their own detail; the two -/// payload-free cases render a fixed phrase. -pub(crate) fn fault_message(fault: &Fault) -> &str { +/// field. The bindgen `Display` is the `{0:?}` debug form, so operator +/// logs render through this instead. The payload-bearing cases carry +/// their own detail; a rate limit keeps its `retry-after-ms` hint; +/// `timeout` renders a fixed phrase. +pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { match fault { Fault::Unsupported(m) | Fault::Unavailable(m) | Fault::Denied(m) | Fault::InvalidInput(m) - | Fault::Internal(m) => m, - Fault::RateLimited(_) => "rate limited", - Fault::Timeout => "timeout", + | Fault::Internal(m) => std::borrow::Cow::Borrowed(m), + Fault::RateLimited(rl) => match rl.retry_after_ms { + Some(ms) => std::borrow::Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => std::borrow::Cow::Borrowed("rate limited"), + }, + Fault::Timeout => std::borrow::Cow::Borrowed("timeout"), } } @@ -49,7 +56,7 @@ pub(crate) fn fault_message(fault: &Fault) -> &str { /// A structured JSON-RPC `ErrorResp` (the node returned a `code`, /// typically `-32000` for an `eth_call` revert) becomes a /// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so the SDK revert classifier can dispatch the ComposableCoW +/// so an SDK revert classifier can dispatch the revert /// envelopes. Everything else - transport failures, an unknown chain, /// bad params - becomes a shared [`Fault`]. impl From for ChainError { diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index f5b1b806..9d097fb0 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,39 +1,367 @@ -//! The extension seam: a linker hook plus the capability namespace an -//! extension contributes, assembled at the composition root and threaded -//! into every module linker. +//! The extension seam: what one extension contributes to the host - a +//! namespace, a capability namespace, a linker hook, an optional host +//! service, an optional provider kind, optional event sources, and +//! optional install predicates over the manifest sections it claims. +//! Assembled at the composition root and threaded into every module +//! linker. +use std::any::Any; +use std::collections::{BTreeMap, BTreeSet}; +use std::pin::Pin; use std::sync::Arc; -use wasmtime::component::Linker; +use async_trait::async_trait; +use futures::Stream; +use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; +use wasmtime::Store; +use wasmtime::component::{Component, Linker}; +use crate::bindings::nexum::host::types::Event; +use crate::engine_config::EngineConfig; +use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -use crate::manifest::NamespaceCaps; - -/// Adds an extension's WIT interfaces to a module linker. Runs after the -/// core interfaces and before instantiation. Takes only `&mut Linker`, so -/// the seam stays compatible with a future per-extension router that -/// serialises access to the non-`Sync` wasmtime `Store`. -pub type LinkerHook = Arc>) -> anyhow::Result<()> + Send + Sync>; - -/// One runtime extension: how to wire its interfaces into a module linker, -/// and the capability namespace enforcement must recognise for it. The two -/// travel together: a module that imports an extension interface boots only -/// if the linker entry AND the capability namespace are both registered -/// before instantiation. -pub struct Extension { - /// Linker contribution: adds the extension's imports to a module linker. - pub link: LinkerHook, - /// Capability namespace this extension owns, merged into enforcement so - /// a module importing the extension's interfaces still validates. - pub capabilities: NamespaceCaps, -} - -impl Clone for Extension { - fn clone(&self) -> Self { +use crate::manifest::{ExtensionSections, NamespaceCaps}; + +/// One runtime extension. A module that imports an extension interface +/// boots only if the linker entry AND the capability namespace are both +/// registered before instantiation. +pub trait Extension: Send + Sync + 'static { + /// Namespace this extension owns; keys its service in [`HostServices`]. + fn namespace(&self) -> &'static str; + + /// Capability namespace merged into enforcement so a module importing + /// the extension's interfaces still validates. + fn capabilities(&self) -> NamespaceCaps; + + /// Adds the extension's imports to a worker linker. Runs after the + /// core interfaces and before instantiation. Takes only `&mut Linker`, + /// so the seam stays compatible with a future per-extension router + /// that serializes access to the non-`Sync` wasmtime `Store`. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Host service this extension owns, published under its namespace on + /// [`HostServices`]. + fn service(&self) -> Option> { + None + } + + /// Provider kind this extension installs. + fn provider(&self) -> Option>> { + None + } + + /// Manifest section names this extension claims. A non-core section + /// no wired extension claims is refused at boot. + fn manifest_sections(&self) -> &'static [&'static str] { + &[] + } + + /// Admit one provider at install, over its opaque manifest sections. + /// Runs before compilation; an `Err` refuses the install fail-fast. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let _ = (provider, sections); + Ok(()) + } + + /// Admit one worker at install, over its own and the loaded + /// providers' opaque manifest sections. Runs before compilation; an + /// `Err` refuses the install fail-fast. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + let _ = (worker, sections, providers); + Ok(()) + } + + /// Manifest subscription kinds this extension's event sources emit. + /// A `[[subscription]]` entry of any other non-core kind is refused + /// at boot. + fn subscriptions(&self) -> &'static [&'static str] { + &[] + } + + /// Open the extension's event sources once the engine is booted. The + /// event loop merges the returned streams and dispatches each item to + /// the modules its kind and attributes admit. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + let _ = sources; + Ok(Vec::new()) + } +} + +/// One extension-observed event: dispatched to every module holding a +/// `[[subscription]]` of `kind` whose filters all match `attrs`. +pub struct ExtensionEvent { + /// Manifest subscription kind that routes this event. + pub kind: &'static str, + /// Routing attributes a subscription's filters match against. + pub attrs: Vec<(&'static str, String)>, + /// The host event delivered to each matching module. + pub event: Event, +} + +/// A stream of extension events the event loop merges and drives. +pub type ExtensionEventStream = Pin + Send>>; + +/// Ambient launch inputs for [`Extension::events`]: the loaded config, the +/// booted service map, the subscription kinds at least one module declares, +/// and the spawn surface for source tasks. +pub struct EventSources<'a> { + /// The loaded engine config. + pub config: &'a EngineConfig, + /// Extension-owned services, as booted. + pub services: &'a HostServices, + /// Extension subscription kinds declared by at least one module. + pub subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, +} + +impl<'a> EventSources<'a> { + /// Bundle the launch inputs for one [`Extension::events`] pass. + pub fn new( + config: &'a EngineConfig, + services: &'a HostServices, + subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, + ) -> Self { Self { - link: Arc::clone(&self.link), - capabilities: self.capabilities, + config, + services, + subscribed, + executor, + tasks, } } + + /// Spawn one event-source task through the engine's executor. The task + /// must end when its stream's receiver drops; the engine drains it on + /// shutdown. + pub fn spawn(&mut self, task: impl Future + Send + 'static) { + self.tasks.push(self.executor.spawn(async move { + task.await; + TaskExit::ReceiverGone + })); + } +} + +/// A type-erased host service an extension owns. Held per namespace on +/// `HostState::services` and downcast at the call site. Kept synchronous +/// so it stays `dyn`-compatible. +pub trait HostService: Any + Send + Sync + 'static {} + +/// A provider component kind: the host holds an instance behind the owning +/// extension's serialized service; others call it. `async_trait` carries +/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes. +#[async_trait] +pub trait ProviderKind: Send + Sync + 'static { + /// Manifest kind this provider answers for. + fn kind(&self) -> &'static str; + + /// Adds the provider's imports to a provider linker. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Instantiate one provider and install it behind the owning service. + /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a + /// boot error. + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result; +} + +/// One provider instance ready to install: the compiled component, the +/// linker the kind's [`ProviderKind::link`] populated, the supervised +/// store, the manifest `[config]` and extension sections, and the +/// per-call fuel budget. +pub struct ProviderInstance<'a, T: RuntimeTypes> { + /// Compiled provider component. + pub component: &'a Component, + /// Linker carrying the kind's imports plus the WASI base. + pub linker: &'a Linker>, + /// Store the instance runs in; the kind takes ownership. + pub store: Store>, + /// Manifest `[config]` handed to the guest `init`. + pub config: Vec<(String, String)>, + /// The provider's extension-owned manifest sections, so a kind can + /// hold the instance to its manifest claims at install. + pub sections: &'a ExtensionSections, + /// Fuel budget applied before each routed guest call. + pub fuel_per_call: u64, + /// Shared liveness the installed instance reports traps on and the + /// supervisor's restart sweep reads. + pub liveness: Liveness, +} + +/// One loaded provider as [`Extension::admit_worker`] sees it: its +/// namespace, registered kind, and opaque manifest sections. Manifest +/// data only, so the predicate is static and liveness-independent. +#[derive(Clone, Debug)] +pub struct ProviderManifest { + /// The provider's namespace: its manifest name. + pub name: String, + /// Registered kind spelling. + pub kind: &'static str, + /// The provider's extension-owned manifest sections. + pub sections: ExtensionSections, +} + +/// Outcome of one provider install. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Installed { + /// `init` succeeded; the instance is installed and routable. + Live, + /// `init` returned a fault; the instance is loaded but not routable. + Dead, +} + +/// Downcast a type-erased service to `S`. `None` when the type differs. +pub fn downcast_service(service: &Arc) -> Option> { + let service = Arc::clone(service); + let erased: Arc = service; + erased.downcast().ok() +} + +/// Immutable per-namespace service map: each extension's [`HostService`] +/// under its [`Extension::namespace`], built once at boot and shared by +/// every module store. +#[derive(Clone, Default)] +pub struct HostServices(Arc>>); + +impl std::fmt::Debug for HostServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_set().entries(self.0.keys()).finish() + } +} + +impl HostServices { + /// Collect each extension's service under its namespace. Refuses a + /// duplicate namespace. + pub fn from_extensions( + extensions: &[Arc>], + ) -> anyhow::Result { + let mut map = BTreeMap::new(); + for ext in extensions { + let Some(service) = ext.service() else { + continue; + }; + let namespace = ext.namespace(); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + } + Ok(Self(Arc::new(map))) + } + + /// The service under `namespace`, downcast to its concrete type. + /// `None` when the namespace is absent or the type does not match. + pub fn get(&self, namespace: &str) -> Option> { + downcast_service(self.0.get(namespace)?) + } + + /// The raw type-erased service under `namespace`. + pub fn raw(&self, namespace: &str) -> Option<&Arc> { + self.0.get(namespace) + } + + /// Publish `service` under `namespace`, refusing a duplicate. The boot + /// path seeds a service no extension registers yet. + pub fn with_service( + self, + namespace: &'static str, + service: Arc, + ) -> anyhow::Result { + let mut map = Arc::unwrap_or_clone(self.0); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + Ok(Self(Arc::new(map))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::supervisor::TestTypes; + + struct Registry(u64); + impl HostService for Registry {} + + struct Clockwork; + impl HostService for Clockwork {} + + struct ServiceExt { + namespace: &'static str, + service: Option>, + } + + impl Extension for ServiceExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn service(&self) -> Option> { + self.service.as_ref().map(Arc::clone) + } + } + + fn ext( + namespace: &'static str, + service: Arc, + ) -> Arc> { + Arc::new(ServiceExt { + namespace, + service: Some(service), + }) + } + + /// A registered service comes back under its namespace, downcast to + /// its concrete type; a wrong type or an absent namespace is `None`. + #[test] + fn get_downcasts_by_namespace() { + let services = + HostServices::from_extensions(&[ext("acme", Arc::new(Registry(7)))]).expect("build"); + + let registry = services.get::("acme").expect("registered"); + assert_eq!(registry.0, 7); + assert!(services.get::("acme").is_none()); + assert!(services.get::("absent").is_none()); + assert!(services.raw("acme").is_some()); + } + + /// A serviceless extension contributes nothing to the map. + #[test] + fn serviceless_extension_is_absent() { + let serviceless: Arc> = Arc::new(ServiceExt { + namespace: "quiet", + service: None, + }); + let services = HostServices::from_extensions(&[serviceless]).expect("build"); + assert!(services.raw("quiet").is_none()); + } + + /// Two services under one namespace refuse to build. + #[test] + fn duplicate_namespace_is_refused() { + let err = HostServices::from_extensions(&[ + ext("acme", Arc::new(Registry(1))), + ext("acme", Arc::new(Clockwork)), + ]) + .expect_err("duplicate namespace"); + assert!(err.to_string().contains("acme"), "{err}"); + } } diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index ec74e6c8..2624457e 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -268,26 +268,53 @@ mod tests { #[test] fn exact_host_passes() { - assert!(admit(&uri("https://api.cow.fi/v1/x"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("http://api.cow.fi/"), &allow(&["api.cow.fi"])).is_ok()); + assert!( + admit( + &uri("https://api.acme.example/v1/x"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("http://api.acme.example/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); } #[test] fn off_list_host_is_denied() { - assert!(denied("https://evil.example/", &["api.cow.fi"])); - assert!(denied("https://api.cow.fi.evil.example/", &["api.cow.fi"])); + assert!(denied("https://evil.example/", &["api.acme.example"])); + assert!(denied( + "https://api.acme.example.evil.example/", + &["api.acme.example"] + )); } #[test] fn empty_allowlist_denies_everything() { - assert!(denied("https://api.cow.fi/", &[])); + assert!(denied("https://api.acme.example/", &[])); assert!(denied("http://127.0.0.1/", &[])); } #[test] fn matching_is_case_insensitive() { - assert!(admit(&uri("https://API.COW.FI/"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("https://api.cow.fi/"), &allow(&["API.COW.FI"])).is_ok()); + assert!( + admit( + &uri("https://API.ACME.EXAMPLE/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("https://api.acme.example/"), + &allow(&["API.ACME.EXAMPLE"]) + ) + .is_ok() + ); } #[test] @@ -301,7 +328,10 @@ mod tests { #[test] fn exact_entry_does_not_match_subdomains() { - assert!(denied("https://sub.api.cow.fi/", &["api.cow.fi"])); + assert!(denied( + "https://sub.api.acme.example/", + &["api.acme.example"] + )); } #[test] @@ -321,13 +351,16 @@ mod tests { #[test] fn ports_do_not_affect_matching() { - let list = allow(&["api.cow.fi"]); - assert!(admit(&uri("https://api.cow.fi:8443/v1"), &list).is_ok()); - assert!(admit(&uri("http://api.cow.fi:80/v1"), &list).is_ok()); - assert!(denied("https://evil.example:443/", &["api.cow.fi"])); + let list = allow(&["api.acme.example"]); + assert!(admit(&uri("https://api.acme.example:8443/v1"), &list).is_ok()); + assert!(admit(&uri("http://api.acme.example:80/v1"), &list).is_ok()); + assert!(denied("https://evil.example:443/", &["api.acme.example"])); // A port spelled in the allowlist entry never matches: entries // are hosts, not authorities. - assert!(denied("https://api.cow.fi:8443/", &["api.cow.fi:8443"])); + assert!(denied( + "https://api.acme.example:8443/", + &["api.acme.example:8443"] + )); } // ----------------- SSRF-style bypass regressions (#57) --------- @@ -449,14 +482,14 @@ mod tests { for scheme in ["http", "https"] { assert!( admit( - &uri(&format!("{scheme}://api.cow.fi/")), - &allow(&["api.cow.fi"]) + &uri(&format!("{scheme}://api.acme.example/")), + &allow(&["api.acme.example"]) ) .is_ok() ); assert!(denied( &format!("{scheme}://evil.example/"), - &["api.cow.fi"] + &["api.acme.example"] )); } } @@ -464,7 +497,7 @@ mod tests { #[test] fn uri_without_authority_is_invalid_not_denied() { assert!(matches!( - admit(&uri("/relative/path"), &allow(&["api.cow.fi"])), + admit(&uri("/relative/path"), &allow(&["api.acme.example"])), Err(ErrorCode::HttpRequestUriInvalid) )); } @@ -491,7 +524,7 @@ mod tests { #[tokio::test] async fn send_request_denies_off_list_host_with_http_request_denied() { - let mut gate = HttpGate::new("test-module", allow(&["api.cow.fi"]), limits()); + let mut gate = HttpGate::new("test-module", allow(&["api.acme.example"]), limits()); let Err(err) = gate.send_request(request("http://evil.example/x"), config()) else { panic!("off-list host must be denied"); }; diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs index 32a17f09..e5abc7c2 100644 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ b/crates/nexum-runtime/src/host/impls/local_store.rs @@ -21,4 +21,16 @@ impl nexum::host::local_store::Host for HostState { async fn list_keys(&mut self, prefix: String) -> Result, Fault> { self.store.list_keys(&prefix).map_err(Fault::from) } + + async fn contains(&mut self, key: String) -> Result { + self.store.contains(&key).map_err(Fault::from) + } + + async fn len(&mut self, key: String) -> Result, Fault> { + self.store.len(&key).map_err(Fault::from) + } + + async fn count(&mut self, prefix: String) -> Result { + self.store.count(&prefix).map_err(Fault::from) + } } diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index bd450cb3..4cf45da5 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,7 +1,7 @@ //! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so //! `publish` reports `unsupported` and `query` returns empty, the same //! posture as `identity::accounts`. The per-store topic scope is enforced -//! ahead of that stub: a venue adapter carrying a +//! ahead of that stub: a provider carrying a //! `[[adapters]].messaging_topics` grant may only publish within it, so //! the egress boundary is live even though delivery is not. @@ -15,7 +15,7 @@ use crate::host::state::HostState; /// when it equals a scope entry or descends from one read as a path prefix /// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is /// the `/` path separator, so a grant never leaks into a longer sibling -/// segment (`/nexum/1/cow` does not admit `/nexum/1/cow-orders/...`). +/// segment (`/nexum/1/acme` does not admit `/nexum/1/acme-orders/...`). fn topic_in_scope(topic: &str, scope: &[String]) -> bool { if scope.is_empty() { return true; @@ -63,27 +63,27 @@ mod tests { #[test] fn exact_topic_is_admitted() { - let scope = vec!["/nexum/1/cow-orders/proto".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); } #[test] fn prefix_scope_admits_the_family_but_not_a_sibling() { let scope = vec!["/nexum/1/".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); // A sibling namespace stays out. - assert!(!topic_in_scope("/nexum/2/cow-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/2/acme-orders/proto", &scope)); } #[test] fn prefix_boundary_is_a_path_segment_not_a_substring() { // A scope entry without a trailing slash still bounds on the path // separator, so it cannot leak into a longer sibling segment. - let scope = vec!["/nexum/1/cow".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow", &scope)); - assert!(topic_in_scope("/nexum/1/cow/orders", &scope)); - assert!(!topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme", &scope)); + assert!(topic_in_scope("/nexum/1/acme/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/acme-orders/proto", &scope)); } } diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index a5b80c14..2247ed60 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -11,6 +11,5 @@ mod identity; mod local_store; mod logging; mod messaging; -mod pool; mod remote_store; mod types; 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/types.rs b/crates/nexum-runtime/src/host/impls/types.rs index a035a97d..f9516569 100644 --- a/crates/nexum-runtime/src/host/impls/types.rs +++ b/crates/nexum-runtime/src/host/impls/types.rs @@ -1,13 +1,8 @@ -//! `nexum:host/types` and the intent vocabulary it uses are type-only -//! interfaces (no functions). The generated traits are empty; we just -//! provide the marker impls. +//! `nexum:host/types` is a type-only interface (no functions). The +//! generated trait is empty; we just provide the marker impl. use crate::bindings::nexum; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; impl nexum::host::types::Host for HostState {} - -impl nexum::intent::types::Host for HostState {} - -impl nexum::value_flow::types::Host for HostState {} diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index bd7e16c7..8e2d95e4 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -105,6 +105,49 @@ impl ModuleStore { Ok(value) } + /// Whether `key` exists, without copying the value out. + pub fn contains(&self, key: &str) -> Result { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .is_some()) + } + + /// Value byte length for `key`, `Ok(None)` when absent. Reads the + /// entry's length in place; the value bytes are never copied out. + pub fn len(&self, key: &str) -> Result, StorageError> { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| v.value().len() as u64)) + } + + /// Number of module-visible keys starting with `prefix`. A bounded + /// B-tree range scan: no key strings are materialised. + pub fn count(&self, prefix: &str) -> Result { + let full_prefix = self.build_key(prefix); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + let mut count = 0u64; + for entry in table + .range(full_prefix.as_slice()..) + .map_err(StorageError::Storage)? + { + let (k, _v) = entry.map_err(StorageError::Storage)?; + if !k.value().starts_with(&full_prefix) { + break; + } + count += 1; + } + Ok(count) + } + /// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key, /// value, overhead) and rejects an over-quota write untouched. The commit /// is fsync-durable. @@ -242,6 +285,7 @@ impl ModuleStore { /// Errors surfaced by [`LocalStore`] and [`ModuleStore`]. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StorageError { #[error("open redb: {0}")] Open(#[source] redb::DatabaseError), diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 1191d8d1..3e1fd159 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -66,6 +66,47 @@ fn list_keys_strips_namespace_prefix() { assert!(keys.iter().all(|k| k.starts_with("posted:"))); } +#[test] +fn contains_answers_without_the_value() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("k", b"v").unwrap(); + assert!(ms.contains("k").unwrap()); + assert!(!ms.contains("missing").unwrap()); + ms.delete("k").unwrap(); + assert!(!ms.contains("k").unwrap()); +} + +#[test] +fn len_reports_value_bytes_or_none() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("empty", b"").unwrap(); + ms.set("k", b"abcde").unwrap(); + assert_eq!(ms.len("empty").unwrap(), Some(0)); + assert_eq!(ms.len("k").unwrap(), Some(5)); + assert_eq!(ms.len("missing").unwrap(), None); +} + +#[test] +fn count_matches_list_keys_and_respects_namespaces() { + let (_dir, store) = fresh(); + let a = store.module("a").unwrap(); + let b = store.module("b").unwrap(); + a.set("posted:1", b"x").unwrap(); + a.set("posted:2", b"y").unwrap(); + a.set("other", b"z").unwrap(); + b.set("posted:9", b"w").unwrap(); + assert_eq!(a.count("posted:").unwrap(), 2); + assert_eq!(a.count("").unwrap(), 3); + assert_eq!(a.count("nope:").unwrap(), 0); + assert_eq!(b.count("posted:").unwrap(), 1); + assert_eq!( + a.count("posted:").unwrap(), + a.list_keys("posted:").unwrap().len() as u64 + ); +} + #[test] fn rejects_empty_namespace() { let (_dir, store) = fresh(); diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index 74e0760f..b619bf3e 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -61,6 +61,7 @@ impl RunId { /// `source` field on the host tracing event. #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum LogSource { /// The `nexum:host/logging` glue: an explicit guest `log` call. HostInterface, diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 5c41e467..a2842435 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -15,15 +15,19 @@ //! - `impls` (private): the bindgen-side trait impls, one file per core //! WIT interface, that dispatch to the backends above. //! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook + capability -//! namespace) an extension is wired in through at the composition root. -//! Domain extensions such as cow-api live in their own crates and plug -//! in through this seam rather than being hard-linked into the core host. +//! - [`extension`]: the extension seam (linker hook, capability +//! namespace, service, provider kind, event sources) an extension is +//! wired in through at the composition root. Domain extensions live in +//! their own crates and plug in through this seam rather than being +//! hard-linked into the core host. +//! - [`actor`]: the supervised host-actor primitive provider instances +//! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module //! `[capabilities.http].allow` list. //! - [`logs`]: the typed module-log pipeline (capture points -> router -> //! tracing event + retention store) and its embedder read surface. +pub mod actor; pub mod component; pub mod error; pub mod extension; @@ -31,6 +35,5 @@ pub mod http; mod impls; pub mod local_store_redb; pub mod logs; -pub mod pool_router; pub mod provider_pool; pub mod state; diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs deleted file mode 100644 index a90797f0..00000000 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ /dev/null @@ -1,1075 +0,0 @@ -//! The intent pool router: the strategy-facing `nexum:intent/pool` 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. -//! -//! 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 -//! 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. -//! -//! Fuel cannot cross stores, so a module that spams undecodable bodies would -//! otherwise burn an adapter's budget for free. Two mechanisms close that: -//! a per-caller submission quota gates every submit before the adapter is -//! touched, and a decode failure (the adapter's `invalid-body`) is charged -//! to the calling module's quota, so a caller feeding garbage exhausts its -//! own budget rather than the adapter's. - -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use futures::future::BoxFuture; -use tokio::sync::Mutex as AsyncMutex; -use tracing::warn; -use wasmtime::Store; - -use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, SubmitOutcome, VenueAdapter, VenueError, -}; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. -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); - -/// 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 { - /// 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 { - /// Pair a budget with the window it is counted over. - pub const fn new(max_charges: u32, window: Duration) -> Self { - Self { - max_charges, - window, - } - } -} - -impl Default for PoolQuota { - 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 { - /// Decide whether the derived header may proceed to the adapter's submit. - fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; -} - -/// 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, - /// Adapter-derived header for the body. - pub header: &'a IntentHeader, -} - -/// The guard's decision on one egress. -pub enum GuardVerdict { - /// Forward the submission to the adapter. - Allow, - /// Refuse the egress with an operator-facing reason. - 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. -/// -/// 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. -pub trait VenueInvoker: Send { - /// Project the opaque body onto the stable header the guard runs on. - fn derive_header<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result>; - - /// Submit the opaque body to this adapter's venue. - fn submit<'a>(&'a mut self, body: &'a [u8]) - -> BoxFuture<'a, Result>; - - /// Report where a previously submitted intent is in its life. The receipt - /// is owned: it is used once, unlike the body a submission re-decodes. - fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; - - /// Ask the venue to withdraw an intent. - fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; -} - -/// 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 -/// calling module's store. -pub struct AdapterActor { - store: Store>, - bindings: VenueAdapter, - fuel_per_call: u64, -} - -impl AdapterActor { - /// Wrap an instantiated adapter store for routing. - pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { - Self { - store, - bindings, - fuel_per_call, - } - } - - /// Refuel the store before a guest call so each invocation starts from a - /// full budget, mirroring the supervisor's per-event refuel. - fn refuel(&mut self) -> Result<(), VenueError> { - self.store - .set_fuel(self.fuel_per_call) - .map_err(|e| VenueError::InternalError(format!("adapter refuel failed: {e}"))) - } -} - -/// Project a wasmtime trap into the venue-error space. The root cause is -/// 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())) -} - -impl VenueInvoker for AdapterActor { - fn derive_header<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.refuel()?; - match self - .bindings - .nexum_intent_adapter() - .call_derive_header(&mut self.store, body) - .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } - }) - } - - fn submit<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.refuel()?; - match self - .bindings - .nexum_intent_adapter() - .call_submit(&mut self.store, body) - .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } - }) - } - - fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { - Box::pin(async move { - self.refuel()?; - match self - .bindings - .nexum_intent_adapter() - .call_status(&mut self.store, &receipt) - .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } - }) - } - - fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { - Box::pin(async move { - self.refuel()?; - match self - .bindings - .nexum_intent_adapter() - .call_cancel(&mut self.store, &receipt) - .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } - }) - } -} - -/// One installed adapter behind its serialising mutex. -type AdapterSlot = Arc>; - -/// Per-caller charge history, pruned to the quota window on each touch. -#[derive(Default)] -struct QuotaLedger { - per_caller: HashMap>, -} - -/// One receipt the router 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, - 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 { - matches!( - status, - IntentStatus::Settled(_) - | IntentStatus::Failed(_) - | IntentStatus::Expired - | IntentStatus::Cancelled - ) -} - -/// 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, - 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 -/// module store. -#[derive(Clone)] -pub struct PoolRouter { - 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. - pub fn empty() -> Self { - PoolRouterBuilder::new(PoolQuota::default()).build() - } - - /// Resolve a venue id to its installed adapter slot. - fn resolve(&self, venue: &str) -> Result { - self.inner - .adapters - .get(venue) - .cloned() - .ok_or(VenueError::UnknownVenue) - } - - /// Whether `caller` has budget left in the current window. Read-only: it - /// prunes aged charges but does not record one. - fn quota_admits(&self, caller: &str) -> bool { - let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); - let history = ledger.per_caller.entry(caller.to_owned()).or_default(); - prune(history, self.inner.quota.window); - (history.len() as u32) < self.inner.quota.max_charges - } - - /// Record one charge against `caller`'s budget. - fn charge(&self, caller: &str) { - let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); - let history = ledger.per_caller.entry(caller.to_owned()).or_default(); - prune(history, self.inner.quota.window); - history.push_back(Instant::now()); - } - - /// Submit an opaque body to `venue` on behalf of `caller`: resolve the - /// adapter, gate on the caller's quota, derive the header, run the guard - /// seam, then forward to the adapter. A decode failure is charged to the - /// caller before returning, so a caller feeding garbage exhausts its own - /// budget and is stopped at the gate on the next call rather than - /// re-invoking the adapter. - /// - /// Charging is deliberately asymmetric across the two stages. Once the - /// guard admits the header the submission is charged before the adapter - /// call, so a forwarded submission spends one unit regardless of the - /// venue's outcome (the adapter did the work, and a transient venue - /// outage must not become a free retry loop). A derive-stage venue error - /// that is not a decode failure is the venue's fault, not the caller's, - /// so it is left uncharged and the caller may retry. - pub async fn submit( - &self, - caller: &str, - venue: &str, - 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. - if !self.quota_admits(caller) { - return Err(VenueError::Denied(format!( - "submission quota exhausted for caller {caller}" - ))); - } - let mut adapter = slot.lock().await; - let header = match adapter.derive_header(&body).await { - Ok(header) => header, - Err(e) => { - // Charge decode failures to the caller before the adapter is - // invoked again; other venue errors are not the caller's fault. - if matches!(e, VenueError::InvalidBody(_)) { - self.charge(caller); - } - return Err(e); - } - }; - let ctx = GuardContext { - caller, - venue, - header: &header, - }; - if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { - return Err(VenueError::Denied(reason)); - } - // A forwarded submission consumes one unit of the caller's budget. - self.charge(caller); - let outcome = adapter.submit(&body).await?; - // An accepted receipt goes under status watch so subscribers see - // its transitions; requires-signing has no receipt to watch yet. - if let SubmitOutcome::Accepted(receipt) = &outcome { - self.watch(venue, receipt.clone()); - } - Ok(outcome) - } - - /// 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) { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - if watched - .iter() - .any(|w| w.venue == venue && w.receipt == receipt) - { - return; - } - watched.push(WatchedIntent { - venue: venue.to_owned(), - receipt, - last: None, - }); - } - - /// Number of receipts currently under status watch. - pub fn watched_count(&self) -> usize { - self.inner - .watched - .lock() - .expect("watch list poisoned") - .len() - } - - /// Poll every watched receipt against its adapter's status export and - /// 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. - 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 watched = self.inner.watched.lock().expect("watch list poisoned"); - watched - .iter() - .map(|w| (w.venue.clone(), w.receipt.clone())) - .collect() - }; - let mut updates = Vec::new(); - for (venue, receipt) in snapshot { - // Installed adapters never leave the router, so a resolve - // failure here is unreachable; skip defensively regardless. - let Ok(slot) = self.resolve(&venue) else { - continue; - }; - let polled = { - let mut adapter = slot.lock().await; - adapter.status(receipt.clone()).await - }; - match polled { - Ok(status) => { - if let Some(update) = self.record_polled_status(&venue, &receipt, status) { - 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, - error = ?err, - "status poll failed - retrying on the next cadence", - ); - } - } - } - updates - } - - /// Fold one polled status into the watch entry: `Some(update)` when it - /// differs from the last reported status, pruning the entry when the - /// status is terminal. `None` also covers an entry that disappeared - /// while the poll was in flight. - fn record_polled_status( - &self, - venue: &str, - 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); - if is_terminal(&status) { - watched.remove(pos); - } else { - watched[pos].last = Some(status.clone()); - } - changed.then(|| IntentStatusUpdate { - venue: venue.to_owned(), - receipt: receipt.to_vec(), - status, - }) - } - - /// 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 { - let slot = self.resolve(venue)?; - let mut adapter = slot.lock().await; - adapter.status(receipt).await - } - - /// 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> { - let slot = self.resolve(venue)?; - let mut adapter = slot.lock().await; - adapter.cancel(receipt).await - } - - /// Number of installed, routable adapters. - pub fn venue_count(&self) -> usize { - self.inner.adapters.len() - } -} - -/// Drop charge timestamps that have aged out of the window. -fn prune(history: &mut VecDeque, window: Duration) { - let now = Instant::now(); - while let Some(&front) = history.front() { - if now.duration_since(front) > window { - history.pop_front(); - } else { - break; - } - } -} - -/// 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, -} - -impl PoolRouterBuilder { - /// Start an empty builder with the given quota and the no-op guard. - pub fn new(quota: PoolQuota) -> Self { - Self { - adapters: HashMap::new(), - guard: Arc::new(AllowAllGuard), - 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 { - self.guard = guard; - self - } - - /// Install an adapter under its venue id. Rejects a duplicate id: two - /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. - pub fn install( - &mut self, - venue: String, - invoker: impl VenueInvoker + 'static, - ) -> Result<(), DuplicateVenue> { - if self.adapters.contains_key(&venue) { - return Err(DuplicateVenue { venue }); - } - self.adapters - .insert(venue, Arc::new(AsyncMutex::new(invoker))); - Ok(()) - } - - /// Freeze the builder into a shared router. - pub fn build(self) -> PoolRouter { - 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 - // than bricking every venue. Mirrors the poison-policy clamp. - warn!("pool 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 { - adapters: self.adapters, - guard: self.guard, - quota, - ledger: Mutex::new(QuotaLedger::default()), - watched: Mutex::new(Vec::new()), - }), - } - } -} - -/// Two installed adapters claimed the same venue id. -#[derive(Debug, thiserror::Error)] -#[error("venue id {venue:?} is claimed by more than one installed adapter")] -pub struct DuplicateVenue { - /// The colliding venue id. - pub venue: String, -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use crate::bindings::nexum::intent::types::UnsignedTx; - use crate::bindings::value_flow::Settlement; - use crate::bindings::{AuthScheme, IntentHeader}; - - use super::*; - - /// 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. - #[derive(Default)] - struct StubCalls { - derive: AtomicUsize, - submit: AtomicUsize, - status: AtomicUsize, - cancel: AtomicUsize, - /// Highest number of overlapping invocations observed; proves the - /// per-adapter mutex serialises access. - max_concurrency: AtomicUsize, - live: AtomicUsize, - } - - struct StubAdapter { - calls: Arc, - derive: Result, - submit: Result, - /// Statuses served front-first by consecutive `status` calls; - /// once drained, every further call reports `open`. - status_script: VecDeque>, - } - - impl StubAdapter { - fn new(calls: Arc) -> Self { - Self { - calls, - derive: Ok(header()), - submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), - status_script: VecDeque::new(), - } - } - - fn with_derive(mut self, derive: Result) -> Self { - self.derive = derive; - self - } - - fn with_submit(mut self, submit: Result) -> Self { - self.submit = submit; - self - } - - fn with_status_script( - mut self, - script: impl IntoIterator>, - ) -> Self { - self.status_script = script.into_iter().collect(); - self - } - - async fn enter(&self) { - let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; - self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); - // Yield inside the critical section so any missing serialisation - // would let a second call observe `live == 2`. - tokio::task::yield_now().await; - self.calls.live.fetch_sub(1, Ordering::SeqCst); - } - } - - impl VenueInvoker for StubAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.calls.derive.fetch_add(1, Ordering::SeqCst); - self.enter().await; - self.derive.clone() - }) - } - - fn submit<'a>( - &'a mut self, - _body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.calls.submit.fetch_add(1, Ordering::SeqCst); - self.enter().await; - self.submit.clone() - }) - } - - fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { - Box::pin(async move { - self.calls.status.fetch_add(1, Ordering::SeqCst); - self.status_script - .pop_front() - .unwrap_or(Ok(IntentStatus::Open)) - }) - } - - fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { - Box::pin(async move { - self.calls.cancel.fetch_add(1, Ordering::SeqCst); - Ok(()) - }) - } - } - - /// A guard that refuses every egress with a fixed reason. - struct DenyGuard; - impl GuardPolicy for DenyGuard { - fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { - GuardVerdict::Deny("blocked by test policy".to_owned()) - } - } - - fn header() -> IntentHeader { - IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), - authorisation: AuthScheme::Unsigned, - } - } - - fn router_with( - quota: PoolQuota, - guard: Option>, - adapter: StubAdapter, - ) -> PoolRouter { - let mut builder = PoolRouterBuilder::new(quota); - if let Some(guard) = guard { - builder = builder.with_guard(guard); - } - builder - .install("cow".to_owned(), 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 outcome = router - .submit("mod-a", "cow", b"body".to_vec()) - .await - .expect("submit succeeds"); - - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); - assert_eq!(calls.derive.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 1); - } - - #[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 err = router - .submit("mod-a", "unlisted", b"body".to_vec()) - .await - .expect_err("unknown venue rejected"); - - assert!(matches!(err, VenueError::UnknownVenue)); - assert_eq!(calls.derive.load(Ordering::SeqCst), 0); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn guard_deny_blocks_submit_after_deriving_the_header() { - let calls = Arc::new(StubCalls::default()); - let router = router_with( - PoolQuota::default(), - Some(Arc::new(DenyGuard)), - StubAdapter::new(calls.clone()), - ); - - let err = router - .submit("mod-a", "cow", b"body".to_vec()) - .await - .expect_err("guard denies"); - - assert!(matches!(err, VenueError::Denied(reason) if reason.contains("test policy"))); - // The seam runs on the derived header, then blocks: derive ran, submit - // did not. - assert_eq!(calls.derive.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn submission_quota_denies_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())); - - 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()) - .await - .expect_err("third submit over quota"); - - assert!(matches!(err, VenueError::Denied(reason) if reason.contains("quota"))); - // 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); - } - - #[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())); - - 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(), - "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(), - "mod-b has an independent budget" - ); - } - - #[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 adapter = - StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); - let router = router_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; - 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(_)))); - assert_eq!( - calls.derive.load(Ordering::SeqCst), - 1, - "adapter derive-header was invoked exactly once", - ); - } - - #[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 adapter = StubAdapter::new(calls.clone()) - .with_derive(Err(VenueError::Unavailable("rpc down".into()))); - let router = router_with(quota, None, adapter); - - assert!(matches!( - router.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, - Err(VenueError::Unavailable(_)) - )); - assert_eq!(calls.derive.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn status_and_cancel_pass_through_without_quota() { - 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())); - - assert!(matches!( - router.status("cow", b"r".to_vec()).await, - Ok(IntentStatus::Open) - )); - assert!(router.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); - } - - #[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 mut handles = Vec::new(); - for _ in 0..8 { - let router = router.clone(); - handles.push(tokio::spawn(async move { - let _ = router.submit("mod-a", "cow", b"b".to_vec()).await; - })); - } - for h in handles { - h.await.expect("task joins"); - } - // The adapter mutex is held across the guest await, so no two calls - // ever overlapped inside the adapter. - assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); - } - - #[test] - fn duplicate_venue_id_is_rejected() { - let mut builder = PoolRouterBuilder::new(PoolQuota::default()); - let a = Arc::new(StubCalls::default()); - let b = Arc::new(StubCalls::default()); - builder - .install("cow".to_owned(), StubAdapter::new(a)) - .expect("first install"); - let err = builder - .install("cow".to_owned(), StubAdapter::new(b)) - .expect_err("second install collides"); - 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); - } - - // ── status watch + polling ──────────────────────────────────────── - - #[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)); - - assert_eq!(router.watched_count(), 0); - router - .submit("mod-a", "cow", b"body".to_vec()) - .await - .expect("submit succeeds"); - assert_eq!(router.watched_count(), 1); - - // Re-submitting the same receipt does not double-watch it. - router - .submit("mod-a", "cow", b"body".to_vec()) - .await - .expect("submit succeeds"); - assert_eq!(router.watched_count(), 1); - } - - #[tokio::test] - async fn requires_signing_outcome_is_not_watched() { - let calls = Arc::new(StubCalls::default()); - let adapter = - StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { - chain_id: 1, - to: vec![0u8; 20], - value: Vec::new(), - input: Vec::new(), - }))); - let router = router_with(PoolQuota::default(), None, adapter); - - router - .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()); - } - - #[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()) - .await - .expect("submit succeeds"); - - // First poll: `last` is unset, so the current status reports. - let first = router.poll_status_transitions().await; - assert_eq!(first.len(), 1); - assert_eq!(first[0].venue, "cow"); - assert_eq!(first[0].receipt, b"receipt"); - assert_eq!(first[0].status, IntentStatus::Open); - - // Second poll: same status, nothing to report. - assert!(router.poll_status_transitions().await.is_empty()); - assert_eq!(calls.status.load(Ordering::SeqCst), 2); - assert_eq!(router.watched_count(), 1, "open is not terminal"); - } - - #[tokio::test] - async fn poll_reports_each_transition_and_prunes_on_terminal() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls).with_status_script([ - Ok(IntentStatus::Pending), - Ok(IntentStatus::Pending), - Ok(IntentStatus::Open), - Ok(IntentStatus::Settled(Some(b"tx".to_vec()))), - ]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .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); - } - let statuses: Vec<&IntentStatus> = seen.iter().map(|u| &u.status).collect(); - assert_eq!( - statuses, - vec![ - &IntentStatus::Pending, - &IntentStatus::Open, - &IntentStatus::Settled(Some(b"tx".to_vec())), - ], - "the repeated pending is deduplicated; each transition reports once", - ); - assert_eq!(router.watched_count(), 0, "settled prunes the watch"); - // A further poll has nothing left to ask the adapter about. - assert!(router.poll_status_transitions().await.is_empty()); - } - - #[tokio::test] - async fn poll_failure_keeps_the_watch_for_the_next_cadence() { - 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()) - .await - .expect("submit succeeds"); - - assert!(router.poll_status_transitions().await.is_empty()); - assert_eq!( - router.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; - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].status, IntentStatus::Open); - } - - #[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", - ); - } -} diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 23230d4f..55d5e912 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -428,7 +428,7 @@ pub enum ProviderError { /// Decoded `ErrorResp.data` payload - for `eth_call` reverts /// this is the abi-encoded revert body, hex-decoded from the /// upstream JSON string once here (consumed directly by - /// `shepherd_sdk::cow::decode_revert`). `None` when the failure + /// an SDK revert decoder). `None` when the failure /// was transport-level or the payload was not a hex string. data: Option>, /// Transport-side typed error. diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index d299ccd0..2023baf4 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -11,9 +11,9 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; +use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -use super::pool_router::PoolRouter; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -30,8 +30,8 @@ pub struct HostState { pub http_gate: HttpGate, /// Messaging content topics this store may publish to. Empty means /// unscoped (the module default and current messaging posture); a - /// venue adapter carries its `[[adapters]].messaging_topics` grant - /// here, so an out-of-scope publish is refused before it reaches the + /// provider carries its `[[adapters]].messaging_topics` grant here, + /// so an out-of-scope publish is refused before it reaches the /// backend. pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart @@ -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. - /// Every module store carries the same shared handle; an adapter store, - /// which cannot call pool, carries an empty one. - pub pool_router: PoolRouter, + /// Extension-owned host services, keyed by extension namespace and + /// downcast at the call site. One shared map across every module store; + /// a provider store carries an empty map. + pub services: HostServices, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index 1d200b90..4a29dd47 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -1,6 +1,11 @@ //! Nexum runtime: a wasmtime-based host for WASM Component Model //! modules, usable as an embeddable library. The bundled binary is a //! thin consumer of the same public surface. +//! +//! Zero-leak charter: this crate is settlement-domain-agnostic. It +//! carries no domain symbol or WIT reference, `nexum:host` stays a +//! leaf WIT package, and no crate edge reaches a domain crate. The +//! zero-leak script under `scripts/` enforces this in CI. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -12,6 +17,14 @@ use alloy_rpc_client as _; use alloy_transport as _; use alloy_transport_ws as _; +/// Sealing markers for [`preset::Runtime`] and +/// [`host::component::RuntimeTypes`]: implement alongside the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedRuntimeTypes {} + pub trait SealedRuntime {} +} + pub mod addons; pub mod bindings; pub mod bootstrap; diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 50108786..3492634f 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -24,7 +24,7 @@ use super::types::{CORE_CAPABILITIES, LoadedManifest}; /// One WIT namespace prefix plus the interface names under it that count as /// capabilities. Core registers `nexum:host/`; an extension registers its -/// own (e.g. `shepherd:cow/`). +/// own. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -39,33 +39,21 @@ 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, -}; - -/// The interfaces a `venue-adapter` world links: the scoped transport -/// only. An adapter has no local-store, remote-store, identity, or -/// logging - it moves bytes to and from its venue and nothing else. `http` -/// is not listed here for the same reason it is not in the core set: it +/// The interfaces a provider world links: the scoped transport only. A +/// provider has no local-store, remote-store, identity, or logging - it +/// moves bytes to and from its counterparty and nothing else. `http` is +/// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const ADAPTER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = + &[nexum_world::caps::CHAIN, nexum_world::caps::MESSAGING]; -/// The adapter namespace: the same `nexum:host/` prefix as core but only -/// the scoped-transport interfaces. Validating an adapter manifest against +/// The provider namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating a provider manifest against /// a registry built from this namespace rejects a declaration of any core -/// interface an adapter must not reach (e.g. `local-store`) as unknown. -pub const ADAPTER_NAMESPACE: NamespaceCaps = NamespaceCaps { +/// interface a provider must not reach (e.g. `local-store`) as unknown. +pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { prefix: "nexum:host/", - ifaces: ADAPTER_CAPABILITIES, + ifaces: PROVIDER_CAPABILITIES, }; /// Import prefix of the wasi:http package. Every interface under it @@ -75,7 +63,7 @@ const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = "http"; +const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; /// Gated WASI capability names. Declaring one grants the matching `wasi:` /// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, @@ -126,22 +114,21 @@ 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. + /// The registry with the core `nexum:host/` namespace. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE, INTENT_NAMESPACE], + namespaces: vec![CORE_NAMESPACE], } } - /// The registry a venue adapter validates against: only the scoped - /// transport interfaces plus `http`. An adapter manifest that declares + /// The registry a provider validates against: only the scoped + /// transport interfaces plus `http`. A provider manifest that declares /// a core-only capability (e.g. `local-store`) fails as unknown here, - /// and the adapter linker withholds the same interfaces so the + /// and the provider linker withholds the same interfaces so the /// component cannot instantiate against them either. - pub fn adapter() -> Self { + pub fn provider() -> Self { Self { - namespaces: vec![ADAPTER_NAMESPACE], + namespaces: vec![PROVIDER_NAMESPACE], } } @@ -179,11 +166,11 @@ impl CapabilityRegistry { /// manifest declaration. /// /// Examples: - /// - `"nexum:host/chain@0.2.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.2.0"` -> `Some("cow-api")` once the cow + /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` + /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that /// namespace is registered /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` - /// - `"nexum:host/types@0.2.0"` -> `None` (type-only, not a capability) + /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) /// - `"wasi:io/streams@0.2.0"` -> `None` pub fn wit_import_to_cap<'a>(&self, import_name: &'a str) -> Option<&'a str> { let without_version = import_name.split('@').next().unwrap_or(import_name); @@ -269,13 +256,13 @@ mod tests { use super::*; use crate::manifest::types::{CapabilitiesSection, Manifest}; - /// A registry with the cow extension namespace registered, mirroring - /// what the composition root assembles. - fn registry_with_cow() -> CapabilityRegistry { + /// A registry with one extension namespace registered, mirroring + /// what a composition root assembles. + fn registry_with_ext() -> CapabilityRegistry { let mut r = CapabilityRegistry::core(); r.register(NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], + prefix: "test:acme/", + ifaces: &["acme-api"], }); r } @@ -283,9 +270,9 @@ mod tests { #[test] fn wit_import_to_cap_nexum_host() { let r = CapabilityRegistry::core(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( - r.wit_import_to_cap("nexum:host/local-store@0.2.0"), + r.wit_import_to_cap("nexum:host/local-store@0.1.0"), Some("local-store") ); } @@ -314,30 +301,21 @@ mod tests { } #[test] - fn wit_import_to_cap_shepherd_cow_needs_registration() { - // Core registry does not recognise the cow namespace. + fn wit_import_to_cap_extension_needs_registration() { + // Core registry does not recognise an extension namespace. let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), None); + assert_eq!(core.wit_import_to_cap("test:acme/acme-api@0.1.0"), None); // Once registered, it resolves. - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!( - r.wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), - Some("cow-api") + r.wit_import_to_cap("test:acme/acme-api@0.1.0"), + Some("acme-api") ); } - #[test] - fn intent_pool_is_a_core_capability_but_intent_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); - } - #[test] fn wit_import_to_cap_non_http_wasi_is_none() { - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!(r.wit_import_to_cap("wasi:io/streams@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); @@ -370,21 +348,21 @@ mod tests { fn enforce_passes_when_caps_absent() { // 0.1-fallback: no capabilities section -> all imports allowed let loaded = manifest_no_caps(); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let r = registry_with_cow(); + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let loaded = manifest_with_caps(&["chain", "acme-api"], &["http"]); let imports = [ - "nexum:host/chain@0.2.0", - "shepherd:cow/cow-api@0.2.0", + "nexum:host/chain@0.1.0", + "test:acme/acme-api@0.1.0", "wasi:http/outgoing-handler@0.2.12", "wasi:io/streams@0.2.0", // non-http wasi is always skipped ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -392,10 +370,10 @@ mod tests { fn enforce_rejects_wasi_http_import_without_declaration() { let loaded = manifest_with_caps(&["chain"], &[]); let imports = [ - "nexum:host/chain@0.2.0", + "nexum:host/chain@0.1.0", "wasi:http/outgoing-handler@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -413,7 +391,7 @@ mod tests { "wasi:http/outgoing-handler@0.2.12", "wasi:http/types@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } } @@ -422,8 +400,8 @@ mod tests { fn enforce_rejects_undeclared_import() { let loaded = manifest_with_caps(&["chain"], &[]); // module imports remote-store but didn't declare it - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let r = registry_with_cow(); + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -434,17 +412,17 @@ mod tests { #[test] fn enforce_optional_caps_are_also_allowed() { let loaded = manifest_with_caps(&["chain"], &["remote-store"]); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let r = registry_with_cow(); + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] - fn adapter_registry_knows_only_scoped_transport() { + fn provider_registry_knows_only_scoped_transport() { // The scoped transport plus http are known; the core-only - // interfaces an adapter must not reach are not, so a manifest + // interfaces a provider must not reach are not, so a manifest // declaring them fails validation as unknown. - let r = CapabilityRegistry::adapter(); + let r = CapabilityRegistry::provider(); assert!(r.is_known("chain")); assert!(r.is_known("messaging")); assert!(r.is_known("http")); @@ -455,26 +433,26 @@ mod tests { } #[test] - fn adapter_registry_maps_transport_imports_but_not_core_only() { - let r = CapabilityRegistry::adapter(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + fn provider_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::provider(); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( - r.wit_import_to_cap("nexum:host/messaging@0.2.0"), + r.wit_import_to_cap("nexum:host/messaging@0.1.0"), Some("messaging") ); assert_eq!( r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), Some("http") ); - // A core-only interface is not a recognised adapter capability. - assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.2.0"), None); + // A core-only interface is not a recognised provider capability. + assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); } #[test] - fn adapter_manifest_declaring_a_core_only_cap_is_unknown() { + fn provider_manifest_declaring_a_core_only_cap_is_unknown() { // The load path validates declared names against the registry; an - // adapter declaring `local-store` must surface as unknown. - let r = CapabilityRegistry::adapter(); + // provider declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::provider(); assert!(!r.is_known("local-store")); assert!(r.known_names().split(", ").all(|n| n != "local-store")); } @@ -495,14 +473,14 @@ mod tests { "wasi:cli/terminal-stdout@0.2.6", "wasi:cli/environment@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn undeclared_gated_wasi_is_refused() { let loaded = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); for (import, cap) in [ ("wasi:sockets/tcp@0.2.6", "wasi-sockets"), ("wasi:filesystem/types@0.2.6", "wasi-filesystem"), @@ -525,14 +503,14 @@ mod tests { "wasi:filesystem/types@0.2.6", "wasi:filesystem/preopens@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn declaring_one_gated_cap_does_not_grant_another() { let loaded = manifest_with_caps(&["wasi-filesystem"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["wasi:filesystem/types@0.2.6"].into_iter(), &r).is_ok() ); @@ -544,7 +522,7 @@ mod tests { // Even with an unrelated gated cap declared, an unrecognised wasi: // namespace is denied outright. let loaded = manifest_with_caps(&["wasi-sockets"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(); assert!(matches!(err, CapabilityError::UnknownWasi { .. })); @@ -554,7 +532,7 @@ mod tests { fn wasi_gate_ignores_version_suffix() { let declared = manifest_with_caps(&["wasi-sockets"], &[]); let none = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&declared, ["wasi:sockets/tcp"].into_iter(), &r).is_ok()); assert!( enforce_capabilities(&declared, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_ok() @@ -567,9 +545,9 @@ mod tests { // No [capabilities] section -> 0.1-fallback: registry imports pass, // but the WASI surface is still gated fail-closed. let loaded = manifest_no_caps(); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( - enforce_capabilities(&loaded, ["nexum:host/remote-store@0.2.0"].into_iter(), &r) + enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) .is_ok() ); assert!(enforce_capabilities(&loaded, ["wasi:io/streams@0.2.6"].into_iter(), &r).is_ok()); @@ -582,7 +560,7 @@ mod tests { #[test] fn wasi_capability_names_are_known() { - let r = registry_with_cow(); + let r = registry_with_ext(); for cap in ["wasi-sockets", "wasi-filesystem"] { assert!(r.is_known(cap), "{cap} missing from known set"); assert!(r.known_names().split(", ").any(|n| n == cap)); diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index 73e1ac4f..ed5858c7 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -44,13 +44,14 @@ pub struct CapabilityViolation { /// Capability name (e.g. `"remote-store"`). pub capability: String, /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.2.0"`). + /// `"nexum:host/remote-store@0.1.0"`). pub wit_import: String, } /// Error returned when a component's WIT imports exceed its declared /// capabilities. #[derive(Debug, Error)] +#[non_exhaustive] pub enum CapabilityError { /// A gated import was not declared in `[capabilities]`. #[error(transparent)] diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 8abb74ea..a7a70e98 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -172,54 +172,103 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } #[test] - fn load_rejects_the_retired_log_kind() { + fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` - // fails to parse with an unknown-variant error naming the valid - // set so a not-yet-migrated manifest surfaces clearly at load. + // parses as an extension kind and boot refuses it against the + // extension vocabulary, so a not-yet-migrated manifest still + // surfaces clearly rather than silently dropping events. let toml = r#" [module] name = "stale" [[subscription]] kind = "log" -chain_id = 1 +chain_id = "1" "#; - let err = toml::from_str::(toml).expect_err("stale kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("chain-log"), - "error names the valid set: {msg}" - ); - assert!( - !msg.contains("unknown field"), - "kind is the discriminator: {msg}" - ); + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Extension { kind, .. } if kind == "log" + )); } #[test] - fn load_parses_intent_status_subscription() { + fn load_parses_extension_subscriptions_with_string_filters() { let toml = r#" [module] name = "watcher" [[subscription]] -kind = "intent-status" +kind = "acme-status" [[subscription]] -kind = "intent-status" -venue = "cow" +kind = "acme-status" +scope = "primary" "#; let manifest: Manifest = toml::from_str(toml).expect("parse"); assert!(matches!( &manifest.subscriptions[0], - Subscription::IntentStatus { venue: None } + Subscription::Extension { kind, filters } if kind == "acme-status" && filters.is_empty() )); assert!(matches!( &manifest.subscriptions[1], - Subscription::IntentStatus { venue: Some(v) } if v == "cow" + Subscription::Extension { kind, filters } + if kind == "acme-status" && filters.get("scope").is_some_and(|v| v == "primary") )); } + /// A non-string filter value on an extension kind is refused at parse. + #[test] + fn load_rejects_a_non_string_extension_filter() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "acme-status" +scope = 7 +"#; + let err = toml::from_str::(toml).expect_err("non-string filter"); + assert!(err.to_string().contains("must be a string"), "{err}"); + } + + /// A non-core top-level section parses into the opaque extension + /// map: the runtime carries it verbatim and ascribes it no meaning. + #[test] + fn load_parses_extension_sections_opaquely() { + let toml = r#" +[module] +name = "keeper" + +[venue] +body_version = 2 + +[[subscription]] +kind = "block" +chain_id = 1 +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "keeper"); + assert_eq!(manifest.subscriptions.len(), 1); + assert_eq!(manifest.extensions.len(), 1); + let venue = manifest.extensions.get("venue").expect("venue section"); + assert_eq!( + venue.get("body_version").and_then(toml::Value::as_integer), + Some(2), + ); + } + + /// A manifest without extension sections carries an empty map. + #[test] + fn load_defaults_to_no_extension_sections() { + let toml = r#" +[module] +name = "plain" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(manifest.extensions.is_empty()); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" @@ -295,8 +344,8 @@ enabled = true } #[test] - fn module_kind_defaults_to_event_module() { - use crate::manifest::types::ModuleKind; + fn component_kind_defaults_to_the_worker() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -304,37 +353,42 @@ name = "plain" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::EventModule); + assert_eq!(manifest.module.kind, ComponentKind::Worker); } #[test] - fn module_kind_parses_venue_adapter() { - use crate::manifest::types::ModuleKind; + fn component_kind_carries_a_provider_spelling() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] -name = "cow" -kind = "venue-adapter" +name = "acme" +kind = "acme-provider" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("acme-provider".to_owned()), + ); } + /// An unknown spelling parses as a provider kind; boot refuses it + /// against the registered kinds, where the valid set is known. #[test] - fn module_kind_rejects_unknown_variant() { - let err = toml::from_str::( + fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( r#" [module] name = "bad" kind = "gadget" "#, ) - .expect_err("unknown kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("venue-adapter") || msg.contains("event-module"), - "error names the valid kinds: {msg}", + .expect("parse"); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("gadget".to_owned()), ); } @@ -390,9 +444,9 @@ max_state_bytes = 52428800 #[test] fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.cow.fi", &allow)); - assert!(!host_allowed("evil.api.cow.fi", &allow)); + let allow = vec!["api.acme.example".to_string(), "*.discord.com".to_string()]; + assert!(host_allowed("api.acme.example", &allow)); + assert!(!host_allowed("evil.api.acme.example", &allow)); assert!(host_allowed("foo.discord.com", &allow)); assert!(host_allowed("a.b.discord.com", &allow)); assert!(!host_allowed("discord.com", &allow)); @@ -401,17 +455,20 @@ max_state_bytes = 52428800 #[test] fn host_allowed_is_case_insensitive_both_ways() { - let upper = vec!["API.COW.FI".to_string()]; - let lower = vec!["api.cow.fi".to_string()]; - assert!(host_allowed("api.cow.fi", &upper)); - assert!(host_allowed("Api.Cow.Fi", &lower)); + let upper = vec!["API.ACME.EXAMPLE".to_string()]; + let lower = vec!["api.acme.example".to_string()]; + assert!(host_allowed("api.acme.example", &upper)); + assert!(host_allowed("Api.Acme.Example", &lower)); } #[test] fn host_allowed_matches_hosts_not_authorities() { // Entries are bare hosts; a port or userinfo in a pattern can // never match a host string. - let allow = vec!["api.cow.fi:8443".to_string(), "u@api.cow.fi".to_string()]; - assert!(!host_allowed("api.cow.fi", &allow)); + let allow = vec![ + "api.acme.example:8443".to_string(), + "u@api.acme.example".to_string(), + ]; + assert!(!host_allowed("api.acme.example", &allow)); } } diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index fef64e1e..da7e0c06 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +35,8 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub(crate) use types::{LoadedManifest, ModuleKind, ResourceSection, Subscription}; +pub use types::ExtensionSections; +pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 78c62fb4..1960dc61 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,23 +4,20 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::collections::BTreeMap; +use std::fmt; + use serde::Deserialize; +use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker. The `http` capability is not a +/// world links into every module linker, emitted from the +/// `nexum-world` capability table. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities (e.g. -/// cow-api) are not listed here; each extension contributes its own -/// namespace to the [`super::capabilities::CapabilityRegistry`] at the -/// composition root. -pub const CORE_CAPABILITIES: &[&str] = &[ - "chain", - "identity", - "local-store", - "remote-store", - "messaging", - "logging", -]; +/// separately by the registry. Domain-extension capabilities are not +/// listed here; each extension contributes its own namespace to the +/// [`super::capabilities::CapabilityRegistry`] at the composition root. +pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; #[derive(Debug, Deserialize, Default)] pub struct Manifest { @@ -36,15 +33,26 @@ pub struct Manifest { /// parsed and ignored (deferred to 0.3). #[serde(default, rename = "subscription")] pub subscriptions: Vec, + /// Extension-owned sections: every non-core top-level key, parsed + /// opaquely. The runtime ascribes them no meaning; it routes them + /// to the wired extensions' install predicates, and a section no + /// extension claims is refused at boot. + #[serde(flatten)] + pub extensions: ExtensionSections, } +/// Extension-owned manifest sections, keyed by top-level name. Opaque +/// to the runtime; each claiming extension parses its own. +pub type ExtensionSections = BTreeMap; + /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. Unknown kinds are surfaced -/// at load time so a typo does not silently disable an event source. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "kind", rename_all = "lowercase")] +/// validated per-kind by the supervisor. A kind outside the core set +/// parses as [`Subscription::Extension`] and is validated at boot +/// against the kinds the wired extensions declare, so a typo still +/// fails loudly rather than silently disabling an event source. +#[derive(Debug, Clone)] pub enum Subscription { /// New-block events. Fan-out is shared per chain - the /// supervisor opens one subscription per chain id and routes to @@ -57,17 +65,14 @@ pub enum Subscription { /// per-module - the supervisor opens one subscription per /// `[[subscription]]` entry and tags emitted events with the /// owning module. - #[serde(rename = "chain-log")] ChainLog { /// EVM chain id. chain_id: u64, /// Contract address as `0x`-prefixed 20-byte hex. Optional. - #[serde(default)] address: Option, /// Topic-0 of the event the module wants to consume. `0x`- /// prefixed 32-byte hex. Optional - when absent the /// subscription matches every event from the address(es). - #[serde(default)] event_signature: Option, /// Resume across engine restarts. When `true` the host persists a /// durable per-subscription cursor and re-opens the log poller @@ -75,13 +80,11 @@ pub enum Subscription { /// current head. Delivery is then at-least-once, so the module must /// tolerate redelivery (the keeper idempotency journal already /// dedups it). - #[serde(default)] resume: bool, /// Optional cap on how far back a `resume` subscription will /// backfill, in blocks. `None` (the default) backfills the entire /// gap with no loss; set it only for a consumer that explicitly /// tolerates dropping the oldest missed blocks. - #[serde(default)] max_lookback: Option, }, /// Cron-scheduled tick. 0.2 parses but does not dispatch; the @@ -93,19 +96,96 @@ pub enum Subscription { #[allow(dead_code)] schedule: String, }, - /// Router-polled intent status transitions, delivered as - /// `intent-status` events. Fan-out is shared: the router polls each - /// installed adapter once per cadence and every subscribed module - /// receives the transition, filtered by `venue` when set. - #[serde(rename = "intent-status")] - IntentStatus { - /// Restrict delivery to transitions from this venue id. - /// Absent means transitions from every venue. + /// An extension-owned event kind. Every non-`kind` key is a string + /// filter matched against the event's routing attributes: an event + /// is delivered when its kind matches and every filter pair is + /// present in the event's attributes. + Extension { + /// The extension-declared subscription kind. + kind: String, + /// Attribute filters; empty admits every event of the kind. + filters: BTreeMap, + }, +} + +/// The core subscription kinds, parsed by shape. Any other kind falls +/// through to [`Subscription::Extension`]. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum CoreSubscription { + Block { + chain_id: u64, + }, + #[serde(rename = "chain-log")] + ChainLog { + chain_id: u64, #[serde(default)] - venue: Option, + address: Option, + #[serde(default)] + event_signature: Option, + #[serde(default)] + resume: bool, + #[serde(default)] + max_lookback: Option, + }, + Cron { + schedule: String, }, } +impl From for Subscription { + fn from(sub: CoreSubscription) -> Self { + match sub { + CoreSubscription::Block { chain_id } => Self::Block { chain_id }, + CoreSubscription::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + } => Self::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + }, + CoreSubscription::Cron { schedule } => Self::Cron { schedule }, + } + } +} + +impl<'de> Deserialize<'de> for Subscription { + fn deserialize>(deserializer: D) -> Result { + let table = toml::Table::deserialize(deserializer)?; + let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { + return Err(D::Error::missing_field("kind")); + }; + match kind { + "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) + .try_into::() + .map(Into::into) + .map_err(D::Error::custom), + _ => { + let kind = kind.to_owned(); + let mut filters = BTreeMap::new(); + for (key, value) in table { + if key == "kind" { + continue; + } + let Some(value) = value.as_str() else { + return Err(D::Error::custom(format!( + "subscription filter `{key}` must be a string" + ))); + }; + filters.insert(key, value.to_owned()); + } + Ok(Self::Extension { kind, filters }) + } + } + } +} + #[derive(Debug, Deserialize, Default)] #[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. pub struct ModuleSection { @@ -115,31 +195,54 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, - /// Which component kind this manifest describes. Defaults to - /// `event-module` so every existing `module.toml` keeps its meaning; - /// a venue adapter sets `kind = "venue-adapter"`. The supervisor picks - /// the bindgen and the scoped capability set from this discriminator. + /// Which component kind this manifest describes. Defaults to the + /// worker kind (`event-module`) so every existing `module.toml` keeps + /// its meaning; a provider names its registered kind. The supervisor + /// resolves the boot path from this discriminator. #[serde(default)] - pub kind: ModuleKind, + pub kind: ComponentKind, /// Per-module resource overrides; each unset field inherits the engine /// `[limits]` default. #[serde(default)] pub resources: ResourceSection, } -/// The component kind a manifest declares. The runtime carries two: the -/// original event-module over the six core primitives, and the venue -/// adapter over scoped transport only. Defaulting to `event-module` -/// preserves the meaning of every manifest written before adapters -/// existed. -#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum ModuleKind { - /// Event-driven automation over the six core primitives. +/// The worker kind's manifest spelling. +pub const WORKER_KIND: &str = "event-module"; + +/// The component kind a manifest declares: the core worker kind, or the +/// manifest spelling of a provider kind an extension registers. Defaults +/// to the worker so every manifest written before providers existed keeps +/// its meaning; an unregistered provider spelling is refused at boot, +/// where the registered kinds are known. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(from = "String")] +pub enum ComponentKind { + /// Event-driven worker over the six core primitives (`event-module`). #[default] - EventModule, - /// A single-venue adapter over scoped chain, messaging, and HTTP. - VenueAdapter, + Worker, + /// A provider the host holds behind a serialised actor, named by its + /// manifest spelling. + Provider(String), +} + +impl From for ComponentKind { + fn from(kind: String) -> Self { + if kind == WORKER_KIND { + Self::Worker + } else { + Self::Provider(kind) + } + } +} + +impl fmt::Display for ComponentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Worker => f.write_str(WORKER_KIND), + Self::Provider(kind) => f.write_str(kind), + } + } } /// `[module.resources]` overrides layered over the engine `[limits]` /// defaults. Every field is optional; an unset field keeps the default. diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 05556ee3..56fe1629 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -1,24 +1,38 @@ -//! Runtime presets: a preset names a lattice, its component builders, and its -//! add-on set as one bundle, so an embedder launches with +//! Runtime presets: a preset names a lattice, its component builders, its +//! extensions, and its add-on set as one bundle, so an embedder launches with //! `RuntimeBuilder::new(cfg).runtime::().launch()` instead of naming -//! each seam. [`CoreRuntime`] is the domain-free default: the reference core -//! backends (a chain provider pool and a local redb store, no extension -//! payload) with the Prometheus add-on. A domain assembly ships its own -//! preset naming its extension builder in the `Ext` slot. +//! each seam. A preset carrying pre-built backends or non-static extensions +//! binds by value through +//! [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime). +//! [`CoreRuntime`] is the domain-free default: the reference core backends +//! (a chain provider pool and a local redb store, no extension payload) with +//! the Prometheus add-on. A domain assembly ships its own preset naming its +//! extension builder in the `Ext` slot and returning its linker extensions. + +use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; +use crate::engine_config::EngineConfig; use crate::host::component::{ - ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, + ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, + ProviderPoolBuilder, RuntimeTypes, }; +use crate::host::extension::Extension; use crate::host::local_store_redb::LocalStore; +use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; -/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component -/// builders and add-ons the launcher needs, gathered behind one name. -/// Implemented by zero-sized markers; +/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the +/// component builders, extensions, and add-ons the launcher needs, gathered +/// behind one name. /// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// one and launches it. -pub trait Runtime { +/// a `Default` marker; +/// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) +/// binds a value, so a preset can hand back already-built backends through a +/// pass-through builder such as `Prebuilt`. +/// +/// Sealed: a preset opts in by also implementing the sealing marker. +pub trait Runtime: crate::sealed::SealedRuntime { /// The lattice the preset assembles. type Types: RuntimeTypes; /// Builds the chain backend ([`RuntimeTypes::Chain`]). @@ -27,12 +41,32 @@ pub trait Runtime { type StoreBuilder: ComponentBuilder::Store>; /// Builds the extension payload ([`RuntimeTypes::Ext`]). type ExtBuilder: ComponentBuilder::Ext>; + /// Builds the shared [`LogPipeline`]. + type LogsBuilder: ComponentBuilder; - /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder; + /// The component builders that open the backends at launch. Consumes the + /// preset, so a value-bound preset hands over owned, pre-built backends. + fn components( + self, + ) -> ComponentsBuilder< + Self::ChainBuilder, + Self::StoreBuilder, + Self::ExtBuilder, + Self::LogsBuilder, + >; /// The cross-cutting add-ons installed before the engine boots. - fn add_ons() -> AddOns; + fn add_ons(&self) -> AddOns; + + /// The extensions the preset launches with, derived from the loaded + /// config so an extension can carry config-resolved policy. None by + /// default; + /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) + /// appends on top. + fn extensions(&self, config: &EngineConfig) -> Vec>> { + let _ = config; + Vec::new() + } } /// The domain-free default preset: the reference core backends (a chain @@ -41,6 +75,9 @@ pub trait Runtime { #[derive(Debug, Clone, Copy, Default)] pub struct CoreRuntime; +impl crate::sealed::SealedRuntimeTypes for CoreRuntime {} +impl crate::sealed::SealedRuntime for CoreRuntime {} + impl RuntimeTypes for CoreRuntime { type Chain = ProviderPool; type Store = LocalStore; @@ -52,12 +89,13 @@ impl Runtime for CoreRuntime { type ChainBuilder = ProviderPoolBuilder; type StoreBuilder = LocalStoreBuilder; type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; - fn components() -> ComponentsBuilder { + fn components(self) -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } - fn add_ons() -> AddOns { + fn add_ons(&self) -> AddOns { vec![Box::new(PrometheusAddOn)] } } diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index dd6fa885..3b848306 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,7 +40,7 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; -use crate::host::pool_router::PoolRouter; +use crate::host::extension::{ExtensionEvent, ExtensionEventStream}; use crate::host::provider_pool::ProviderError; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; @@ -50,6 +50,7 @@ use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; /// supervisor consumes. Library-side code keeps `anyhow::Error` out /// of long-lived stream item types per the rust idiomatic rubric. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StreamError { /// Underlying provider / transport failure while opening or /// pumping the subscription. @@ -147,40 +148,6 @@ where streams } -/// Router-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 -/// 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, - 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)))); - 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, - cadence: Duration, - tx: mpsc::Sender, -) -> TaskExit { - loop { - tokio::time::sleep(cadence).await; - for update in router.poll_status_transitions().await { - if tx.send(update).await.is_err() { - // Receiver dropped -> engine shutting down. - return TaskExit::ReceiverGone; - } - } - } -} - /// Wrap an `mpsc::Receiver` as a `Stream` using /// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just /// for `ReceiverStream`. @@ -492,12 +459,6 @@ pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; -/// Router-observed intent status transitions, fanned to subscribers by the -/// event loop. Infallible items: poll failures are retried inside the poll -/// task on the next cadence rather than surfaced here. -pub type IntentStatusStream = - std::pin::Pin + Send>>; - /// Drive the supervisor with events until `shutdown` resolves. /// /// Graceful shutdown: the dispatch path is structured so @@ -516,7 +477,7 @@ pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, - intent_status_stream: Option, + extension_streams: Vec, tasks: TaskSet, shutdown: impl std::future::Future + Send, ) -> (u64, u64) { @@ -538,14 +499,15 @@ pub async fn run( } else { select_all(chain_log_streams).boxed() }; - let mut intent_statuses: BoxStream<'_, _> = match intent_status_stream { - Some(stream) => stream, - None => futures::stream::pending().boxed(), + let mut extension_events: BoxStream<'_, _> = if extension_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(extension_streams).boxed() }; let mut shutdown = Box::pin(shutdown); let mut dispatched_blocks: u64 = 0; let mut dispatched_chain_logs: u64 = 0; - let mut dispatched_intent_statuses: u64 = 0; + let mut dispatched_extension_events: u64 = 0; let started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -562,7 +524,7 @@ pub async fn run( Box, Option>, ), - IntentStatus(nexum::host::types::IntentStatusUpdate), + Extension(ExtensionEvent), // Carries the drain guard `shutdown` yielded. Shutdown(G), StreamPanic(&'static str), @@ -593,10 +555,10 @@ pub async fn run( } None => NextEvent::StreamPanic("chain-log"), }, - next = intent_statuses.next() => match next { - Some(update) => NextEvent::IntentStatus(update), - // The poll task loops forever; `None` means it exited. - None => NextEvent::StreamPanic("intent-status"), + next = extension_events.next() => match next { + Some(event) => NextEvent::Extension(event), + // Extension source tasks loop forever; `None` means one exited. + None => NextEvent::StreamPanic("extension-event"), }, }; @@ -611,9 +573,9 @@ pub async fn run( .await; dispatched_chain_logs += 1; } - NextEvent::IntentStatus(update) => { - supervisor.dispatch_intent_status(update).await; - dispatched_intent_statuses += 1; + NextEvent::Extension(event) => { + supervisor.dispatch_extension_event(event).await; + dispatched_extension_events += 1; } NextEvent::Shutdown(guard) => { // Drop the stream-end receivers so the reconnect @@ -622,12 +584,12 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, - dispatched_intent_statuses, + dispatched_extension_events, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); @@ -640,7 +602,7 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; warn!( kind, diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index 70998a6e..bfc1a427 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -31,7 +31,7 @@ use std::time::Duration; /// Aggressive enough to catch a deterministically broken module /// without waiting out the full exponential backoff (the 5th trap /// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real cow-api submit does +/// enough that a one-off RPC blip during a real extension submit does /// not get a module quarantined. pub const POISON_MAX_FAILURES: u32 = 5; pub const POISON_WINDOW: Duration = Duration::from_secs(600); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 78390678..fd369eeb 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,6 +18,11 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! +//! Providers ride the same sweeps: a trap inside a routed call flips +//! the [`Liveness`] their actor shares with the supervisor, the owning +//! service reports the instance unavailable while dead, and the sweep +//! reinstalls the provider after the same backoff and poison policies. +//! //! Multi-chain isolation: `dispatch_block(block)` walks //! every module but only enters those whose subscriptions match //! `block.chain_id`. Per-module restart / poison / fuel limits are @@ -26,6 +31,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -38,22 +44,26 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +use crate::bindings::{Config, EventModule, nexum}; use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; +use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::Extension; +use crate::host::extension::ExtensionEvent; +use crate::host::extension::{ + Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, + ProviderManifest, +}; 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::manifest::{ - self, CapabilityRegistry, LoadedManifest, ModuleKind, ResourceSection, Subscription, + self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; /// Owns every loaded module and exposes the dispatch surface the @@ -61,17 +71,12 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// The intent pool router: 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 - /// adapters into the restart and poison sweeps is still a later change. - pool_router: PoolRouter, - /// Venue adapters loaded at boot, whether or not `init` succeeded. - adapters_total: usize, - /// Adapters whose `init` succeeded and that are installed for routing. - adapters_alive: usize, + /// Providers loaded at boot, whether or not `init` succeeded. Swept + /// for restart and poison alongside the modules. + providers: Vec, + /// Registered provider kinds paired with their services, kept for the + /// provider restart sweep to reinstall through. + kinds: ProviderKinds, /// Cached for module restart: re-instantiating a trapped module /// requires a fresh wasmtime `Store` + `Linker`, which in turn need /// the shared backends. The `Components` bundle is cheaply cloned @@ -81,7 +86,10 @@ pub struct Supervisor { /// Extensions wired at boot. Cached so the module-restart path can /// rebuild an identical linker (core interfaces plus every extension /// hook) without re-consulting the composition root. - extensions: Vec>, + extensions: Vec>>, + /// Extension-owned host services, built once at boot from the same + /// extension set and carried by every store. + services: HostServices, /// Poison-pill thresholds resolved from `[limits.poison]` at boot /// (production defaults: 5 failures / 10 min). poison_policy: crate::runtime::poison_policy::PoisonPolicy, @@ -98,6 +106,9 @@ pub struct Supervisor { #[derive(Clone, Copy, Default)] pub(crate) struct TestTypes; +#[cfg(test)] +impl crate::sealed::SealedRuntimeTypes for TestTypes {} + #[cfg(test)] impl RuntimeTypes for TestTypes { type Chain = ProviderPool; @@ -253,19 +264,123 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// A venue adapter instantiated into a supervised store, ready to install in -/// the pool router. 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 -/// 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, - /// Whether `init` succeeded; a failed adapter is not installed for routing. +/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping; liveness is shared with the installed actor, which marks +/// it dead on a trap, and read back by the sweep. +struct LoadedProvider { + /// The provider's namespace: its manifest name, and the id its kind + /// installs it under. + name: String, + /// Registered kind spelling the restart sweep reinstalls through. + kind: &'static str, + /// Extension-owned manifest sections, as the worker install + /// predicates see them. + sections: manifest::ExtensionSections, + /// Cached for restart, like a module's. + component: Component, + /// Cached for restart: the manifest `[config]` handed to `init`. + init_config: Config, + /// Cached for restart: the operator's transport grants. + http_allow: Vec, + messaging_topics: Vec, + /// Cached for restart: the engine `[limits]` applied at boot. + http_limits: OutboundHttpLimits, + fuel_per_call: u64, + memory_limit: usize, + chain_response_max_bytes: usize, + local_store_bytes: u64, + /// Trap flag shared with the installed actor. + liveness: Liveness, + /// Sequence of the run currently installed; restarts increment it. + run_seq: u64, + /// The sweep's view of `liveness`: a `true` here against a dead + /// liveness is an unrecorded trap. Boot init failure leaves it `false` + /// with `next_attempt = None`, permanent like a module's. alive: bool, + failure_count: u32, + next_attempt: Option, + failure_timestamps: std::collections::VecDeque, + poisoned: bool, +} + +/// One registered provider kind paired with the service its installs bind to. +type ProviderRow = (Box>, Arc); + +/// Registered provider kinds, keyed by their manifest spelling. +type ProviderKinds = BTreeMap<&'static str, ProviderRow>; + +/// Collect each extension's provider kind paired with that extension's +/// service. Refuses a duplicate spelling and a provider whose extension +/// owns no service to install into. +fn provider_kinds( + extensions: &[Arc>], + services: &HostServices, +) -> Result> { + let mut kinds = ProviderKinds::new(); + for ext in extensions { + let Some(provider) = ext.provider() else { + continue; + }; + let service = services.raw(ext.namespace()).cloned().ok_or_else(|| { + anyhow!( + "extension {} registers provider kind {} without a host service", + ext.namespace(), + provider.kind(), + ) + })?; + register_kind(&mut kinds, provider, service)?; + } + Ok(kinds) +} + +/// The union of subscription kinds the wired extensions declare; a +/// manifest subscription of any other non-core kind fails the load. +fn extension_subscription_vocabulary( + extensions: &[Arc>], +) -> BTreeSet<&'static str> { + extensions + .iter() + .flat_map(|ext| ext.subscriptions().iter().copied()) + .collect() +} + +/// Refuse a manifest section no wired extension claims, so a typo'd +/// section fails loudly instead of silently skipping its extension's +/// install predicate. +fn enforce_extension_sections( + owner: &str, + sections: &manifest::ExtensionSections, + extensions: &[Arc>], +) -> Result<()> { + for key in sections.keys() { + let claimed = extensions + .iter() + .any(|ext| ext.manifest_sections().contains(&key.as_str())); + if !claimed { + return Err(anyhow!( + "{owner} declares manifest section [{key}]; no wired extension claims it" + )); + } + } + Ok(()) +} + +/// Insert one kind row, refusing a duplicate manifest spelling. +fn register_kind( + kinds: &mut ProviderKinds, + provider: Box>, + service: Arc, +) -> Result<()> { + let kind = provider.kind(); + if kinds.insert(kind, (provider, service)).is_some() { + return Err(anyhow!("provider kind {kind} is registered twice")); + } + Ok(()) +} + +/// Comma-joined registered provider kind spellings, for boot errors. +fn registered_kinds(kinds: &ProviderKinds) -> String { + kinds.keys().copied().collect::>().join(", ") } impl Supervisor { @@ -277,46 +392,45 @@ impl Supervisor { linker: &Linker>, engine_cfg: &EngineConfig, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], 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. - let adapter_linker = build_adapter_linker::(engine)?; - let adapter_registry = CapabilityRegistry::adapter(); - let mut router_builder = PoolRouterBuilder::new(engine_cfg.limits.quota()); - let adapters_total = engine_cfg.adapters.len(); - let mut adapters_alive = 0; + let services = HostServices::from_extensions(extensions)?; + // Provider kinds the boot loop resolves manifest kinds against. + let kinds = provider_kinds(extensions, &services)?; + // Providers boot first into their extension-owned services, so + // every module store built below already routes to the installed + // instances. Providers link only their kind's scoped imports. + let provider_registry = CapabilityRegistry::provider(); + let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); for entry in &engine_cfg.adapters { - let loaded = Self::load_adapter( + let loaded = Self::load_provider( engine, - &adapter_linker, entry, components, &engine_cfg.limits, - &adapter_registry, + &provider_registry, clocks.as_ref(), + &kinds, + extensions, ) .await - .with_context(|| format!("load adapter {}", entry.path.display()))?; - if loaded.alive { - adapters_alive += 1; - router_builder - .install(loaded.venue_id.clone(), loaded.actor) - .with_context(|| format!("install adapter {}", loaded.venue_id))?; - } else { - warn!( - adapter = %loaded.venue_id, - "adapter init failed - not installed for routing", - ); - } + .with_context(|| format!("load provider {}", entry.path.display()))?; + providers.push(loaded); } - let pool_router = router_builder.build(); + // The loaded providers' manifests, as the worker install + // predicates see them. + let provider_manifests: Vec = providers + .iter() + .map(|p| ProviderManifest { + name: p.name.clone(), + kind: p.kind, + sections: p.sections.clone(), + }) + .collect(); + let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { let loaded = Self::load_one( @@ -327,28 +441,32 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), - pool_router.clone(), + services.clone(), + &extension_kinds, + extensions, + &provider_manifests, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; modules.push(loaded); } let alive = modules.iter().filter(|m| m.alive).count(); + let adapters_alive = providers.iter().filter(|p| p.alive).count(); info!( loaded = modules.len(), alive, - adapters = adapters_total, + adapters = providers.len(), adapters_alive, "supervisor up" ); Ok(Self { modules, - pool_router, - adapters_total, - adapters_alive, + providers, + kinds, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: engine_cfg.limits.poison(), clocks, }) @@ -368,18 +486,18 @@ impl Supervisor { manifest: Option<&Path>, components: &Components, limits: &ModuleLimits, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; let entry = ModuleEntry { path: wasm.to_path_buf(), 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(); + // The single-module override path serves `just run`; providers + // are configured through `engine.toml`, so none boot here. + let extension_kinds = extension_subscription_vocabulary(extensions); let loaded = Self::load_one( engine, linker, @@ -388,17 +506,20 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), - pool_router.clone(), + services.clone(), + &extension_kinds, + extensions, + &[], ) .await?; Ok(Self { modules: vec![loaded], - pool_router, - adapters_total: 0, - adapters_alive: 0, + providers: Vec::new(), + kinds: ProviderKinds::new(), engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: limits.poison(), clocks, }) @@ -423,7 +544,7 @@ impl Supervisor { chain_response_max_bytes: usize, state_quota: u64, clocks: Option<&WasiClockOverride>, - pool_router: PoolRouter, + services: HostServices, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -481,7 +602,7 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, - pool_router, + services, }, ); store.limiter(|state| &mut state.limits); @@ -489,8 +610,7 @@ impl Supervisor { Ok(store) } - // One flat argument per shared input threaded onto the store, plus the - // pool router the module's `nexum:intent/pool` import dispatches to. + // One flat argument per shared input threaded onto the store. #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, @@ -500,7 +620,10 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - pool_router: PoolRouter, + services: HostServices, + extension_kinds: &BTreeSet<&'static str>, + extensions: &[Arc>], + provider_manifests: &[ProviderManifest], ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -516,6 +639,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the worker against the loaded providers' manifests. + let sections = &loaded_manifest.manifest.extensions; + enforce_extension_sections(&module_namespace, sections, extensions)?; + for ext in extensions { + ext.admit_worker(&module_namespace, sections, provider_manifests) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // Compile + instantiate. info!(component = %entry.path.display(), "compiling component"); @@ -530,11 +668,6 @@ impl Supervisor { registry, ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "module".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; // Layer the manifest's `[module.resources]` over the engine `[limits]` // defaults: an unset override field keeps the engine default. let ResolvedLimits { @@ -557,15 +690,15 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), - // Event modules are unscoped for messaging; only venue - // adapters carry a topic grant. + // Event modules are unscoped for messaging; only providers + // carry a topic grant. Vec::new(), memory, fuel, limits_cfg.chain_response_max_bytes(), state_bytes, clocks, - pool_router, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -608,7 +741,7 @@ impl Supervisor { warn!( module = %module_namespace, kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + message = %crate::host::error::fault_message(&e), "init failed - module loaded but marked dead; dispatcher will skip it", ); false @@ -619,13 +752,23 @@ impl Supervisor { // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 - // manifest does not silently drop events. + // manifest does not silently drop events, and refuse an + // extension kind no wired extension declares. for sub in &loaded_manifest.manifest.subscriptions { - if matches!(sub, Subscription::Cron { .. }) { - warn!( + match sub { + Subscription::Cron { .. } => warn!( module = %module_namespace, "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", - ); + ), + Subscription::Extension { kind, .. } + if !extension_kinds.contains(kind.as_str()) => + { + return Err(anyhow!( + "module {module_namespace} subscribes to unknown event kind {kind}; \ + no wired extension declares it" + )); + } + _ => {} } } @@ -656,60 +799,93 @@ impl Supervisor { }) } - /// Load one `[[adapters]]` entry: resolve its manifest, verify it - /// declares the venue-adapter kind, enforce the scoped-transport - /// 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. - async fn load_adapter( + /// Load one `[[adapters]]` entry: resolve its manifest, resolve the + /// declared kind against the registered provider kinds, enforce the + /// scoped-transport capability set, build a supervised store carrying + /// the operator's HTTP and messaging grants, and hand the instance to + /// its kind to instantiate and install. A failed guest `init` loads the + /// provider dead and unroutable, permanently like a module's. + // One flat argument per shared input threaded onto the store, matching + // the module load path. + #[allow(clippy::too_many_arguments)] + async fn load_provider( engine: &Engine, - linker: &Linker>, entry: &AdapterEntry, components: &Components, limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - ) -> Result> { + kinds: &ProviderKinds, + extensions: &[Arc>], + ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading adapter manifest"); + info!(manifest = %p.display(), "loading provider manifest"); manifest::load(p, registry)? } _ => { warn!( component = %entry.path.display(), - "no module.toml - falling back to anonymous adapter" + "no module.toml - falling back to anonymous provider" ); manifest::fallback_manifest() } }; + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; - // The manifest kind is the discriminator: an [[adapters]] entry - // whose manifest is (or defaults to) an event-module is a config - // error, caught here before instantiation. A fallback manifest has - // the default event-module kind, so an adapter must ship a - // module.toml that declares the venue-adapter kind explicitly. - let kind = loaded_manifest.manifest.module.kind; - if kind != ModuleKind::VenueAdapter { - return Err(anyhow!( - "adapter {} declares module kind {kind:?}; an [[adapters]] entry requires \ - a module.toml with [module] kind = \"venue-adapter\"", - entry.path.display(), - )); + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the provider's own sections. + let sections = loaded_manifest.manifest.extensions.clone(); + enforce_extension_sections(&namespace, §ions, extensions)?; + for ext in extensions { + ext.admit_provider(&namespace, §ions) + .with_context(|| format!("install refused for {}", entry.path.display()))?; } - info!(component = %entry.path.display(), "compiling adapter component"); + // The manifest kind is the discriminator: an [[adapters]] entry + // must name a registered provider kind, caught here before + // instantiation. A fallback manifest has the default worker kind, + // so a provider must ship a module.toml that declares its kind + // explicitly. + let (kind, service) = match &loaded_manifest.manifest.module.kind { + ComponentKind::Worker => { + return Err(anyhow!( + "{} declares the worker kind; an [[adapters]] entry requires a \ + module.toml declaring a registered provider kind ({})", + entry.path.display(), + registered_kinds(kinds), + )); + } + ComponentKind::Provider(spelling) => kinds.get(spelling.as_str()).ok_or_else(|| { + anyhow!( + "{} declares unregistered provider kind {spelling}; registered \ + kinds: {}", + entry.path.display(), + registered_kinds(kinds), + ) + })?, + }; + + info!( + component = %entry.path.display(), + kind = kind.kind(), + "compiling provider component", + ); let component = Component::from_file(engine, &entry.path) .map_err(Error::from) .with_context(|| format!("compile {}", entry.path.display()))?; // Enforce the scoped-transport capability set: `registry` is the - // adapter registry, so a declaration of any core-only interface + // provider registry, so a declaration of any core-only interface // fails at manifest load, and an undeclared transport import fails - // here. The linker withholds the same core-only interfaces, so an - // adapter reaching for one also fails to instantiate below. + // here. The linker withholds the same core-only interfaces, so a + // provider reaching for one also fails to instantiate. manifest::enforce_capabilities( &loaded_manifest, component.component_type().imports(engine).map(|(n, _)| n), @@ -717,28 +893,25 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "adapter".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; info!( - adapter = %adapter_namespace, + provider = %namespace, + kind = kind.kind(), fuel = limits_cfg.fuel(), memory_bytes = limits_cfg.memory(), http_allow = entry.http_allow.len(), messaging_topics = entry.messaging_topics.len(), - "applied adapter resource limits and transport scope", + "applied provider resource limits and transport scope", ); - 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. - let mut store = Self::build_store( + let linker = build_provider_linker::(engine, kind.as_ref())?; + let run = RunId::new(namespace.clone(), 0); + // A provider links no service-consuming import, so its store carries + // an empty service map; the shared map holds the registry that owns + // the provider's store, and carrying it here would cycle. + let store = Self::build_store( engine, components, - run.clone(), + run, entry.http_allow.clone(), limits_cfg.http(), entry.messaging_topics.clone(), @@ -747,44 +920,53 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), limits_cfg.state_bytes(), clocks, - PoolRouter::empty(), + HostServices::default(), )?; - let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) - .await - .map_err(Error::from) - .with_context(|| format!("instantiate {}", entry.path.display()))?; let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), adapter_namespace.clone())] + vec![("name".into(), namespace.clone())] } else { loaded_manifest.config.clone() }; - let init_succeeded = match bindings - .call_init(&mut store, &config) + let liveness = Liveness::default(); + let installed = kind + .install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config: config.clone(), + sections: §ions, + fuel_per_call: limits_cfg.fuel(), + liveness: liveness.clone(), + }, + service, + ) .await - .map_err(Error::from)? - { - Ok(()) => { - info!(adapter = %adapter_namespace, "adapter init succeeded"); - true - } - Err(e) => { - warn!( - adapter = %adapter_namespace, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), - "adapter init failed - loaded but marked dead", - ); - false - } - }; - // Refuel after init so the first routed call starts with a full budget. - store.set_fuel(limits_cfg.fuel())?; - - Ok(LoadedAdapter { - venue_id: adapter_namespace, - actor: AdapterActor::new(store, bindings, limits_cfg.fuel()), - alive: init_succeeded, + .with_context(|| format!("install {}", entry.path.display()))?; + if installed == Installed::Dead { + liveness.mark_dead(); + } + Ok(LoadedProvider { + name: namespace, + kind: kind.kind(), + sections, + component, + init_config: config, + http_allow: entry.http_allow.clone(), + messaging_topics: entry.messaging_topics.clone(), + http_limits: limits_cfg.http(), + fuel_per_call: limits_cfg.fuel(), + memory_limit: limits_cfg.memory(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), + local_store_bytes: limits_cfg.state_bytes(), + liveness, + run_seq: 0, + alive: installed == Installed::Live, + failure_count: 0, + next_attempt: None, + failure_timestamps: std::collections::VecDeque::new(), + poisoned: false, }) } @@ -793,16 +975,19 @@ impl Supervisor { self.modules.len() } - /// Number of venue adapters loaded at boot, alive or not. + /// Number of providers loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { - self.adapters_total + self.providers.len() } - /// Number of adapters whose `init` succeeded and that are installed in the - /// pool router for routing. + /// Number of adapters currently alive and routable. Live: a trap drops + /// it, the restart sweep raises it again. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { - self.adapters_alive + self.providers + .iter() + .filter(|p| p.liveness.is_alive()) + .count() } /// Chains any **alive** module asked for block events on. Dead modules @@ -914,10 +1099,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 services // as the initial boot. let clocks = self.clocks.clone(); - let pool_router = self.pool_router.clone(); + let services = self.services.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 +1119,7 @@ impl Supervisor { module.chain_response_max_bytes, module.local_store_bytes, clocks.as_ref(), - pool_router, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -993,6 +1178,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let mut dispatched = 0; let candidate_indices: Vec = (0..self.modules.len()) @@ -1056,6 +1242,7 @@ impl Supervisor { cursor_key: Option<&str>, ) -> bool { let now = std::time::Instant::now(); + self.sweep_providers().await; let Some(idx) = self.modules.iter().position(|m| m.name == module_name) else { warn!(module = %module_name, "no such module - dropping chain-log"); return false; @@ -1126,15 +1313,12 @@ impl Supervisor { ok } - /// Dispatch a router-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 one extension-observed event to every module holding a + /// subscription of its kind whose filters all match the event's + /// attributes. Returns the number of modules invoked. Mirrors /// `dispatch_block`: dead modules past their backoff are restarted /// first, poisoned modules are skipped. - pub async fn dispatch_intent_status( - &mut self, - update: nexum::host::types::IntentStatusUpdate, - ) -> usize { + pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { let now = std::time::Instant::now(); let restart_candidates: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1145,6 +1329,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let candidate_indices: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1155,19 +1340,20 @@ impl Supervisor { m.subscriptions.iter().any(|s| { matches!( s, - Subscription::IntentStatus { venue } - if venue.as_deref().is_none_or(|v| v == update.venue) + Subscription::Extension { kind, filters } + if kind == event.kind && filters.iter().all(|(fk, fv)| { + event.attrs.iter().any(|(ak, av)| ak == fk && av == fv) + }) ) }) }) .collect(); - let event = nexum::host::types::Event::IntentStatus(update); let mut dispatched = 0; for idx in candidate_indices { - // Status transitions are venue-scoped, not chain-scoped: the - // telemetry chain id and block number carry the 0 sentinel. + // Extension events are not chain-scoped: the telemetry chain + // id and block number carry the 0 sentinel. if matches!( - self.dispatch_to(idx, 0, "intent-status", 0, &event).await, + self.dispatch_to(idx, 0, event.kind, 0, &event.event).await, DispatchOutcome::Ok, ) { dispatched += 1; @@ -1176,20 +1362,25 @@ impl Supervisor { dispatched } - /// Whether any loaded module subscribes to `intent-status` events. - /// The launcher polls adapter statuses only when this holds: with no - /// subscriber every transition would be dropped on arrival. - pub fn has_intent_status_subscribers(&self) -> bool { - self.modules.iter().any(|m| { - m.subscriptions - .iter() - .any(|s| matches!(s, Subscription::IntentStatus { .. })) - }) + /// The extension subscription kinds at least one loaded module + /// declares. An extension opens an event source only when its kind + /// appears here: with no subscriber every event would be dropped on + /// arrival. + pub fn extension_subscription_kinds(&self) -> BTreeSet { + self.modules + .iter() + .flat_map(|m| m.subscriptions.iter()) + .filter_map(|s| match s { + Subscription::Extension { kind, .. } => Some(kind.clone()), + _ => None, + }) + .collect() } - /// The shared intent pool router carried by every module store. - pub fn pool_router(&self) -> PoolRouter { - self.pool_router.clone() + /// The extension-owned services, as booted. Shared by every module + /// store through the service map. + pub fn services(&self) -> &HostServices { + &self.services } /// Shared per-module dispatch path: refuel, call `on_event`, and @@ -1295,7 +1486,7 @@ impl Supervisor { block_number, latency_ms, kind, - message = crate::host::error::fault_message(&fault), + message = %crate::host::error::fault_message(&fault), "on-event returned fault", ); metrics::counter!( @@ -1384,6 +1575,134 @@ impl Supervisor { } } + /// Fold providers into the recovery path: record any trap the shared + /// liveness reports (backoff plus poison bookkeeping), then reinstall + /// dead, unpoisoned providers whose backoff has elapsed. Runs at the + /// head of every dispatch, beside the module restart sweep. + async fn sweep_providers(&mut self) { + let now = std::time::Instant::now(); + let policy = self.poison_policy; + for idx in 0..self.providers.len() { + let provider = &mut self.providers[idx]; + if provider.alive + && let Some(died_at) = provider.liveness.dead_since() + { + provider.alive = false; + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + // Backoff counts from the death, not from this sweep, so a + // trap whose backoff already elapsed restarts right below. + provider.next_attempt = Some(died_at.checked_add(backoff).unwrap_or(now)); + warn!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + "adapter trapped - marked dead; will restart after backoff", + ); + metrics::counter!( + "shepherd_adapter_errors_total", + "adapter" => provider.name.clone(), + "error_kind" => "trap", + ) + .increment(1); + if let Some(recent) = poison_crossed(&mut provider.failure_timestamps, policy) + && !provider.poisoned + { + provider.poisoned = true; + warn!( + adapter = %provider.name, + recent_failures = recent, + window_secs = policy.window.as_secs(), + "adapter poisoned - quarantined; remove from engine.toml + restart to clear", + ); + metrics::gauge!( + "shepherd_adapter_poisoned", + "adapter" => provider.name.clone(), + ) + .set(1.0); + } + } + let provider = &self.providers[idx]; + if !provider.poisoned + && !provider.alive + && provider.next_attempt.is_some_and(|t| t <= now) + { + self.try_restart_provider(idx).await; + } + } + } + + /// Attempt to reinstall a dead provider in place: fresh store, fresh + /// instance, `init`, and a re-install replacing the dead slot. On + /// success the shared liveness is revived; on failure the backoff + /// slides further out, like a module restart. + async fn try_restart_provider(&mut self, idx: usize) { + let name = self.providers[idx].name.clone(); + let failure_count = self.providers[idx].failure_count; + info!(adapter = %name, failure_count, "adapter restart attempt"); + metrics::counter!( + "shepherd_adapter_restarts_total", + "adapter" => name.clone(), + ) + .increment(1); + let outcome = self.reinstall_provider(idx).await; + let provider = &mut self.providers[idx]; + match outcome { + Ok(Installed::Live) => { + provider.run_seq += 1; + provider.liveness.mark_alive(); + provider.alive = true; + provider.failure_count = 0; + provider.next_attempt = None; + info!(adapter = %name, "adapter restart succeeded"); + } + Ok(Installed::Dead) => { + defer_provider_restart(provider, "init returned fault on restart"); + } + Err(e) => defer_provider_restart(provider, &format!("{e:#}")), + } + } + + /// Rebuild a provider from its cached component and grants, then hand + /// it back to its kind to instantiate and install over the dead slot. + async fn reinstall_provider(&mut self, idx: usize) -> Result { + let provider = &self.providers[idx]; + let (kind, service) = self + .kinds + .get(provider.kind) + .ok_or_else(|| anyhow!("provider kind {} is not registered", provider.kind))?; + let linker = build_provider_linker::(&self.engine, kind.as_ref())?; + // A restart is a new run, like a module's. + let run = RunId::new(provider.name.clone(), provider.run_seq + 1); + let store = Self::build_store( + &self.engine, + &self.components, + run, + provider.http_allow.clone(), + provider.http_limits, + provider.messaging_topics.clone(), + provider.memory_limit, + provider.fuel_per_call, + provider.chain_response_max_bytes, + provider.local_store_bytes, + self.clocks.as_ref(), + HostServices::default(), + )?; + kind.install( + ProviderInstance { + component: &provider.component, + linker: &linker, + store, + config: provider.init_config.clone(), + sections: &provider.sections, + fuel_per_call: provider.fuel_per_call, + liveness: provider.liveness.clone(), + }, + service, + ) + .await + } + /// Count of modules currently alive. A module is not alive when its /// `init` returned `Err` (permanent, never retried) or when `on_event` /// trapped and its restart backoff has not yet elapsed. @@ -1419,48 +1738,34 @@ impl Supervisor { /// and capability enforcement via the crate-internal `capability_registry`. pub fn build_linker( engine: &Engine, - extensions: &[Extension], + extensions: &[Arc>], ) -> 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>>( - &mut linker, - |state| state, - )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; // wasi:http only; the p2 call above already covers the shared // wasi:io/wasi:clocks interfaces. wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; for ext in extensions { - (ext.link)(&mut linker)?; + ext.link(&mut linker)?; } Ok(linker) } -/// Build a `Linker` for the `venue-adapter` world: only the scoped -/// transport an adapter may reach - `chain`, `messaging`, and the -/// allowlisted `wasi:http` - plus the ambient WASI base. The core -/// `nexum:host` interfaces an adapter must not touch (local-store, -/// remote-store, identity, logging) are deliberately withheld, so an -/// adapter that imports one of them fails to instantiate rather than -/// silently gaining reach. Extensions are not linked into adapters: an -/// adapter speaks its venue's protocol over the standard transport, not a -/// domain extension surface. -pub fn build_adapter_linker( +/// Build a `Linker` for one provider kind: the kind's own scoped imports +/// plus the ambient WASI base and the allowlisted `wasi:http`. The core +/// `nexum:host` interfaces a provider must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so a +/// provider that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into providers: a +/// provider speaks its protocol over the standard transport, not a domain +/// extension surface. +pub fn build_provider_linker( engine: &Engine, + kind: &dyn ProviderKind, ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); - nexum::host::chain::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; - nexum::host::messaging::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; + kind.link(&mut linker)?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; Ok(linker) @@ -1499,11 +1804,11 @@ fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option( - extensions: &[Extension], + extensions: &[Arc>], ) -> CapabilityRegistry { let mut registry = CapabilityRegistry::core(); for ext in extensions { - registry.register(ext.capabilities); + registry.register(ext.capabilities()); } registry } @@ -1564,28 +1869,38 @@ enum DispatchOutcome { RateLimited, } -/// Push the current trap timestamp into the module's -/// failure-window ring, drop entries older than the policy window, -/// and flip `poisoned = true` once the window holds more than -/// `policy.max_failures` traps. The first transition emits the -/// `shepherd_module_poisoned` gauge + a structured WARN. -fn record_failure_and_maybe_poison( - module: &mut LoadedModule, +/// Push the current trap timestamp into a component's failure-window +/// ring, drop entries older than the policy window, and report the +/// recent-failure count once it crosses `policy.max_failures`. Shared by +/// the module and provider poison sweeps. +fn poison_crossed( + failure_timestamps: &mut std::collections::VecDeque, policy: crate::runtime::poison_policy::PoisonPolicy, - last_error: &str, -) { +) -> Option { let now = std::time::Instant::now(); - // Prune entries outside the window. - while let Some(&front) = module.failure_timestamps.front() { + while let Some(&front) = failure_timestamps.front() { if now.duration_since(front) > policy.window { - module.failure_timestamps.pop_front(); + failure_timestamps.pop_front(); } else { break; } } - module.failure_timestamps.push_back(now); - let recent = module.failure_timestamps.len() as u32; - if crate::runtime::poison_policy::should_poison(policy, recent) && !module.poisoned { + failure_timestamps.push_back(now); + let recent = failure_timestamps.len() as u32; + crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) +} + +/// Flip `poisoned = true` once the module's failure window crosses the +/// policy threshold. The first transition emits the +/// `shepherd_module_poisoned` gauge + a structured WARN. +fn record_failure_and_maybe_poison( + module: &mut LoadedModule, + policy: crate::runtime::poison_policy::PoisonPolicy, + last_error: &str, +) { + if let Some(recent) = poison_crossed(&mut module.failure_timestamps, policy) + && !module.poisoned + { module.poisoned = true; warn!( module = %module.name, @@ -1602,6 +1917,20 @@ fn record_failure_and_maybe_poison( } } +/// Slide a failed provider restart's next attempt further out. +fn defer_provider_restart(provider: &mut LoadedProvider, error: &str) { + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + provider.next_attempt = Some(std::time::Instant::now() + backoff); + error!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + error, + "adapter restart failed - will retry after backoff", + ); +} + /// Persisted per-chain progress key; must stay numeric for data compat. fn progress_key(chain: Chain) -> String { format!("last_dispatched_block:{}", chain.id()) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index e2779f85..2617869f 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -28,6 +28,41 @@ fn manifest_resource_overrides_take_effect_and_are_field_local() { assert_eq!(resolved.state_bytes, 2048); } +/// A manifest section a wired extension claims passes; an unclaimed one +/// (a typo, or a section for an unwired extension) is refused. +#[test] +fn extension_sections_must_be_claimed() { + struct Claiming; + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + "acme" + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn manifest_sections(&self) -> &'static [&'static str] { + &["venue"] + } + } + let extensions: Vec>> = vec![Arc::new(Claiming)]; + + let mut sections = manifest::ExtensionSections::new(); + sections.insert("venue".into(), toml::Value::Boolean(true)); + enforce_extension_sections("keeper", §ions, &extensions).expect("claimed section"); + + sections.insert("venu".into(), toml::Value::Boolean(true)); + let err = enforce_extension_sections("keeper", §ions, &extensions) + .expect_err("unclaimed section"); + assert!(err.to_string().contains("[venu]"), "{err}"); + assert!(err.to_string().contains("keeper"), "{err}"); +} + #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); @@ -72,7 +107,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), - None, + Vec::new(), nexum_tasks::TaskSet::new(), shutdown, ) @@ -145,7 +180,7 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { &mut supervisor, block_streams, chain_log_streams, - None, + Vec::new(), tasks, shutdown, ), @@ -202,7 +237,7 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { &mut supervisor, block_streams, vec![], - None, + Vec::new(), tasks, shutdown, ), @@ -234,77 +269,6 @@ fn example_module_toml() -> PathBuf { .join("modules/example/module.toml") } -fn echo_venue_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-venue/module.toml") -} - -fn echo_client_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-client/module.toml") -} - -/// Path to the pre-built reference venue adapter. Built by -/// `just build-venue`; the import-pinning test skips when absent. -fn echo_venue_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_venue.wasm") -} - -/// Returns `None` and prints a skip message if the venue fixture isn't -/// built. -fn echo_venue_wasm_or_skip() -> Option { - let p = echo_venue_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-venue` to enable the venue import test", - p.display() - ); - None - } -} - -/// Path to the pre-built echo-client module, the strategy half of the echo -/// pair. Built by `just build-echo-client`; the round-trip test skips when -/// absent. -fn echo_client_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_client.wasm") -} - -/// Returns `None` and prints a skip message if the echo-client fixture -/// isn't built. -fn echo_client_wasm_or_skip() -> Option { - let p = echo_client_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-echo-client` to enable the echo round-trip test", - p.display() - ); - None - } -} - /// Returns `None` and prints a skip message if the fixture isn't built. fn example_wasm_or_skip() -> Option { let p = example_wasm(); @@ -328,7 +292,7 @@ fn make_wasmtime_engine() -> wasmtime::Engine { /// The core-only extension set: no domain extensions. Domain-extension /// boot coverage lives in the extension crate that owns the backend. -fn core_extensions() -> Vec> { +fn core_extensions() -> Vec>> { Vec::new() } @@ -433,54 +397,12 @@ fn e2e_example_component_imports_equal_declared_capabilities() { "imports were: {imports:?}" ); - // No extension interface leaks in either: the blanket cow world is - // gone from modules that never declared it. + // No extension interface leaks in either: the per-module world holds + // exactly what the manifest declared. assert!( imports .iter() - .all(|name| !name.starts_with("shepherd:cow/")), - "imports were: {imports:?}" - ); -} - -/// The per-component venue-adapter world contract: an adapter built -/// through `#[nexum_venue_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. -#[test] -fn e2e_echo_venue_component_imports_equal_declared_capabilities() { - let Some(wasm) = echo_venue_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - // Capability-bearing imports resolve to exactly the declared set. - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["chain"]), - "imports were: {imports:?}" - ); - - // No host key-material or persistence interface leaks in: an adapter - // structurally cannot reach messaging it never declared, local-store, - // identity, or logging. - assert!( - imports.iter().all(|name| !name.contains("messaging") - && !name.contains("local-store") - && !name.contains("identity") - && !name.contains("logging")), + .all(|name| name.starts_with("nexum:host/") || name.starts_with("wasi:")), "imports were: {imports:?}" ); } @@ -540,355 +462,6 @@ chain_id = 1 assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); } -// ── intent-status subscription E2E ──────────────────────────────────── - -/// A scripted venue adapter for the router: accepts every submission with -/// a fixed receipt and serves statuses front-first from a script, falling -/// back to `open` once drained. -struct ScriptedAdapter { - statuses: std::collections::VecDeque, -} - -impl ScriptedAdapter { - fn new(statuses: impl IntoIterator) -> Self { - Self { - statuses: statuses.into_iter().collect(), - } - } -} - -impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - 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, - }) - }) - } - - fn submit<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::SubmitOutcome::Accepted( - b"receipt".to_vec(), - )) - }) - } - - fn status( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture< - '_, - Result, - > { - Box::pin(async move { - Ok(self - .statuses - .pop_front() - .unwrap_or(crate::bindings::IntentStatus::Open)) - }) - } - - fn cancel( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture<'_, Result<(), crate::bindings::VenueError>> { - Box::pin(async move { Ok(()) }) - } -} - -/// 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(), - ); - builder.install("cow".to_owned(), adapter).expect("install"); - builder.build() -} - -/// Write a manifest subscribing the example module to intent-status -/// events from the `cow` venue. -fn intent_status_manifest(dir: &Path) -> PathBuf { - let manifest = dir.join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "intent-status" -venue = "cow" -"#, - ) - .unwrap(); - manifest -} - -/// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the router observed by polling the adapter's status -/// export, and a transition from a venue outside its filter is not -/// delivered. -#[tokio::test] -async fn e2e_intent_status_subscription_receives_polled_transitions() { - use crate::bindings::IntentStatus; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - assert!(supervisor.has_intent_status_subscribers()); - - // The router 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([ - IntentStatus::Pending, - IntentStatus::Settled(None), - ])); - router - .submit("test-caller", "cow", b"body".to_vec()) - .await - .expect("submit"); - - let mut delivered = 0; - for _ in 0..2 { - for update in router.poll_status_transitions().await { - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!(delivered, 2, "pending then settled, one subscriber each"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // A venue outside the module's filter is not delivered. - let foreign = crate::bindings::IntentStatusUpdate { - venue: "other".to_owned(), - receipt: b"receipt".to_vec(), - status: IntentStatus::Open, - }; - assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); -} - -/// The event-loop wiring: the poll task's stream drives the supervisor, -/// and the module's handler observably ran (its log line is retained). -#[tokio::test] -async fn e2e_intent_status_flows_through_the_event_loop() { - use std::time::Duration; - - use nexum_tasks::{TaskManager, TaskSet}; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let logs = components.logs.clone(); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let router = scripted_router(ScriptedAdapter::new([])); - router - .submit("test-caller", "cow", b"body".to_vec()) - .await - .expect("submit"); - - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let stream = crate::runtime::event_loop::open_intent_status_stream( - router, - Duration::from_millis(10), - &executor, - &mut tasks, - ); - crate::runtime::event_loop::run( - &mut supervisor, - Vec::new(), - Vec::new(), - Some(stream), - tasks, - tokio::time::sleep(Duration::from_millis(300)), - ) - .await; - - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - let runs = logs.list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); - let page = logs.read(&runs[0].run, 0); - assert!( - page.records - .iter() - .any(|r| r.message.contains("intent status update from venue cow")), - "the module's on_intent_status handler ran; records were: {:?}", - page.records - .iter() - .map(|r| r.message.as_str()) - .collect::>(), - ); -} - -/// 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 -/// scripted stand-ins on either side. -#[tokio::test] -async fn e2e_echo_module_router_adapter_round_trip() { - use crate::bindings::IntentStatus; - use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; - use crate::host::component::ChainMethod; - use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; - - let (Some(adapter_wasm), Some(module_wasm)) = - (echo_venue_wasm_or_skip(), echo_client_wasm_or_skip()) - else { - return; - }; - - // The adapter reads eth_blockNumber on submit to justify its `chain` - // grant; program the mock so that read succeeds. The response body is - // discarded by the adapter, so any Ok value serves. - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = crate::test_utils::mock_components_from(chain, MockStateStore::new()); - let logs = components.logs.clone(); - - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(echo_venue_module_toml()), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(echo_client_module_toml()), - }], - ..Default::default() - }; - - let mut supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - assert_eq!( - supervisor.adapter_alive_count(), - 1, - "echo-venue is routable" - ); - assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); - 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. - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - assert_eq!(supervisor.dispatch_block(block).await, 1); - - // Poll the router 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 mut delivered = 0; - for _ in 0..2 { - for update in router.poll_status_transitions().await { - assert_eq!(update.venue, "echo-venue"); - assert!( - matches!(update.status, IntentStatus::Settled(_)), - "echo settles instantly; got {:?}", - update.status, - ); - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!( - delivered, 1, - "one terminal status delivered to the subscriber" - ); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // The module observably completed the round trip: it submitted, and it - // received the settled status from the echo venue. - let runs = logs.list_runs("echo-client"); - assert_eq!(runs.len(), 1, "one run recorded for echo-client"); - let page = logs.read(&runs[0].run, 0); - let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert!( - messages - .iter() - .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the pool; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("intent status from venue echo-venue")), - "module received the settled status; records were: {messages:?}", - ); -} - /// A `ManualClock` override threads through `boot_single` onto the module /// store and is behaviour-neutral: the module boots, dispatches a block, and /// stays alive exactly as it does on the ambient clock. Locks the plumbing so @@ -1054,53 +627,6 @@ async fn boot_production_module( .expect("boot_single") } -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot once the cow extension -/// is wired at the composition root; drop the pairing and boot fails. The -/// positive direction (boots WITH the cow extension) is covered by the -/// extension crate that owns the backend. -#[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} - #[tokio::test] async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -2768,41 +2294,97 @@ fn chainlog_cursor_key_differs_by_each_input() { ); } -// ── venue-adapter boot ──────────────────────────────────────────────── +// ── provider boot gating ────────────────────────────────────────────── -/// The venue-adapter linker binds only the scoped transport (chain, -/// messaging, wasi base, allowlisted http) and withholds the core-only -/// interfaces. Assembling it proves the scope wires without a -/// duplicate-definition clash between the shared `nexum:host` interfaces. -#[tokio::test] -async fn adapter_linker_assembles_with_scoped_transport() { - let engine = make_wasmtime_engine(); - crate::supervisor::build_adapter_linker::(&engine) - .expect("adapter linker assembles"); +/// A stub extension registering the `acme-adapter` provider kind behind a +/// unit service, so the boot-gate tests exercise the generic kind loop +/// without a real provider component. +struct AcmeService; +impl crate::host::extension::HostService for AcmeService {} + +struct AcmeKind; + +#[async_trait::async_trait] +impl ProviderKind for AcmeKind { + fn kind(&self) -> &'static str { + "acme-adapter" + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn install( + &self, + _instance: ProviderInstance<'_, crate::test_utils::MockTypes>, + _service: &Arc, + ) -> anyhow::Result { + Ok(Installed::Live) + } +} + +struct AcmeExtension; + +impl Extension for AcmeExtension { + fn namespace(&self) -> &'static str { + "acme" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:acme/", + ifaces: &[], + } + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::new(AcmeService)) + } + + fn provider(&self) -> Option>> { + Some(Box::new(AcmeKind)) + } +} + +/// The stub extension set registering the `acme-adapter` kind. +fn acme_extensions() -> Vec>> { + vec![Arc::new(AcmeExtension)] } -/// The module-kind discriminator gates the adapter load path: an +/// The module-kind discriminator gates the provider load path: an /// `[[adapters]]` entry whose manifest is (or defaults to) an event-module -/// is rejected before instantiation with a message naming the required -/// kind. +/// is rejected before instantiation with a message naming the registered +/// kinds. #[tokio::test] -async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { +async fn boot_rejects_provider_whose_manifest_is_an_event_module() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + "[module]\nname = \"acme\"\nkind = \"event-module\"\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("cow.wasm"), + path: dir.path().join("acme.wasm"), manifest: Some(manifest), http_allow: Vec::new(), messaging_topics: Vec::new(), @@ -2810,54 +2392,96 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), - Err(err) => err, + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("acme-adapter"), + "the kind gate names the registered kinds: {msg}", + ); +} + +/// A kind spelling no extension registered is refused at boot with a +/// message naming the registered kinds. +#[tokio::test] +async fn boot_rejects_an_unregistered_provider_kind() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write(&manifest, "[module]\nname = \"bad\"\nkind = \"gadget\"\n") + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("gadget.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() }; + + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("venue-adapter"), - "the kind gate names the required kind: {msg}", + msg.contains("unregistered provider kind gadget") && msg.contains("acme-adapter"), + "the refusal names the unknown spelling and the registered kinds: {msg}", ); } -/// A venue-adapter manifest clears the discriminator; boot then reaches the +/// A registered kind clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This -/// proves the discriminator routed the entry to the adapter load path +/// proves the discriminator routed the entry to the provider load path /// rather than rejecting it on kind. #[tokio::test] -async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { +async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"venue-adapter\"\n\n\ + "[module]\nname = \"acme\"\nkind = \"acme-adapter\"\n\n\ [capabilities]\nrequired = [\"chain\"]\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("missing-cow.wasm"), + path: dir.path().join("missing-acme.wasm"), manifest: Some(manifest), - http_allow: vec!["api.cow.fi".into()], - messaging_topics: vec!["/nexum/1/cow-orders/proto".into()], + http_allow: vec!["api.acme.example".into()], + messaging_topics: vec!["/nexum/1/acme-orders/proto".into()], }], ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("absent adapter wasm must fail the compile step"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("absent provider wasm must fail the compile step"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("compile") || msg.contains("missing-cow"), + msg.contains("compile") || msg.contains("missing-acme"), "boot reached the compile step past the kind gate: {msg}", ); assert!( @@ -2865,3 +2489,54 @@ async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { "the kind gate passed rather than rejecting: {msg}", ); } + +/// A module subscribing to an extension kind no wired extension declares +/// is refused at boot, preserving the unknown-kind fail-fast. +#[tokio::test] +async fn boot_refuses_an_undeclared_extension_subscription_kind() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "acme-status" +"#, + ) + .expect("write manifest"); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await; + let err = result + .err() + .expect("an undeclared extension subscription kind must refuse boot"); + let msg = format!("{err:#}"); + assert!( + msg.contains("unknown event kind acme-status"), + "the refusal names the kind: {msg}", + ); +} diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 31f2a8d7..10d808d6 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -22,6 +22,7 @@ //! crate's backend through the same harness. use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use alloy_rpc_types_eth::{Header, Log}; @@ -54,7 +55,7 @@ where { wasm: PathBuf, manifest: ManifestSource, - extensions: Vec>>, + extensions: Vec>>>, ext: E, limits: ModuleLimits, chain: MockChainProvider, @@ -100,8 +101,8 @@ impl TestRuntimeBuilder { self } - /// Register an extension's linker hook and capability namespace. - pub fn extension(mut self, extension: Extension>) -> Self { + /// Register an extension. + pub fn extension(mut self, extension: Arc>>) -> Self { self.extensions.push(extension); self } @@ -109,7 +110,7 @@ impl TestRuntimeBuilder { /// Register several extensions at once. pub fn extensions( mut self, - extensions: impl IntoIterator>>, + extensions: impl IntoIterator>>>, ) -> Self { self.extensions.extend(extensions); self @@ -425,18 +426,31 @@ chain_id = {chain_id} return; }; - let calls = Arc::new(AtomicUsize::new(0)); - let hooked = calls.clone(); - let extension = Extension::>> { - link: Arc::new(move |_linker| { - hooked.fetch_add(1, Ordering::SeqCst); + struct CountingExtension(Arc); + + impl Extension>> for CountingExtension { + fn namespace(&self) -> &'static str { + "test" + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link( + &self, + _linker: &mut wasmtime::component::Linker< + crate::host::state::HostState>>, + >, + ) -> anyhow::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); Ok(()) - }), - capabilities: NamespaceCaps { - prefix: "test:ext/", - ifaces: &[], - }, - }; + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let extension = Arc::new(CountingExtension(calls.clone())); let mut rt = TestRuntime::builder_with_ext(wasm, calls.clone()) .extension(extension) diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 089d197a..4180b328 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -15,6 +15,8 @@ use crate::test_utils::{MockChainProvider, MockStateStore}; /// it derives no traits and is zero-sized at runtime. pub struct MockTypes(PhantomData E>); +impl crate::sealed::SealedRuntimeTypes for MockTypes {} + impl RuntimeTypes for MockTypes { type Chain = MockChainProvider; type Store = MockStateStore; diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 022c61bd..ceee3ec0 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -68,7 +68,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature, keccak256}; use tracing::field::{Field, Visit}; use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; @@ -80,8 +84,14 @@ use tracing::{Event, Metadata, Subscriber}; pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, /// `nexum:host/local-store` mock. pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -112,6 +122,59 @@ impl LocalStoreHost for MockHost { fn list_keys(&self, prefix: &str) -> Result, Fault> { self.store.list_keys(prefix) } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: the mock's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } +} + +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } } impl LoggingHost for MockHost { @@ -190,6 +253,337 @@ impl ChainHost for MockChain { } } +// ---------------------------------------------------------------- identity + +/// One recorded [`MockIdentity`] signing invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignCall { + /// Account the guest asked to sign with. + pub account: Address, + /// What was signed. + pub payload: SignPayload, +} + +/// The payload of a [`SignCall`], per signing entry point. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignPayload { + /// A `sign` call: raw message bytes (`personal_sign` semantics). + Message(Vec), + /// A `sign_typed_data` call: the JSON-encoded EIP-712 payload. + TypedData(String), +} + +/// In-memory [`IdentityHost`]. Holds a programmable account roster and +/// one programmed signing outcome; records every signing call. With no +/// outcome programmed, signing fails as [`Fault::Unsupported`], the +/// stub-backend posture; an account outside the roster fails as +/// [`Fault::Denied`] before the programmed outcome applies. +#[derive(Default)] +pub struct MockIdentity { + accounts: RefCell>, + response: RefCell>>, + calls: RefCell>, +} + +impl MockIdentity { + /// Add an account to the roster [`accounts`](IdentityHost::accounts) + /// reports and signing admits. + pub fn add_account(&self, account: Address) { + self.accounts.borrow_mut().push(account); + } + + /// Program the outcome every subsequent signing call returns. + pub fn respond(&self, result: Result) { + *self.response.borrow_mut() = Some(result); + } + + /// All signing calls received, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last signing call received, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Total signing call count. + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + fn dispatch(&self, account: Address, payload: SignPayload) -> Result { + self.calls.borrow_mut().push(SignCall { account, payload }); + if !self.accounts.borrow().contains(&account) { + return Err(Fault::Denied(format!( + "MockIdentity: account {account} is not held" + ))); + } + self.response.borrow().clone().unwrap_or_else(|| { + Err(Fault::Unsupported( + "MockIdentity: no signing outcome programmed".to_string(), + )) + }) + } +} + +impl IdentityHost for MockIdentity { + fn accounts(&self) -> Result, Fault> { + Ok(self.accounts.borrow().clone()) + } + + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.dispatch(account, SignPayload::Message(message.to_vec())) + } + + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.dispatch(account, SignPayload::TypedData(typed_data.to_owned())) + } +} + +// ---------------------------------------------------------------- messaging + +/// One recorded [`MessagingHost::publish`] invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishRecord { + /// Content topic published to. + pub content_topic: String, + /// Payload bytes, verbatim. + pub payload: Vec, +} + +/// In-memory [`MessagingHost`]. Seeded messages answer queries, +/// publishes are recorded for assertion, and an optional topic scope +/// mirrors the host's `messaging_topics` grant. Seeded history and +/// published records are deliberately separate stores: a query answers +/// from what the test seeded, never from what the guest published. +#[derive(Default)] +pub struct MockMessaging { + history: RefCell>, + published: RefCell>, + scope: RefCell>>, + faults: RefCell>, +} + +impl MockMessaging { + /// Seed one message into the queryable history. + pub fn seed(&self, message: Message) { + self.history.borrow_mut().push(message); + } + + /// Seed a payload on `content_topic` at `timestamp` (ms since the + /// Unix epoch, UTC), with no sender. + pub fn seed_payload( + &self, + content_topic: impl Into, + payload: impl Into>, + timestamp: u64, + ) { + self.seed(Message { + content_topic: content_topic.into(), + payload: payload.into(), + timestamp, + sender: None, + }); + } + + /// Confine the mock to `topics`, playing the component's + /// `messaging_topics` grant with the host's matching: a topic is + /// admitted when it equals a grant entry or descends from one read + /// as a `/`-bounded path prefix; anything else fails as + /// [`Fault::Denied`]. An empty grant is unscoped, the host's module + /// default, as is an untouched mock. + pub fn scope_topics(&self, topics: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); + } + + /// Inject a fault for any operation on a topic starting with + /// `prefix`. Multiple patterns can be registered; the first + /// matching one fires. + pub fn fail_on(&self, prefix: impl Into, fault: Fault) { + self.faults.borrow_mut().push((prefix.into(), fault)); + } + + /// All publishes received, in arrival order. + pub fn published(&self) -> Vec { + self.published.borrow().clone() + } + + /// Last publish received, if any. + pub fn last_published(&self) -> Option { + self.published.borrow().last().cloned() + } + + /// Total publish count. + pub fn publish_count(&self) -> usize { + self.published.borrow().len() + } + + fn admit(&self, content_topic: &str) -> Result<(), Fault> { + for (prefix, fault) in self.faults.borrow().iter() { + if content_topic.starts_with(prefix.as_str()) { + return Err(fault.clone()); + } + } + if let Some(scope) = self.scope.borrow().as_ref() + && !topic_in_scope(content_topic, scope) + { + return Err(Fault::Denied(format!( + "MockMessaging: {content_topic} is outside the scoped topics" + ))); + } + Ok(()) + } +} + +/// The host's `messaging_topics` matching: an empty scope admits every +/// topic; otherwise a topic is admitted when it equals a scope entry or +/// descends from one read as a path prefix bounded at `/`, so a grant +/// never leaks into a longer sibling segment. +fn topic_in_scope(topic: &str, scope: &[String]) -> bool { + if scope.is_empty() { + return true; + } + scope.iter().any(|allowed| { + if topic == allowed { + return true; + } + let prefix = allowed.strip_suffix('/').unwrap_or(allowed); + topic + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + +impl MessagingHost for MockMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.admit(content_topic)?; + self.published.borrow_mut().push(PublishRecord { + content_topic: content_topic.to_owned(), + payload: payload.to_vec(), + }); + Ok(()) + } + + /// Answer from the seeded history: exact-topic matches whose + /// timestamp lies within the inclusive `start_time..=end_time` + /// window, in seed order. Seed order is delivery order, so a + /// `limit` keeps the newest matches: the tail. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.admit(content_topic)?; + let mut matches: Vec = self + .history + .borrow() + .iter() + .filter(|message| { + message.content_topic == content_topic + && start_time.is_none_or(|start| message.timestamp >= start) + && end_time.is_none_or(|end| message.timestamp <= end) + }) + .cloned() + .collect(); + if let Some(limit) = limit { + let keep = usize::try_from(limit).unwrap_or(usize::MAX); + if matches.len() > keep { + matches.drain(..matches.len() - keep); + } + } + Ok(matches) + } +} + +// ---------------------------------------------------------------- remote-store + +/// In-memory [`RemoteStoreHost`]: content-addressed blobs plus mutable +/// `(owner, topic)` feeds. The mock addresses blobs by `keccak256` of +/// their content, so uploads are deterministic for assertion; the real +/// store's addressing scheme is the host's concern. Feed writes land +/// under the mock's own owner ([`set_owner`](Self::set_owner), +/// zero-address by default), mirroring the host signing feed updates +/// with its configured identity. +#[derive(Default)] +pub struct MockRemoteStore { + blobs: RefCell>>, + feeds: RefCell>>, + owner: Cell
, + fault: RefCell>, +} + +impl MockRemoteStore { + /// Set the owner feed writes land under. + pub fn set_owner(&self, owner: Address) { + self.owner.set(owner); + } + + /// Seed a blob without going through the trait; returns its + /// reference. + pub fn seed_blob(&self, data: impl Into>) -> B256 { + let data = data.into(); + let reference = keccak256(&data); + self.blobs.borrow_mut().insert(reference, data); + reference + } + + /// Seed another owner's feed for [`read_feed`](RemoteStoreHost::read_feed) + /// tests. + pub fn seed_feed(&self, owner: Address, topic: B256, data: impl Into>) { + self.feeds.borrow_mut().insert((owner, topic), data.into()); + } + + /// Inject a fault every subsequent operation returns. + pub fn fail_with(&self, fault: Fault) { + *self.fault.borrow_mut() = Some(fault); + } + + /// Number of stored blobs. + pub fn blob_count(&self) -> usize { + self.blobs.borrow().len() + } + + fn check_injected_fault(&self) -> Result<(), Fault> { + match self.fault.borrow().as_ref() { + Some(fault) => Err(fault.clone()), + None => Ok(()), + } + } +} + +impl RemoteStoreHost for MockRemoteStore { + fn upload(&self, data: &[u8]) -> Result { + self.check_injected_fault()?; + Ok(self.seed_blob(data)) + } + + fn download(&self, reference: B256) -> Result, Fault> { + self.check_injected_fault()?; + self.blobs + .borrow() + .get(&reference) + .cloned() + .ok_or_else(|| Fault::Unavailable(format!("MockRemoteStore: no blob at {reference}"))) + } + + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.check_injected_fault()?; + Ok(self.feeds.borrow().get(&(owner, topic)).cloned()) + } + + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.check_injected_fault()?; + let reference = self.seed_blob(data); + self.feeds + .borrow_mut() + .insert((self.owner.get(), topic), data.to_vec()); + Ok(reference) + } +} + // ---------------------------------------------------------------- local-store /// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: @@ -380,6 +774,33 @@ impl LocalStoreHost for MockLocalStore { keys.sort(); Ok(keys) } + fn contains(&self, key: &str) -> Result { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .contains_key(&(self.namespace.clone(), key.to_string()))) + } + fn len(&self, key: &str) -> Result, Fault> { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .map(|v| v.len() as u64)) + } + fn count(&self, prefix: &str) -> Result { + self.check_injected_error(prefix)?; + Ok(self + .shared + .rows + .borrow() + .keys() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .count() as u64) + } } // ---------------------------------------------------------------- logging @@ -679,6 +1100,8 @@ pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { #[cfg(test)] mod tests { + use nexum_sdk::prelude::U256; + use super::*; #[test] @@ -739,6 +1162,33 @@ mod tests { assert!(log.contains("uh oh")); } + #[test] + fn local_store_metadata_queries() { + let store = MockLocalStore::default(); + store.set("watch:a", b"abc").unwrap(); + store.set("watch:b", b"").unwrap(); + store.set("posted:1", b"x").unwrap(); + + assert!(store.contains("watch:a").unwrap()); + assert!(!store.contains("missing").unwrap()); + assert_eq!(LocalStoreHost::len(&store, "watch:a").unwrap(), Some(3)); + assert_eq!(LocalStoreHost::len(&store, "watch:b").unwrap(), Some(0)); + assert_eq!(LocalStoreHost::len(&store, "missing").unwrap(), None); + assert_eq!(store.count("watch:").unwrap(), 2); + assert_eq!(store.count("").unwrap(), 3); + + // Metadata queries stay namespace-scoped. + let other = store.namespaced("other"); + assert_eq!(other.count("").unwrap(), 0); + assert!(!other.contains("watch:a").unwrap()); + + // And respect fault injection. + store.fail_on("bad:", Fault::Internal("injected".into())); + assert!(store.contains("bad:k").is_err()); + assert!(LocalStoreHost::len(&store, "bad:k").is_err()); + assert!(store.count("bad:").is_err()); + } + #[test] fn local_store_error_injection() { let store = MockLocalStore::default(); @@ -838,22 +1288,219 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn identity_roster_and_programmed_outcome() { + let identity = MockIdentity::default(); + let account = Address::from([0xAA; 20]); + assert!(identity.accounts().unwrap().is_empty()); + identity.add_account(account); + assert_eq!(identity.accounts().unwrap(), vec![account]); + + // No outcome programmed: signing is unsupported, the stub posture. + let err = identity.sign(account, b"hello").unwrap_err(); + assert!(matches!(err, Fault::Unsupported(ref m) if m.contains("MockIdentity"))); + + let signature = Signature::new(U256::from(1), U256::from(2), false); + identity.respond(Ok(signature)); + assert_eq!(identity.sign(account, b"hello").unwrap(), signature); + assert_eq!(identity.sign_typed_data(account, "{}").unwrap(), signature); + + assert_eq!(identity.call_count(), 3); + assert_eq!( + identity.last_call().unwrap(), + SignCall { + account, + payload: SignPayload::TypedData("{}".to_owned()), + }, + ); + } + + #[test] + fn identity_denies_off_roster_accounts() { + let identity = MockIdentity::default(); + identity.respond(Ok(Signature::new(U256::from(1), U256::from(2), true))); + let err = identity.sign(Address::from([0xBB; 20]), b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused call is still recorded. + assert_eq!(identity.call_count(), 1); + } + + #[test] + fn messaging_records_publishes_and_answers_from_seeds() { + let messaging = MockMessaging::default(); + messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); + messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); + messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); + + messaging.publish("/acme/1/orders/proto", b"out").unwrap(); + assert_eq!(messaging.publish_count(), 1); + assert_eq!( + messaging.last_published().unwrap(), + PublishRecord { + content_topic: "/acme/1/orders/proto".to_owned(), + payload: b"out".to_vec(), + }, + ); + + // Publishes never leak into query results. + let all = messaging + .query("/acme/1/orders/proto", None, None, None) + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].payload, b"one"); + assert_eq!(all[1].payload, b"two"); + } + + #[test] + fn messaging_query_applies_bounds_and_limit() { + let messaging = MockMessaging::default(); + for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { + messaging.seed_payload("/t", payload.to_vec(), ts); + } + + let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); + assert_eq!(window.len(), 2); + assert_eq!(window[0].payload, b"b"); + + // A limit keeps the newest matches: the tail of the window. + let limited = messaging.query("/t", None, None, Some(2)).unwrap(); + assert_eq!(limited.len(), 2); + assert_eq!(limited[0].payload, b"c"); + assert_eq!(limited[1].payload, b"d"); + } + + #[test] + fn messaging_scope_denies_off_grant_topics() { + let messaging = MockMessaging::default(); + messaging.scope_topics(["/acme/1/orders/proto"]); + + messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); + let err = messaging.publish("/other", b"no").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + let err = messaging.query("/other", None, None, None).unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused publish was never recorded. + assert_eq!(messaging.publish_count(), 1); + } + + #[test] + fn messaging_scope_matches_the_host_grant() { + // A prefix grant admits the family beneath it, bounded at `/`. + let messaging = MockMessaging::default(); + messaging.scope_topics(["/nexum/1/"]); + messaging + .publish("/nexum/1/acme-orders/proto", b"x") + .unwrap(); + messaging.publish("/nexum/1/twap/proto", b"x").unwrap(); + let err = messaging.publish("/nexum/2/acme/proto", b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + + // No trailing slash still bounds on the separator: a grant never + // leaks into a longer sibling segment. + let messaging = MockMessaging::default(); + messaging.scope_topics(["/nexum/1/acme"]); + messaging.publish("/nexum/1/acme", b"x").unwrap(); + messaging.publish("/nexum/1/acme/orders", b"x").unwrap(); + let err = messaging + .publish("/nexum/1/acme-orders/proto", b"x") + .unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + + // An empty grant is unscoped, the host's module default. + let messaging = MockMessaging::default(); + messaging.scope_topics(Vec::::new()); + messaging.publish("/anywhere/at/all", b"x").unwrap(); + } + + #[test] + fn messaging_fault_injection_fires_by_prefix() { + let messaging = MockMessaging::default(); + messaging.fail_on("/flaky", Fault::Timeout); + assert!(matches!( + messaging.publish("/flaky/topic", b"x").unwrap_err(), + Fault::Timeout, + )); + messaging.publish("/steady", b"x").unwrap(); + } + + #[test] + fn remote_store_round_trips_content_addressed_blobs() { + let store = MockRemoteStore::default(); + let reference = store.upload(b"chunk").unwrap(); + assert_eq!(reference, keccak256(b"chunk")); + assert_eq!(store.download(reference).unwrap(), b"chunk"); + assert_eq!(store.blob_count(), 1); + + let missing = store.download(B256::from([0xCC; 32])).unwrap_err(); + assert!(matches!(missing, Fault::Unavailable(ref m) if m.contains("MockRemoteStore"))); + } + + #[test] + fn remote_store_feeds_are_owner_scoped() { + let store = MockRemoteStore::default(); + let owner = Address::from([0xAA; 20]); + let topic = B256::from([0x11; 32]); + + // Writes land under the mock's own owner and stay downloadable. + store.set_owner(owner); + let reference = store.write_feed(topic, b"v1").unwrap(); + assert_eq!(store.download(reference).unwrap(), b"v1"); + assert_eq!( + store.read_feed(owner, topic).unwrap().as_deref(), + Some(&b"v1"[..]) + ); + + // Another owner's feed is a distinct slot. + let other = Address::from([0xBB; 20]); + assert_eq!(store.read_feed(other, topic).unwrap(), None); + store.seed_feed(other, topic, b"theirs"); + assert_eq!( + store.read_feed(other, topic).unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + } + + #[test] + fn remote_store_fault_injection_covers_every_operation() { + let store = MockRemoteStore::default(); + store.fail_with(Fault::Timeout); + assert!(matches!(store.upload(b"x").unwrap_err(), Fault::Timeout)); + assert!(matches!( + store.download(B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.read_feed(Address::ZERO, B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.write_feed(B256::ZERO, b"x").unwrap_err(), + Fault::Timeout, + )); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); host.chain .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + host.messaging.seed_payload("/t", b"m".to_vec(), 1); - // Through the `Host` supertrait. + // Through the `Host` supertrait: all six seams on one value. let _: &dyn nexum_sdk::host::Host = &host; host.set("key", b"val").unwrap(); assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); + assert!(host.accounts().unwrap().is_empty()); + assert_eq!(host.query("/t", None, None, None).unwrap().len(), 1); + let reference = host.upload(b"blob").unwrap(); + assert_eq!(host.download(reference).unwrap(), b"blob"); host.log(Level::INFO, "happy path"); assert_eq!(host.chain.call_count(), 1); assert_eq!(host.logging.lines().len(), 1); assert_eq!(host.store.len(), 1); + assert_eq!(host.remote_store.blob_count(), 1); } #[test] diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index f713c5c1..445c894a 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -22,17 +22,31 @@ stderr-echo = [] # Re-exported as `nexum_sdk::module`; the proc-macro emits glue that # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). -nexum-macros = { path = "../nexum-macros" } +nexum-module-macros = { path = "../nexum-module-macros" } +# Decoder for the opaque status body an `intent-status` event carries; +# re-exported as `nexum_sdk::status_body`. +nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true +# The provider seam: `HostTransport` speaks alloy's JSON-RPC packet +# vocabulary over `ChainHost::request`, and `provider()` fronts it with +# a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches +# the wasm guest. +alloy-json-rpc.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +alloy-transport.workspace = true +tower.workspace = true # Standard HTTP request/response/method vocabulary; the SDK adds only # the wasi:http-specific `fetch` seam on top. wstd re-exports the same # `http` types, so a request passes through to the client unconverted. http.workspace = true -serde_json.workspace = true +# `raw_value` backs the transport's pass-through of host JSON into +# alloy's `Box` payload slots. +serde_json = { workspace = true, features = ["std", "raw_value"] } strum.workspace = true thiserror.workspace = true # `tracing-core` backs the guest facade's subscriber plumbing; the @@ -49,6 +63,8 @@ proptest.workspace = true # The keeper never touches the orderbook, so a CoW-layer mock would only drag # the domain crates into this crate's dev graph. nexum-sdk-test = { path = "../nexum-sdk-test" } +# Pins the strum-derived fault labels to the single-source vocabulary. +nexum-world = { path = "../nexum-world" } # The wasi:http client only links on the wasm guest target; host-side # consumers (tests, backtest tooling) compile the `http` module's types diff --git a/crates/nexum-sdk/src/chain/id.rs b/crates/nexum-sdk/src/chain/id.rs new file mode 100644 index 00000000..598857c3 --- /dev/null +++ b/crates/nexum-sdk/src/chain/id.rs @@ -0,0 +1,125 @@ +//! Zero-cost chain identity newtypes. + +use core::fmt; + +/// EIP-155 chain id, typed so a bare `u64` never crosses an SDK API. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ChainId(u64); + +impl ChainId { + /// Wrap a raw EIP-155 id. + pub const fn new(id: u64) -> Self { + Self(id) + } + + /// The raw id, for the WIT edge. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl From for ChainId { + fn from(id: u64) -> Self { + Self::new(id) + } +} + +impl From for u64 { + fn from(id: ChainId) -> Self { + id.get() + } +} + +impl fmt::Display for ChainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// A chain a strategy targets, keyed by its [`ChainId`]. The type the +/// provider seam takes; events deliver a raw id, so `ev.chain_id.into()` +/// bridges at the handler edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Chain(ChainId); + +impl Chain { + /// Ethereum mainnet. + pub const MAINNET: Self = Self::from_id(1); + /// Gnosis Chain. + pub const GNOSIS: Self = Self::from_id(100); + /// Base. + pub const BASE: Self = Self::from_id(8_453); + /// Arbitrum One. + pub const ARBITRUM: Self = Self::from_id(42_161); + /// Sepolia testnet. + pub const SEPOLIA: Self = Self::from_id(11_155_111); + + /// Chain with the given raw EIP-155 id. + pub const fn from_id(id: u64) -> Self { + Self(ChainId::new(id)) + } + + /// The chain's id. + pub const fn id(self) -> ChainId { + self.0 + } +} + +impl From for Chain { + fn from(id: u64) -> Self { + Self::from_id(id) + } +} + +impl From for Chain { + fn from(id: ChainId) -> Self { + Self(id) + } +} + +impl From for ChainId { + fn from(chain: Chain) -> Self { + chain.id() + } +} + +impl From for u64 { + fn from(chain: Chain) -> Self { + chain.id().get() + } +} + +impl fmt::Display for Chain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::{Chain, ChainId}; + + #[test] + fn ids_round_trip() { + assert_eq!(u64::from(ChainId::new(100)), 100); + assert_eq!(ChainId::from(7u64).get(), 7); + assert_eq!(u64::from(Chain::from_id(42)), 42); + assert_eq!(Chain::from(ChainId::new(1)), Chain::MAINNET); + assert_eq!(ChainId::from(Chain::SEPOLIA).get(), 11_155_111); + } + + #[test] + fn named_chains_carry_canonical_ids() { + assert_eq!(u64::from(Chain::MAINNET), 1); + assert_eq!(u64::from(Chain::GNOSIS), 100); + assert_eq!(u64::from(Chain::BASE), 8_453); + assert_eq!(u64::from(Chain::ARBITRUM), 42_161); + assert_eq!(u64::from(Chain::SEPOLIA), 11_155_111); + } + + #[test] + fn display_is_the_raw_id() { + assert_eq!(Chain::GNOSIS.to_string(), "100"); + assert_eq!(ChainId::new(1).to_string(), "1"); + } +} diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs new file mode 100644 index 00000000..58ecfae6 --- /dev/null +++ b/crates/nexum-sdk/src/chain/method.rs @@ -0,0 +1,122 @@ +//! The typed JSON-RPC method surface, guest side. + +use strum::{EnumString, IntoStaticStr}; + +/// The permitted JSON-RPC read surface as a closed type, mirroring the +/// runtime's `ChainMethod` case for case. Signing and mutating methods +/// have no variant, so they cannot be represented and never cross the +/// WIT edge; [`HostTransport`](super::HostTransport) rejects anything +/// outside this set before calling the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +#[non_exhaustive] +pub enum ChainMethod { + /// `eth_blockNumber`. + #[strum(serialize = "eth_blockNumber")] + EthBlockNumber, + /// `eth_call`. + #[strum(serialize = "eth_call")] + EthCall, + /// `eth_chainId`. + #[strum(serialize = "eth_chainId")] + EthChainId, + /// `eth_estimateGas`. + #[strum(serialize = "eth_estimateGas")] + EthEstimateGas, + /// `eth_feeHistory`. + #[strum(serialize = "eth_feeHistory")] + EthFeeHistory, + /// `eth_gasPrice`. + #[strum(serialize = "eth_gasPrice")] + EthGasPrice, + /// `eth_maxPriorityFeePerGas`. + #[strum(serialize = "eth_maxPriorityFeePerGas")] + EthMaxPriorityFeePerGas, + /// `eth_getBalance`. + #[strum(serialize = "eth_getBalance")] + EthGetBalance, + /// `eth_getBlockByHash`. + #[strum(serialize = "eth_getBlockByHash")] + EthGetBlockByHash, + /// `eth_getBlockByNumber`. + #[strum(serialize = "eth_getBlockByNumber")] + EthGetBlockByNumber, + /// `eth_getBlockReceipts`. + #[strum(serialize = "eth_getBlockReceipts")] + EthGetBlockReceipts, + /// `eth_getCode`. + #[strum(serialize = "eth_getCode")] + EthGetCode, + /// `eth_getLogs`. + #[strum(serialize = "eth_getLogs")] + EthGetLogs, + /// `eth_getProof`. + #[strum(serialize = "eth_getProof")] + EthGetProof, + /// `eth_getStorageAt`. + #[strum(serialize = "eth_getStorageAt")] + EthGetStorageAt, + /// `eth_getTransactionByHash`. + #[strum(serialize = "eth_getTransactionByHash")] + EthGetTransactionByHash, + /// `eth_getTransactionCount`. + #[strum(serialize = "eth_getTransactionCount")] + EthGetTransactionCount, + /// `eth_getTransactionReceipt`. + #[strum(serialize = "eth_getTransactionReceipt")] + EthGetTransactionReceipt, + /// `net_version`. + #[strum(serialize = "net_version")] + NetVersion, +} + +impl ChainMethod { + /// The wire method name. `&'static` because the set is closed. + pub fn as_str(self) -> &'static str { + self.into() + } +} + +#[cfg(test)] +mod tests { + use super::ChainMethod; + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "admin_peers", + "debug_traceCall", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), + Ok(ChainMethod::EthGetBalance), + ); + } +} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index dd60ba0d..e10c8808 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,10 +1,21 @@ -//! `chain::request` JSON plumbing. +//! Chain access for guest strategies. //! -//! Build the `[{to, data}, "latest"]` params array for `eth_call` and -//! parse the `"0x..."` hex result string. Pure-logic helpers so a -//! module can plumb its own `chain::request` shim around them. +//! Typed identity ([`Chain`], [`ChainId`]), the closed JSON-RPC read +//! surface ([`ChainMethod`]), and the alloy provider seam: a +//! [`HostTransport`] over `ChainHost::request` fronted by +//! [`ProviderHost::provider`], driven with [`block_on`]. Plus the +//! `eth_call` JSON plumbing helpers for modules that keep their own +//! `chain::request` shim. pub mod chainlink; pub mod eth_call; +pub mod id; +pub mod method; +pub mod provider; +pub mod transport; pub use eth_call::{eth_call_params, parse_eth_call_result}; +pub use id::{Chain, ChainId}; +pub use method::ChainMethod; +pub use provider::{ProviderHost, block_on}; +pub use transport::HostTransport; diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs new file mode 100644 index 00000000..a46d4f8c --- /dev/null +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -0,0 +1,119 @@ +//! `host.provider(chain)`: an alloy `Provider` over the chain host. + +use std::future::{Future, IntoFuture}; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; + +use super::{Chain, HostTransport}; +use crate::host::ChainHost; + +/// Mints an alloy [`Provider`](alloy_provider::Provider) over +/// [`ChainHost::request`], so a strategy calls typed provider methods +/// instead of hand-building JSON-RPC. Blanket-implemented for every +/// cloneable [`ChainHost`]; drive the returned futures with +/// [`block_on`]. +/// +/// ``` +/// use alloy_provider::Provider; +/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; +/// use nexum_sdk::host::{ChainError, ChainHost}; +/// +/// #[derive(Clone)] +/// struct StubHost; +/// impl ChainHost for StubHost { +/// fn request(&self, _: u64, _: &str, _: &str) -> Result { +/// Ok("\"0x2a\"".into()) +/// } +/// } +/// +/// let provider = StubHost.provider(Chain::MAINNET); +/// let block = block_on(provider.get_block_number()).unwrap(); +/// assert_eq!(block, 42); +/// ``` +pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { + /// Provider for `chain`, routed through the host's RPC stack. + fn provider(&self, chain: Chain) -> RootProvider { + RootProvider::new(RpcClient::new( + HostTransport::new(self.clone(), chain), + false, + )) + } +} + +impl ProviderHost for H {} + +/// Drive a provider future to completion. Host-backed transports +/// resolve synchronously, so this is a poll loop, not a scheduler; a +/// future that awaits anything other than a host call will spin. +pub fn block_on(future: F) -> F::Output { + let mut future = pin!(future.into_future()); + let mut cx = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return output; + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Bytes, address}; + use alloy_provider::Provider; + use alloy_rpc_types_eth::TransactionRequest; + + use super::{ProviderHost, block_on}; + use crate::chain::Chain; + use crate::host::{ChainError, ChainHost}; + + #[derive(Clone)] + struct StubHost; + + impl ChainHost for StubHost { + fn request( + &self, + chain_id: u64, + method: &str, + _params: &str, + ) -> Result { + assert_eq!(chain_id, 100); + match method { + "eth_blockNumber" => Ok("\"0x2a\"".into()), + "eth_call" => Ok("\"0x1234\"".into()), + other => panic!("unexpected method {other}"), + } + } + } + + #[test] + fn provider_reads_typed_values_through_the_host() { + let provider = StubHost.provider(Chain::GNOSIS); + let block = block_on(provider.get_block_number()).expect("block number"); + assert_eq!(block, 42); + } + + #[test] + fn provider_call_decodes_bytes() { + let provider = StubHost.provider(Chain::GNOSIS); + let tx = TransactionRequest::default() + .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); + let out = block_on(provider.call(tx)).expect("eth_call"); + assert_eq!(out, Bytes::from(vec![0x12, 0x34])); + } + + #[test] + fn signing_methods_error_before_the_host() { + let provider = StubHost.provider(Chain::GNOSIS); + let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) + .expect_err("write method is rejected"); + let payload = err.as_error_resp().expect("json-rpc error response"); + assert_eq!(payload.code, -32601); + } + + #[test] + fn block_on_drives_plain_futures() { + assert_eq!(block_on(async { 7 }), 7); + } +} diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs new file mode 100644 index 00000000..10c64974 --- /dev/null +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -0,0 +1,252 @@ +//! [`HostTransport`]: the alloy transport over [`ChainHost::request`]. + +use std::future::ready; +use std::task::{Context, Poll}; + +use alloy_json_rpc::{ + ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, SerializedRequest, +}; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use serde_json::value::RawValue; +use tower::Service; + +use super::{Chain, ChainMethod}; +use crate::host::{ChainError, ChainHost}; + +/// An alloy `Transport` routing JSON-RPC through the host's chain +/// interface. Dispatch is synchronous: the host blocks the guest until +/// the response is available, so every returned future is ready on its +/// first poll and [`block_on`](super::block_on) drives it for free. +/// +/// Methods outside the typed [`ChainMethod`] surface never reach the +/// host; they fail as a JSON-RPC `-32601` error response. A structured +/// node error comes back as the error payload (code, message, revert +/// bytes as `0x` hex); a host [`Fault`](crate::host::Fault) surfaces as +/// a custom transport error carrying the typed fault. +#[derive(Clone, Copy, Debug)] +pub struct HostTransport { + host: H, + chain: Chain, +} + +impl HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + /// Transport dispatching on `chain` through `host`. + pub const fn new(host: H, chain: Chain) -> Self { + Self { host, chain } + } + + fn dispatch(&self, packet: RequestPacket) -> Result { + match packet { + RequestPacket::Single(req) => Ok(ResponsePacket::Single(self.dispatch_single(&req)?)), + RequestPacket::Batch(reqs) => reqs + .iter() + .map(|req| self.dispatch_single(req)) + .collect::, _>>() + .map(ResponsePacket::Batch), + } + } + + fn dispatch_single(&self, req: &SerializedRequest) -> Result { + let Ok(method) = ChainMethod::try_from(req.method()) else { + return Ok(failure( + req, + ErrorPayload { + code: -32601, + message: format!( + "method outside the permitted read surface: {}", + req.method() + ) + .into(), + data: None, + }, + )); + }; + let params = req.params().map_or("[]", RawValue::get); + match self + .host + .request(self.chain.into(), method.as_str(), params) + { + Ok(result) => { + let payload = RawValue::from_string(result) + .map_err(|e| TransportError::deser_err(e, "host chain response"))?; + Ok(Response { + id: req.id().clone(), + payload: ResponsePayload::Success(payload), + }) + } + Err(ChainError::Rpc(rpc)) => Ok(failure( + req, + ErrorPayload { + code: rpc.code.into(), + message: rpc.message.into(), + data: rpc.data.and_then(|bytes| { + serde_json::value::to_raw_value(&alloy_primitives::hex::encode_prefixed( + bytes, + )) + .ok() + }), + }, + )), + Err(ChainError::Fault(fault)) => Err(TransportErrorKind::custom(fault)), + } + } +} + +fn failure(req: &SerializedRequest, payload: ErrorPayload) -> Response { + Response { + id: req.id().clone(), + payload: ResponsePayload::Failure(payload), + } +} + +impl Service for HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, packet: RequestPacket) -> Self::Future { + let result = self.dispatch(packet); + Box::pin(ready(result)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use alloy_json_rpc::{Id, Request, RequestPacket, ResponsePacket, ResponsePayload}; + use alloy_transport::TransportError; + use tower::Service; + + use super::HostTransport; + use crate::chain::{Chain, block_on}; + use crate::host::{ChainError, ChainHost, Fault, RpcError}; + + type StubFn = dyn Fn(u64, &str, &str) -> Result + Send + Sync; + + #[derive(Clone)] + struct Stub(Arc); + + impl Stub { + fn new( + f: impl Fn(u64, &str, &str) -> Result + Send + Sync + 'static, + ) -> Self { + Self(Arc::new(f)) + } + } + + impl ChainHost for Stub { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + (self.0)(chain_id, method, params) + } + } + + fn single(method: &'static str) -> RequestPacket { + let req = Request::new(method, Id::Number(1), ()) + .serialize() + .expect("request serializes"); + RequestPacket::Single(req) + } + + fn call(transport: &mut HostTransport, packet: RequestPacket) -> super::Response { + let ResponsePacket::Single(resp) = + block_on(Service::call(transport, packet)).expect("transport dispatches") + else { + panic!("single request yields a single response"); + }; + resp + } + + #[test] + fn success_passes_host_json_through() { + let stub = Stub::new(|chain_id, method, params| { + assert_eq!(chain_id, 100); + assert_eq!(method, "eth_blockNumber"); + assert_eq!(params, "[]"); + Ok("\"0x2a\"".into()) + }); + let mut transport = HostTransport::new(stub, Chain::GNOSIS); + let resp = call(&mut transport, single("eth_blockNumber")); + let ResponsePayload::Success(payload) = resp.payload else { + panic!("expected success, got {resp:?}"); + }; + assert_eq!(payload.get(), "\"0x2a\""); + } + + #[test] + fn unlisted_method_never_reaches_the_host() { + let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let resp = call(&mut transport, single("eth_sendRawTransaction")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32601); + assert!(err.message.contains("eth_sendRawTransaction")); + } + + #[test] + fn rpc_error_surfaces_code_message_and_revert_hex() { + let stub = Stub::new(|_, _, _| { + Err(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), + })) + }); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let resp = call(&mut transport, single("eth_call")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32000); + assert_eq!(err.message, "execution reverted"); + assert_eq!(err.data.expect("revert data").get(), "\"0x08c379a0\"",); + } + + #[test] + fn fault_becomes_a_typed_transport_error() { + let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let err = block_on(Service::call(&mut transport, single("eth_call"))) + .expect_err("fault propagates"); + let TransportError::Transport(kind) = err else { + panic!("expected transport kind, got {err:?}"); + }; + assert!(kind.to_string().contains("timeout")); + } + + #[test] + fn batches_dispatch_per_request() { + let stub = Stub::new(|_, method, _| match method { + "eth_blockNumber" => Ok("\"0x1\"".into()), + _ => Ok("\"0x64\"".into()), + }); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let reqs = vec![ + Request::new("eth_blockNumber", Id::Number(1), ()) + .serialize() + .expect("request serializes"), + Request::new("eth_chainId", Id::Number(2), ()) + .serialize() + .expect("request serializes"), + ]; + let ResponsePacket::Batch(resps) = + block_on(Service::call(&mut transport, RequestPacket::Batch(reqs))) + .expect("batch dispatches") + else { + panic!("batch request yields a batch response"); + }; + assert_eq!(resps.len(), 2); + } +} diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index 4a278cfc..fdfcfc9c 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -20,6 +20,7 @@ use thiserror::Error; /// /// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput #[derive(Debug, Error)] +#[non_exhaustive] pub enum ConfigError { /// The key was not present in the `entries` slice. #[error("missing key {key:?}")] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 6e0e71d5..9fc0cb68 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,13 +1,14 @@ //! Host traits - the seam between strategy logic and the wit-bindgen //! shims a module generates per-cdylib. //! -//! Each trait mirrors one nexum host interface ([`ChainHost`] for -//! `nexum:host/chain`, [`LocalStoreHost`] for `nexum:host/local-store`, -//! [`LoggingHost`] for `nexum:host/logging`). A module that wants +//! Each trait mirrors one nexum host interface: [`ChainHost`], +//! [`IdentityHost`], [`LocalStoreHost`], [`RemoteStoreHost`], +//! [`MessagingHost`], and [`LoggingHost`]. A module that wants //! host-free unit tests writes its strategy logic against the -//! [`Host`] supertrait and lets `nexum-sdk-test` slot in the -//! in-memory mocks. Domain SDKs bound extra host interfaces on top -//! with their own traits over the same [`Fault`]. +//! [`Host`] supertrait (all six) or the exact traits it exercises, +//! and lets `nexum-sdk-test` slot in the in-memory mocks. Domain SDKs +//! bound extra host interfaces on top with their own traits over the +//! same [`Fault`]. //! //! ## Why a separate `Fault` //! @@ -18,7 +19,7 @@ //! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s //! crate docs for the adapter pattern. -use alloy_primitives::Bytes; +use alloy_primitives::{Address, B256, Bytes, Signature}; use strum::IntoStaticStr; use tracing_core::Level; @@ -44,7 +45,7 @@ pub enum Fault { Denied(String), /// Rate-limited by an upstream service; may carry backoff guidance /// when the host knows the retry window. - #[error("rate limited")] + #[error("rate limited{}", .0.retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] RateLimited(RateLimit), /// Operation took too long. #[error("timeout")] @@ -65,13 +66,32 @@ pub struct RateLimit { pub retry_after_ms: Option, } +/// Sealing markers for [`Host`] and [`HostFault`]: implement alongside +/// the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedHost {} + pub trait SealedHostFault {} +} + +impl sealed::SealedHost for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} + +impl sealed::SealedHostFault for Fault {} +impl sealed::SealedHostFault for ChainError {} + /// Recovers the shared [`Fault`] from a richer, per-interface error. /// /// Typed interface errors that embed a fault case implement this so a /// caller can dispatch on the structured cause and pull a stable /// snake_case [`label`](HostFault::label) for logs and metrics without /// matching the outer type. -pub trait HostFault { +/// +/// Sealed: an error type opts in by also implementing the sealing +/// marker. +pub trait HostFault: sealed::SealedHostFault { /// The embedded fault, when this value represents one. fn fault(&self) -> Option<&Fault>; /// Stable snake_case label for logs and metrics. @@ -120,6 +140,7 @@ pub struct RpcError { /// [`HostFault`] recovers the embedded [`Fault`] (present only on the /// `Fault` case) and a stable snake_case label for logs and metrics. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ChainError { /// A shared host fault. #[error(transparent)] @@ -192,6 +213,21 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, Fault> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } /// `nexum:host/logging` - structured runtime logs. @@ -202,14 +238,108 @@ pub trait LoggingHost { fn log(&self, level: Level, message: &str); } -/// Supertrait that bundles the core host interfaces a typical -/// strategy module exercises. Modules that want full host-free -/// integration tests take `&impl Host` (or a generic ``) in -/// their strategy function; `nexum-sdk-test::MockHost` is the -/// in-memory implementation. Strategies that reach a domain extension -/// bound its host trait as well (the CoW SDK's `CowHost`, say). +/// `nexum:host/identity` - host-held accounts and signing. +pub trait IdentityHost { + /// Accounts the host is willing to sign for. Empty means no + /// signing capability. + fn accounts(&self) -> Result, Fault>; + /// Sign `message` with `personal_sign` semantics (the host + /// prepends the `"\x19Ethereum Signed Message:\n"` prefix). + fn sign(&self, account: Address, message: &[u8]) -> Result; + /// Sign a JSON-encoded EIP-712 payload. + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result; +} + +/// One delivered message, mirrored from `nexum:host/types.message` so +/// the [`MessagingHost`] seam stays mockable without naming bindgen +/// types. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + /// Content topic the message arrived on. + pub content_topic: String, + /// Opaque payload bytes. + pub payload: Vec, + /// Delivery timestamp, ms since the Unix epoch, UTC. + pub timestamp: u64, + /// Optional sender identity (protocol-dependent). + pub sender: Option>, +} + +/// `nexum:host/messaging` - publish to and query content topics. The +/// host confines both to the component's `messaging_topics` grant; an +/// off-scope topic fails as [`Fault::Denied`]. +pub trait MessagingHost { + /// Publish a payload to a content topic + /// (`////`). + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; + /// Query historical messages on a topic, window bounded by the + /// optional `start_time` / `end_time` (ms since the Unix epoch, + /// UTC) and `limit`. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault>; +} + +/// `nexum:host/remote-store` - content-addressed blobs and mutable +/// feeds on the decentralized store. +pub trait RemoteStoreHost { + /// Upload raw data; returns its 32-byte content reference. + fn upload(&self, data: &[u8]) -> Result; + /// Download the data behind a content reference. + fn download(&self, reference: B256) -> Result, Fault>; + /// Latest value of the `(owner, topic)` mutable feed, when set. + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault>; + /// Update the host-owned feed at `topic` (the host signs with its + /// configured identity); returns the new chunk's reference. + fn write_feed(&self, topic: B256, data: &[u8]) -> Result; +} + +/// Lift a host-returned account into an [`Address`]. The WIT edge +/// carries it as bytes; any length but 20 is a host-side bug, folded +/// to [`Fault::Internal`]. +pub fn account_from_wire(raw: &[u8]) -> Result { + Address::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "identity returned a {}-byte account, expected 20", + raw.len() + )) + }) +} + +/// Lift a host-returned 65-byte `r || s || v` signature into a +/// [`Signature`]. A malformed buffer is a host-side bug, folded to +/// [`Fault::Internal`]. +pub fn signature_from_wire(raw: &[u8]) -> Result { + Signature::from_raw(raw) + .map_err(|e| Fault::Internal(format!("identity returned a malformed signature: {e}"))) +} + +/// Lift a host-returned content reference into a [`B256`]. Any length +/// but 32 is a host-side bug, folded to [`Fault::Internal`]. +pub fn reference_from_wire(raw: &[u8]) -> Result { + B256::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "remote-store returned a {}-byte reference, expected 32", + raw.len() + )) + }) +} + +/// Supertrait that bundles all six core host interfaces. Modules that +/// want full host-free integration tests take `&impl Host` (or a +/// generic ``) in their strategy function; +/// `nexum-sdk-test::MockHost` is the in-memory implementation. +/// Strategies that exercise fewer interfaces bound exactly those +/// (`H: ChainHost + LoggingHost`, say) so their production adapter +/// only needs the capabilities the module declares; a domain +/// extension's host trait is bounded the same way (the CoW SDK's +/// `CowHost`). /// -/// A blanket impl is provided for any type that implements all three +/// A blanket impl is provided for any type that implements all six /// component traits, so callers do not have to add a redundant /// `impl Host for MyHost {}`. /// @@ -222,8 +352,10 @@ pub trait LoggingHost { /// ``` /// use nexum_sdk::Level; /// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, LocalStoreHost, LoggingHost, +/// ChainError, ChainHost, Fault, Host, IdentityHost, LocalStoreHost, LoggingHost, +/// Message, MessagingHost, RemoteStoreHost, /// }; +/// # use nexum_sdk::prelude::{Address, B256, Signature}; /// /// /// Pure strategy logic - no wit-bindgen calls in here. /// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { @@ -241,34 +373,154 @@ pub trait LoggingHost { /// # Ok("\"0x0\"".into()) /// # } /// # } +/// # impl IdentityHost for StubHost { +/// # fn accounts(&self) -> Result, Fault> { Ok(vec![]) } +/// # fn sign(&self, _: Address, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn sign_typed_data(&self, _: Address, _: &str) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } /// # impl LocalStoreHost for StubHost { /// # fn get(&self, _: &str) -> Result>, Fault> { Ok(None) } /// # fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } /// # fn delete(&self, _: &str) -> Result<(), Fault> { Ok(()) } /// # fn list_keys(&self, _: &str) -> Result, Fault> { Ok(vec![]) } /// # } +/// # impl RemoteStoreHost for StubHost { +/// # fn upload(&self, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn download(&self, _: B256) -> Result, Fault> { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn read_feed(&self, _: Address, _: B256) -> Result>, Fault> { Ok(None) } +/// # fn write_feed(&self, _: B256, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } +/// # impl MessagingHost for StubHost { +/// # fn publish(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } +/// # fn query( +/// # &self, +/// # _: &str, +/// # _: Option, +/// # _: Option, +/// # _: Option, +/// # ) -> Result, Fault> { +/// # Ok(vec![]) +/// # } +/// # } /// # impl LoggingHost for StubHost { /// # fn log(&self, _: Level, _: &str) {} /// # } /// record_block(&StubHost, 1, "block:42").unwrap(); /// ``` -pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} -impl Host for T {} +/// Sealed: the blanket impl is the only implementation. +pub trait Host: + sealed::SealedHost + + ChainHost + + IdentityHost + + LocalStoreHost + + RemoteStoreHost + + MessagingHost + + LoggingHost +{ +} +impl Host for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} #[cfg(test)] mod tests { - use super::{ChainError, Fault, HostFault, RateLimit, RpcError}; + use alloy_primitives::{Address, B256, U256}; + + use super::{ + ChainError, Fault, HostFault, RateLimit, RpcError, account_from_wire, reference_from_wire, + signature_from_wire, + }; #[test] - fn fault_labels_are_stable_snake_case() { + fn local_store_metadata_defaults_derive_from_required_methods() { + use super::LocalStoreHost; + + /// Two fixed rows; only the four required methods are written. + struct TwoRows; + impl LocalStoreHost for TwoRows { + fn get(&self, key: &str) -> Result>, Fault> { + Ok(match key { + "a" => Some(b"abc".to_vec()), + "b" => Some(Vec::new()), + _ => None, + }) + } + fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), Fault> { + Ok(()) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + Ok(["a", "b"] + .iter() + .filter(|k| k.starts_with(prefix)) + .map(|k| (*k).to_owned()) + .collect()) + } + } + + assert!(TwoRows.contains("a").unwrap()); + assert!(!TwoRows.contains("missing").unwrap()); + assert_eq!(TwoRows.len("a").unwrap(), Some(3)); + assert_eq!(TwoRows.len("b").unwrap(), Some(0)); + assert_eq!(TwoRows.len("missing").unwrap(), None); + assert_eq!(TwoRows.count("").unwrap(), 2); + assert_eq!(TwoRows.count("a").unwrap(), 1); + assert_eq!(TwoRows.count("z").unwrap(), 0); + } + + #[test] + fn wire_lifts_accept_exact_lengths() { + let account = account_from_wire(&[0x11; 20]).unwrap(); + assert_eq!(account, Address::from([0x11; 20])); + + let reference = reference_from_wire(&[0x22; 32]).unwrap(); + assert_eq!(reference, B256::from([0x22; 32])); + + let raw = alloy_primitives::Signature::new(U256::from(1), U256::from(2), true).as_bytes(); + let signature = signature_from_wire(&raw).unwrap(); + assert_eq!(signature.r(), U256::from(1)); + assert_eq!(signature.s(), U256::from(2)); + assert!(signature.v()); + } + + #[test] + fn wire_lifts_fold_malformed_buffers_to_internal() { + for fault in [ + account_from_wire(&[0u8; 19]).unwrap_err(), + signature_from_wire(&[0u8; 64]).unwrap_err(), + reference_from_wire(&[0u8; 31]).unwrap_err(), + ] { + assert!(matches!(fault, Fault::Internal(_)), "got {fault:?}"); + } + } + + #[test] + fn fault_labels_match_the_single_source_vocabulary() { + use nexum_world::fault_labels as labels; let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), "unsupported"), - (Fault::Unavailable(String::new()), "unavailable"), - (Fault::Denied(String::new()), "denied"), - (Fault::RateLimited(RateLimit::default()), "rate_limited"), - (Fault::Timeout, "timeout"), - (Fault::InvalidInput(String::new()), "invalid_input"), - (Fault::Internal(String::new()), "internal"), + (Fault::Unsupported(String::new()), labels::UNSUPPORTED), + (Fault::Unavailable(String::new()), labels::UNAVAILABLE), + (Fault::Denied(String::new()), labels::DENIED), + ( + Fault::RateLimited(RateLimit::default()), + labels::RATE_LIMITED, + ), + (Fault::Timeout, labels::TIMEOUT), + (Fault::InvalidInput(String::new()), labels::INVALID_INPUT), + (Fault::Internal(String::new()), labels::INTERNAL), ]; for (fault, label) in cases { assert_eq!(fault.label(), label); @@ -276,6 +528,18 @@ mod tests { } } + #[test] + fn rate_limit_display_carries_the_retry_hint() { + let hinted = Fault::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!(hinted.to_string(), "rate limited, retry after 250 ms"); + assert_eq!( + Fault::RateLimited(RateLimit::default()).to_string(), + "rate limited" + ); + } + #[test] fn host_fault_is_object_safe() { let boxed: Box = Box::new(Fault::Timeout); diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index 7ca0c551..4e35b146 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -56,6 +56,7 @@ impl Default for FetchOptions { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum FetchError { /// The host's `[capabilities.http].allow` list refused the request /// before any connection was made. @@ -95,6 +96,17 @@ pub trait Fetch { } } +/// A shared reference forwards, so middleware can borrow its transport. +impl Fetch for &F { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + (**self).fetch_with(request, options) + } +} + /// [`Fetch`] adapter over the host's wasi:http outgoing handler. /// /// Guest-only glue: the type exists on every target so module diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index bd255a8e..b766be1f 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -17,12 +17,13 @@ //! primitives ([`Address`], [`B256`], [`Bytes`], [`U256`], //! [`keccak256`]). //! -//! - [`host`] - host trait seam ([`Host`] / [`ChainHost`] / -//! [`LocalStoreHost`] / [`LoggingHost`]) plus the host-neutral -//! [`Fault`] vocabulary. Modules that want host-free tests structure -//! their strategy logic against these traits and slot in the -//! `nexum-sdk-test` mocks. See the host module docs for the -//! wit-bindgen adapter pattern. +//! - [`host`] - host trait seam: [`Host`] bundling all six core +//! interfaces ([`ChainHost`] / [`IdentityHost`] / [`LocalStoreHost`] +//! / [`RemoteStoreHost`] / [`MessagingHost`] / [`LoggingHost`]) plus +//! the host-neutral [`Fault`] vocabulary. Modules that want +//! host-free tests structure their strategy logic against these +//! traits and slot in the `nexum-sdk-test` mocks. See the host +//! module docs for the wit-bindgen adapter pattern. //! //! - [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) - //! generates the per-module `WitBindgenHost` adapter over the @@ -39,7 +40,10 @@ //! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the //! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! -//! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], +//! - [`chain`] - typed chain access: [`Chain`] / [`ChainId`] newtypes, +//! the closed [`ChainMethod`] read surface, and the alloy provider +//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus +//! `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader //! ([`read_latest_answer`]). //! @@ -47,6 +51,9 @@ //! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind //! macro fills to rebuild it from the wire record. //! +//! - [`status_body`] - decoder for the opaque versioned status body an +//! `intent-status` event carries ([`StatusBody`]). +//! //! - [`config`] - `(key, value)` config-table lookups and decimal //! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). //! @@ -81,7 +88,10 @@ //! [`keccak256`]: alloy_primitives::keccak256 //! [`Host`]: host::Host //! [`ChainHost`]: host::ChainHost +//! [`IdentityHost`]: host::IdentityHost //! [`LocalStoreHost`]: host::LocalStoreHost +//! [`RemoteStoreHost`]: host::RemoteStoreHost +//! [`MessagingHost`]: host::MessagingHost //! [`LoggingHost`]: host::LoggingHost //! [`Fault`]: host::Fault //! [`WatchSet`]: keeper::WatchSet @@ -90,11 +100,18 @@ //! [`ConditionalSource`]: keeper::ConditionalSource //! [`Retrier`]: keeper::Retrier //! [`RetryAction`]: keeper::RetryAction +//! [`Chain`]: chain::Chain +//! [`ChainId`]: chain::ChainId +//! [`ChainMethod`]: chain::ChainMethod +//! [`HostTransport`]: chain::HostTransport +//! [`provider`]: chain::ProviderHost::provider +//! [`block_on`]: chain::block_on //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer //! [`Log`]: events::Log //! [`ChainLogParts`]: events::ChainLogParts +//! [`StatusBody`]: status_body::StatusBody //! [`get_required`]: config::get_required //! [`get_optional`]: config::get_optional //! [`scale_decimal`]: config::scale_decimal @@ -111,8 +128,8 @@ /// Generate the per-cdylib module glue (wit-bindgen, host adapter, /// `Guest`/`on-event` dispatch, `export!`) from an `impl` block of named -/// handlers. See [`nexum_macros::module`]. -pub use nexum_macros::module; +/// handlers. See [`nexum_module_macros::module`]. +pub use nexum_module_macros::module; pub mod address; pub mod chain; @@ -125,6 +142,10 @@ pub mod prelude; pub mod tracing; pub mod wit_bindgen_macro; +/// The opaque status-body codec: decode an `intent-status` event's +/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +pub use nexum_status_body as status_body; + /// The level vocabulary for every SDK log path: the host logging trait, /// the guest tracing facade sink, and the module mocks all speak /// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs index ef4bcc1f..b72a6b04 100644 --- a/crates/nexum-sdk/src/prelude.rs +++ b/crates/nexum-sdk/src/prelude.rs @@ -7,4 +7,4 @@ //! crate (one `wit_bindgen::generate!` call per cdylib). Domain SDKs //! ship their own prelude for their protocol surface. -pub use alloy_primitives::{Address, B256, Bytes, U256, address, b256, hex, keccak256}; +pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 25a91376..586df413 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -3,17 +3,16 @@ //! //! Before this macro existed, each module hand-rolled ~80 lines of //! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus -//! `convert_chain_err` / `convert_fault` / `sdk_fault_into_wit` / -//! `convert_level`. The code differed across modules in zero places -//! that were not bugs. +//! impls plus the fault, chain-error, and level conversions. The code +//! differed across modules in zero places that were not bugs. //! //! The adapter is capability-selected: the `caps: [...]` form emits //! only the pieces backed by the module's declared capabilities //! (`#[nexum_sdk::module]` invokes it this way, matching the //! per-module world it generates), while the zero-argument form emits -//! the full `chain, local_store, logging` set for modules that -//! compile against a blanket world with every core import present. +//! the full six-interface set (`chain, identity, local_store, +//! remote_store, messaging, logging`) for modules that compile +//! against a blanket world with every core import present. //! Either way the call site must already have the wit-bindgen output //! for its world in scope (`wit_bindgen::generate!({ ..., //! generate_all })`): each selected piece resolves its @@ -32,31 +31,38 @@ //! // or, capability-selected: //! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); //! -//! // `WitBindgenHost`, `convert_fault`, and `sdk_fault_into_wit` are -//! // now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `LocalStoreHost` impl (local_store), and -//! // `convert_level`, `HostLogSink`, and `install_tracing` -//! // (logging), with the wit-bindgen and SDK types tied together -//! // through identifier resolution. Call `install_tracing()` once at -//! // the top of `Guest::init` to route `tracing::info!(...)` to the -//! // host. A `From for nexum_sdk::events::Log` is also -//! // emitted so `on_event` maps a chain-logs batch straight to -//! // `Vec`. +//! // `WitBindgenHost` and the `Fault` `From` impls (both directions) +//! // are now in scope, plus per selected capability: `convert_chain_err` +//! // (chain), the `IdentityHost`, `LocalStoreHost`, `RemoteStoreHost`, +//! // and `MessagingHost` impls (identity / local_store / remote_store / +//! // messaging), and the `Level` `From` impl, `HostLogSink`, and +//! // `install_tracing` (logging), with the wit-bindgen and SDK types +//! // tied together through identifier resolution. Call +//! // `install_tracing()` once at the top of `Guest::init` to route +//! // `tracing::info!(...)` to the host. `From` impls for +//! // `nexum_sdk::events::Log` and `nexum_sdk::host::Message` are also +//! // emitted so `on_event` maps chain-log batches and messages +//! // straight to the SDK types. //! ``` /// Generate `WitBindgenHost` + the `*Host` trait impls + the error / -/// level converters for the selected capabilities. See module docs. +/// level `From` impls for the selected capabilities. See module docs. +/// +/// The fault and level conversions are `From` impls: orphan-legal here +/// because the wit-bindgen types are local to the expanding cdylib. /// /// Macro hygiene note: `macro_rules!` is not hygienic for type names /// or function items, so the names `WitBindgenHost`, `convert_chain_err`, -/// `convert_fault`, `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, -/// and `install_tracing` are intentionally visible in the caller's scope. +/// `HostLogSink`, and `install_tracing` are intentionally visible in the +/// caller's scope. #[macro_export] macro_rules! bind_host_via_wit_bindgen { // Blanket-world form: every core interface is in scope, emit the // full adapter. () => { - $crate::bind_host_via_wit_bindgen!(caps: [chain, local_store, logging]); + $crate::bind_host_via_wit_bindgen!( + caps: [chain, identity, local_store, remote_store, messaging, logging] + ); }; // Capability-selected form: the base pieces (which need only the // always-present `nexum:host/types`) plus one block per listed @@ -71,46 +77,51 @@ macro_rules! bind_host_via_wit_bindgen { /// Lift the wit-bindgen `types.fault` (per-cdylib) into the /// SDK's `Fault`. Exhaustive on the seven-case vocabulary; the /// `rate-limited` backoff record maps field for field. - fn convert_fault(f: nexum::host::types::Fault) -> $crate::host::Fault { - match f { - nexum::host::types::Fault::Unsupported(s) => $crate::host::Fault::Unsupported(s), - nexum::host::types::Fault::Unavailable(s) => $crate::host::Fault::Unavailable(s), - nexum::host::types::Fault::Denied(s) => $crate::host::Fault::Denied(s), - nexum::host::types::Fault::RateLimited(rl) => { - $crate::host::Fault::RateLimited($crate::host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }) + impl ::core::convert::From for $crate::host::Fault { + fn from(f: nexum::host::types::Fault) -> Self { + match f { + nexum::host::types::Fault::Unsupported(s) => Self::Unsupported(s), + nexum::host::types::Fault::Unavailable(s) => Self::Unavailable(s), + nexum::host::types::Fault::Denied(s) => Self::Denied(s), + nexum::host::types::Fault::RateLimited(rl) => { + Self::RateLimited($crate::host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + nexum::host::types::Fault::Timeout => Self::Timeout, + nexum::host::types::Fault::InvalidInput(s) => Self::InvalidInput(s), + nexum::host::types::Fault::Internal(s) => Self::Internal(s), } - nexum::host::types::Fault::Timeout => $crate::host::Fault::Timeout, - nexum::host::types::Fault::InvalidInput(s) => $crate::host::Fault::InvalidInput(s), - nexum::host::types::Fault::Internal(s) => $crate::host::Fault::Internal(s), } } - /// Reverse direction: lower the SDK [`Fault`]( - /// $crate::host::Fault) back into the per-cdylib wit-bindgen - /// `Fault` so `Guest::init` / `Guest::on_event` can return what - /// the export signature expects. + /// Reverse direction: lower the SDK `Fault` back into the + /// per-cdylib wit-bindgen `Fault` so `Guest::init` / + /// `Guest::on_event` can return what the export signature + /// expects (`?` applies it). /// /// Carries a wildcard arm because `$crate::host::Fault` is /// `#[non_exhaustive]`: a future SDK-side case must compile in /// module crates without source changes. Falls back to /// `internal` carrying the `Display` detail. - fn sdk_fault_into_wit(f: $crate::host::Fault) -> nexum::host::types::Fault { - use nexum::host::types::{Fault as WitFault, RateLimit as WitRateLimit}; - match f { - $crate::host::Fault::Unsupported(s) => WitFault::Unsupported(s), - $crate::host::Fault::Unavailable(s) => WitFault::Unavailable(s), - $crate::host::Fault::Denied(s) => WitFault::Denied(s), - $crate::host::Fault::RateLimited(rl) => WitFault::RateLimited(WitRateLimit { - retry_after_ms: rl.retry_after_ms, - }), - $crate::host::Fault::Timeout => WitFault::Timeout, - $crate::host::Fault::InvalidInput(s) => WitFault::InvalidInput(s), - $crate::host::Fault::Internal(s) => WitFault::Internal(s), - // `$crate::host::Fault` is `#[non_exhaustive]`; a future - // SDK case lands here as `internal`. - other => WitFault::Internal(::std::string::ToString::to_string(&other)), + impl ::core::convert::From<$crate::host::Fault> for nexum::host::types::Fault { + fn from(f: $crate::host::Fault) -> Self { + match f { + $crate::host::Fault::Unsupported(s) => Self::Unsupported(s), + $crate::host::Fault::Unavailable(s) => Self::Unavailable(s), + $crate::host::Fault::Denied(s) => Self::Denied(s), + $crate::host::Fault::RateLimited(rl) => { + Self::RateLimited(nexum::host::types::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + $crate::host::Fault::Timeout => Self::Timeout, + $crate::host::Fault::InvalidInput(s) => Self::InvalidInput(s), + $crate::host::Fault::Internal(s) => Self::Internal(s), + // `$crate::host::Fault` is `#[non_exhaustive]`; a + // future SDK case lands here as `internal`. + other => Self::Internal(::std::string::ToString::to_string(&other)), + } } } @@ -136,6 +147,20 @@ macro_rules! bind_host_via_wit_bindgen { } } + /// Rebuild the SDK message from the per-cdylib wit-bindgen + /// `message` record, so `on_event` maps a delivery straight to + /// `nexum_sdk::host::Message`. + impl ::core::convert::From for $crate::host::Message { + fn from(message: nexum::host::types::Message) -> Self { + Self { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } + } + $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* }; } @@ -163,7 +188,7 @@ macro_rules! __bind_host_cap_via_wit_bindgen { fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { match e { nexum::host::chain::ChainError::Fault(f) => { - $crate::host::ChainError::Fault(convert_fault(f)) + $crate::host::ChainError::Fault(::core::convert::Into::into(f)) } nexum::host::chain::ChainError::Rpc(r) => { $crate::host::ChainError::Rpc($crate::host::RpcError { @@ -175,6 +200,40 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } }; + (identity) => { + impl $crate::host::IdentityHost for WitBindgenHost { + fn accounts( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec<$crate::prelude::Address>, + $crate::host::Fault, + > { + nexum::host::identity::accounts() + .map_err($crate::host::Fault::from)? + .iter() + .map(|account| $crate::host::account_from_wire(account)) + .collect() + } + fn sign( + &self, + account: $crate::prelude::Address, + message: &[u8], + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign(account.as_slice(), message) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + fn sign_typed_data( + &self, + account: $crate::prelude::Address, + typed_data: &str, + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign_typed_data(account.as_slice(), typed_data) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + } + }; (local_store) => { impl $crate::host::LocalStoreHost for WitBindgenHost { fn get( @@ -184,31 +243,112 @@ macro_rules! __bind_host_cap_via_wit_bindgen { ::core::option::Option<::std::vec::Vec>, $crate::host::Fault, > { - nexum::host::local_store::get(key).map_err(convert_fault) + nexum::host::local_store::get(key).map_err($crate::host::Fault::from) } fn set( &self, key: &str, value: &[u8], ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err(convert_fault) + nexum::host::local_store::set(key, value).map_err($crate::host::Fault::from) } fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err(convert_fault) + nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) } fn list_keys( &self, prefix: &str, ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> { - nexum::host::local_store::list_keys(prefix).map_err(convert_fault) + nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) + } + fn contains(&self, key: &str) -> ::core::result::Result { + nexum::host::local_store::contains(key).map_err($crate::host::Fault::from) + } + fn len( + &self, + key: &str, + ) -> ::core::result::Result<::core::option::Option, $crate::host::Fault> { + nexum::host::local_store::len(key).map_err($crate::host::Fault::from) + } + fn count(&self, prefix: &str) -> ::core::result::Result { + nexum::host::local_store::count(prefix).map_err($crate::host::Fault::from) + } + } + }; + (remote_store) => { + impl $crate::host::RemoteStoreHost for WitBindgenHost { + fn upload( + &self, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = + nexum::host::remote_store::upload(data).map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + fn download( + &self, + reference: $crate::prelude::B256, + ) -> ::core::result::Result<::std::vec::Vec, $crate::host::Fault> { + nexum::host::remote_store::download(reference.as_slice()) + .map_err($crate::host::Fault::from) + } + fn read_feed( + &self, + owner: $crate::prelude::Address, + topic: $crate::prelude::B256, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::remote_store::read_feed(owner.as_slice(), topic.as_slice()) + .map_err($crate::host::Fault::from) + } + fn write_feed( + &self, + topic: $crate::prelude::B256, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = nexum::host::remote_store::write_feed(topic.as_slice(), data) + .map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + } + }; + (messaging) => { + impl $crate::host::MessagingHost for WitBindgenHost { + fn publish( + &self, + content_topic: &str, + payload: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::messaging::publish(content_topic, payload) + .map_err($crate::host::Fault::from) + } + fn query( + &self, + content_topic: &str, + start_time: ::core::option::Option, + end_time: ::core::option::Option, + limit: ::core::option::Option, + ) -> ::core::result::Result<::std::vec::Vec<$crate::host::Message>, $crate::host::Fault> + { + let messages = + nexum::host::messaging::query(content_topic, start_time, end_time, limit) + .map_err($crate::host::Fault::from)?; + ::core::result::Result::Ok( + messages + .into_iter() + .map(::core::convert::Into::into) + .collect(), + ) } } }; (logging) => { impl $crate::host::LoggingHost for WitBindgenHost { fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); + nexum::host::logging::log(nexum::host::logging::Level::from(level), message); } } @@ -216,18 +356,19 @@ macro_rules! __bind_host_cap_via_wit_bindgen { /// `logging::Level` wire enum. `Level` is a set of associated /// consts, not a matchable enum, so compare rather than match; /// the five tiers are total, so the final arm is `Trace`. - fn convert_level(level: $crate::Level) -> nexum::host::logging::Level { - use $crate::Level; - if level == Level::ERROR { - nexum::host::logging::Level::Error - } else if level == Level::WARN { - nexum::host::logging::Level::Warn - } else if level == Level::INFO { - nexum::host::logging::Level::Info - } else if level == Level::DEBUG { - nexum::host::logging::Level::Debug - } else { - nexum::host::logging::Level::Trace + impl ::core::convert::From<$crate::Level> for nexum::host::logging::Level { + fn from(level: $crate::Level) -> Self { + if level == $crate::Level::ERROR { + Self::Error + } else if level == $crate::Level::WARN { + Self::Warn + } else if level == $crate::Level::INFO { + Self::Info + } else if level == $crate::Level::DEBUG { + Self::Debug + } else { + Self::Trace + } } } diff --git a/crates/nexum-status-body/Cargo.toml b/crates/nexum-status-body/Cargo.toml new file mode 100644 index 00000000..cd4361b3 --- /dev/null +++ b/crates/nexum-status-body/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nexum-status-body" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Versioned codec for the opaque status body the host event stream carries: a leading version tag, then that version's borsh payload; unknown tags fail closed." + +[lints] +workspace = true + +[dependencies] +borsh.workspace = true +thiserror.workspace = true diff --git a/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs new file mode 100644 index 00000000..2bd9c009 --- /dev/null +++ b/crates/nexum-status-body/src/lib.rs @@ -0,0 +1,248 @@ +//! The versioned opaque status-body codec. +//! +//! The host `event` stream carries an intent-status transition as opaque +//! bytes: a leading `u8` version tag, then that version's borsh payload. +//! The emitter encodes, the keeper decodes, the host never inspects the +//! bytes. An unknown tag fails closed and a body is never empty. +//! +//! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the +//! borsh `option` encodings of `proof` and `reason`. + +#![warn(missing_docs)] + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// Wire tag of the v1 payload. +pub const VERSION_V1: u8 = 1; + +/// Where an intent is in its life at the venue. The borsh discriminant +/// is the wire form: append new states, never reorder. +#[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntentStatus { + /// Accepted for processing but not yet live at the venue. + Pending, + /// Live at the venue and eligible for settlement. + Open, + /// Settled. + Fulfilled, + /// Withdrawn or terminally refused before settlement. + Cancelled, + /// Reached its expiry without settling. + Expired, +} + +/// Why an intent failed terminally, as reported by the venue. +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct FailReason { + /// Venue-scoped machine-readable code, stable enough to match on. + pub code: String, + /// Human-readable detail for logs and the consent surface. + pub detail: String, +} + +/// One decoded status body. +/// +/// `proof` is display-grade venue bytes (for an EVM venue, typically +/// the settlement transaction hash). There is no `failed` status: a +/// venue-reported terminal failure reads as a non-[`Fulfilled`] +/// terminal `status` plus a `reason`. +/// +/// [`Fulfilled`]: IntentStatus::Fulfilled +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct StatusBody { + /// Where the intent is in its life at the venue. + pub status: IntentStatus, + /// Venue-defined settlement proof. + pub proof: Option>, + /// Terminal-failure reason. + pub reason: Option, +} + +impl StatusBody { + /// Encode as the version tag plus the borsh payload. Never empty: + /// at minimum the tag and the status discriminant. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = vec![VERSION_V1]; + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode, failing typedly on an empty body, an unknown version + /// tag (fail-closed), or a payload that does not parse as the + /// tagged version (including trailing bytes). + pub fn decode(bytes: &[u8]) -> Result { + match bytes { + [] => Err(DecodeError::Empty), + [VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| DecodeError::Malformed { + version: VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(DecodeError::UnknownVersion { version: *version }), + } + } +} + +/// A payload failed to encode. Only reachable when a field's length +/// exceeds the wire's `u32` bound. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("status body failed to encode: {detail}")] +pub struct EncodeError { + /// Borsh's encode failure detail. + pub detail: String, +} + +/// Why bytes failed to decode as a status body. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum DecodeError { + /// No bytes at all: not even a version tag. + #[error("empty status body: missing the version tag")] + Empty, + /// The version tag names no published version. Fail-closed: a + /// keeper never guesses at a future layout. + #[error("unknown status-body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(status: IntentStatus) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + + #[test] + fn golden_minimal_open() { + let encoded = body(IntentStatus::Open).encode().expect("encode"); + assert_eq!(encoded, [VERSION_V1, 1, 0, 0]); + } + + #[test] + fn golden_fulfilled_with_proof() { + let encoded = StatusBody { + status: IntentStatus::Fulfilled, + proof: Some(vec![0xaa, 0xbb]), + reason: None, + } + .encode() + .expect("encode"); + assert_eq!(encoded, [VERSION_V1, 2, 1, 2, 0, 0, 0, 0xaa, 0xbb, 0]); + } + + #[test] + fn golden_terminal_failure() { + let encoded = StatusBody { + status: IntentStatus::Cancelled, + proof: None, + reason: Some(FailReason { + code: "oc".into(), + detail: "od".into(), + }), + } + .encode() + .expect("encode"); + assert_eq!( + encoded, + [ + VERSION_V1, 3, 0, 1, 2, 0, 0, 0, b'o', b'c', 2, 0, 0, 0, b'o', b'd' + ], + ); + } + + #[test] + fn round_trips_every_status() { + for status in [ + IntentStatus::Pending, + IntentStatus::Open, + IntentStatus::Fulfilled, + IntentStatus::Cancelled, + IntentStatus::Expired, + ] { + let original = StatusBody { + status, + proof: Some(b"proof".to_vec()), + reason: Some(FailReason { + code: "code".into(), + detail: "detail".into(), + }), + }; + let decoded = StatusBody::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + } + + #[test] + fn a_body_is_never_empty() { + let encoded = body(IntentStatus::Pending).encode().expect("encode"); + assert!(encoded.len() >= 2, "at minimum the tag and the status"); + } + + #[test] + fn empty_bytes_fail_typedly() { + assert_eq!(StatusBody::decode(&[]), Err(DecodeError::Empty)); + } + + #[test] + fn unknown_version_fails_closed() { + assert_eq!( + StatusBody::decode(&[2, 1, 0, 0]), + Err(DecodeError::UnknownVersion { version: 2 }), + ); + } + + #[test] + fn unknown_status_discriminant_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 5, 0, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn trailing_bytes_are_malformed() { + let mut encoded = body(IntentStatus::Open).encode().expect("encode"); + encoded.push(0); + assert!(matches!( + StatusBody::decode(&encoded), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_payload_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 1, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } +} diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs deleted file mode 100644 index 3e88fff2..00000000 --- a/crates/nexum-venue-sdk/src/bindings.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Guest bindings for the `nexum:adapter/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 -//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport -//! wrappers, and the intent client core are all expressed over these -//! 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`. - -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", - "../../wit/nexum-host", - "../../wit/nexum-adapter", - ], - world: "nexum:adapter/venue-adapter", - generate_all, - pub_export_macro: true, - export_macro_name: "__export_venue_adapter_world", - default_bindings_module: "nexum_venue_sdk::bindings", - additional_derives: [PartialEq], -}); diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs deleted file mode 100644 index edfc8dd7..00000000 --- a/crates/nexum-venue-sdk/src/client.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The typed intent client core: [`IntentClient`] over the byte-level -//! [`IntentPool`] seam. -//! -//! The pool 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 -//! 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 -//! (an in-process adapter works directly), and the typed layer above is -//! shared by both. - -use strum::IntoStaticStr; - -use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; - -/// Byte-level access to the strategy-facing `nexum:intent/pool` -/// interface, venue named per call as on the wire. -pub trait IntentPool { - /// Submit an opaque intent body to the named venue. - fn submit(&self, venue: &str, body: Vec) -> Result; - - /// Report where a previously submitted intent is in its life. - fn status(&self, venue: &str, 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>; -} - -/// A typed intent client bound to one venue: encodes an [`IntentBody`] -/// to wire bytes and forwards through the [`IntentPool`] seam. -#[derive(Clone, Debug)] -pub struct IntentClient

{ - pool: 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 { - Self { - pool, - venue: venue.into(), - } - } - - /// The venue id every call on this client routes to. - pub fn venue(&self) -> &str { - &self.venue - } - - /// 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 - .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 - .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 - .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). -/// -/// `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. - #[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 - /// `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 deleted file mode 100644 index 9a0e3a1e..00000000 --- a/crates/nexum-venue-sdk/src/faults.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! 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. -//! -//! Every conversion here is lossy only downward (a structured case folds -//! to a payload-bearing string case, never the reverse), so `?` in an -//! adapter always preserves the most structured form the target -//! vocabulary can carry. - -use nexum_sdk::host; - -use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; -use crate::{Fault, VenueError}; - -/// 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. -pub fn fault_into_sdk(fault: Fault) -> host::Fault { - match fault { - Fault::Unsupported(s) => host::Fault::Unsupported(s), - Fault::Unavailable(s) => host::Fault::Unavailable(s), - Fault::Denied(s) => host::Fault::Denied(s), - Fault::RateLimited(rl) => host::Fault::RateLimited(host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }), - Fault::Timeout => host::Fault::Timeout, - Fault::InvalidInput(s) => host::Fault::InvalidInput(s), - Fault::Internal(s) => host::Fault::Internal(s), - } -} - -/// Lower the SDK-neutral fault back into the wire fault an adapter's -/// `init` returns, so a helper's `host::Fault` propagates with `?`. -/// -/// Carries a wildcard arm because `host::Fault` is `#[non_exhaustive]`: -/// a future SDK case lands as `internal` carrying its `Display` detail. -impl From for Fault { - fn from(fault: host::Fault) -> Self { - match fault { - host::Fault::Unsupported(s) => Fault::Unsupported(s), - host::Fault::Unavailable(s) => Fault::Unavailable(s), - host::Fault::Denied(s) => Fault::Denied(s), - host::Fault::RateLimited(rl) => Fault::RateLimited(WireRateLimit { - retry_after_ms: rl.retry_after_ms, - }), - host::Fault::Timeout => Fault::Timeout, - host::Fault::InvalidInput(s) => Fault::InvalidInput(s), - host::Fault::Internal(s) => Fault::Internal(s), - other => Fault::Internal(other.to_string()), - } - } -} - -/// 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. -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()), - } - } -} - -/// 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`. -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(_) => { - VenueError::Unavailable(err.to_string()) - } - FetchError::InvalidRequest(_) => VenueError::InternalError(err.to_string()), - } - } -} - -#[cfg(test)] -mod tests { - use nexum_sdk::host; - - use crate::{Fault, VenueError}; - - #[test] - fn wire_fault_round_trips_through_sdk() { - let cases = [ - Fault::Unsupported("u".into()), - Fault::Unavailable("u".into()), - Fault::Denied("d".into()), - Fault::RateLimited(crate::bindings::nexum::host::types::RateLimit { - retry_after_ms: Some(250), - }), - Fault::Timeout, - Fault::InvalidInput("i".into()), - Fault::Internal("i".into()), - ]; - for case in cases { - let there = super::fault_into_sdk(case.clone()); - assert_eq!(Fault::from(there), case); - } - } - - #[test] - fn transport_fault_folds_to_venue_error_by_shape() { - assert_eq!( - VenueError::from(host::Fault::Denied("nope".into())), - VenueError::Denied("nope".into()), - ); - assert!(matches!( - VenueError::from(host::Fault::Timeout), - VenueError::Unavailable(_) - )); - assert!(matches!( - VenueError::from(host::Fault::InvalidInput("bug".into())), - VenueError::InternalError(_) - )); - } - - #[test] - fn fetch_error_folds_to_venue_error_by_shape() { - use nexum_sdk::http::FetchError; - assert!(matches!( - VenueError::from(FetchError::Denied), - VenueError::Denied(_) - )); - assert!(matches!( - VenueError::from(FetchError::Transport("reset".into())), - VenueError::Unavailable(_) - )); - assert!(matches!( - VenueError::from(FetchError::InvalidRequest("bad url".into())), - VenueError::InternalError(_) - )); - } -} diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs deleted file mode 100644 index 45a5dc43..00000000 --- a/crates/nexum-venue-sdk/src/lib.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! # nexum-venue-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. -//! -//! ## What lives here -//! -//! - [`VenueAdapter`] - the trait mirroring the world's export face -//! (`init` plus the four intent functions), and -//! [`export_venue_adapter!`] which turns an impl into the component's -//! export glue. -//! -//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh -//! codec over the outer per-venue version enum. The wire form is a -//! 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 -//! [`IntentPool`] 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: -//! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] -//! seam (plus batch), [`HostMessaging`](transport::HostMessaging) -//! behind [`MessagingHost`](transport::MessagingHost), and the -//! wasi:http surface re-exported as [`transport::http`]. -//! -//! - [`faults`] - the conversions that make `?` work across the wire -//! fault, the SDK-neutral fault, and [`VenueError`]. -//! -//! ## Why the bindgen lives in this crate -//! -//! Unlike event modules (per-cdylib `wit_bindgen::generate!`), the -//! adapter world's bindings generate once, in [`bindings`]: the trait, -//! wrappers, and client core are all typed over them, and the export -//! macro reaches back in via `with_types_in`. An adapter crate therefore -//! needs no wit-bindgen dependency and no world knowledge of its own. -//! -//! [`ChainHost`]: nexum_sdk::host::ChainHost -//! [`IntentClient`]: client::IntentClient -//! [`IntentPool`]: client::IntentPool - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -#[allow(missing_docs)] -pub mod bindings; - -pub mod adapter; -pub mod body; -pub mod client; -pub mod faults; -pub mod transport; - -pub use adapter::VenueAdapter; -pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, IntentPool}; -/// Derive [`IntentBody`] on the outer per-venue version enum. See -/// [`nexum_macros::IntentBody`]. -pub use nexum_macros::IntentBody; -/// Emit the per-cdylib export glue and per-component world for a venue -/// adapter. Apply to an inherent `impl` of the adapter face -/// (`derive_header`, `submit`, `status`, `cancel`, plus an optional -/// `init`); the built component imports exactly the manifest's declared -/// scoped transport. See [`nexum_macros::venue`]. -/// -/// The self-contained per-cdylib alternative to -/// [`export_venue_adapter!`]: that macro exports through this crate's -/// shared blanket-world bindgen (chain and messaging always imported, -/// relying on toolchain elision), whereas `#[venue]` derives a narrowed -/// world from the manifest and generates its own bindings. -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, -}; -/// The value-flow vocabulary intent headers are expressed in. -pub use bindings::nexum::value_flow::types as value_flow; - -/// The wire config table (`nexum:host/types.config`) `init` receives. -pub use bindings::nexum::host::types::Config; -/// The wire fault (`nexum:host/types.fault`) `init` returns. Transport -/// seams speak the SDK-neutral [`nexum_sdk::host::Fault`] instead; the -/// [`faults`] conversions bridge the two. -pub use bindings::nexum::host::types::Fault; diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs deleted file mode 100644 index c6afd9ca..00000000 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Acceptance surface for the venue SDK: a hand-written adapter -//! compiles against [`VenueAdapter`], exports through -//! `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. - -use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; -use nexum_venue_sdk::{ - AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, - IntentPool, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, -}; - -/// First published body version: a fixed-price quote. -#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] -struct QuoteV1 { - amount_wei: u64, - memo: String, -} - -/// Second published version: v1 plus an expiry. -#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] -struct QuoteV2 { - amount_wei: u64, - memo: String, - valid_until_ms: Option, -} - -/// The outer per-venue version enum: the schema the demo venue -/// publishes. Tag order is the schema; versions append. -#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] -enum QuoteBody { - V1(QuoteV1), - V2(QuoteV2), -} - -/// The hand-written adapter: enough venue to exercise every trait -/// function without a live transport. -struct DemoAdapter; - -/// The receipt the demo venue issues for every accepted intent. -const RECEIPT: [u8; 4] = [0xA5, 0x5A, 0xC3, 0x3C]; - -impl DemoAdapter { - fn decode(body: &[u8]) -> Result<(u64, Option), VenueError> { - // `BodyError` converts through `?`: malformed and - // unknown-version bodies surface as `invalid-body`. - let body = QuoteBody::from_bytes(body)?; - Ok(match body { - QuoteBody::V1(quote) => (quote.amount_wei, None), - QuoteBody::V2(quote) => (quote.amount_wei, quote.valid_until_ms), - }) - } -} - -impl VenueAdapter for DemoAdapter { - fn init(_config: Config) -> Result<(), Fault> { - Ok(()) - } - - fn derive_header(body: Vec) -> Result { - let (amount_wei, valid_until) = Self::decode(&body)?; - Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), - amount: amount_wei.to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until, - settlement: Settlement::EvmChain(1), - authorisation: AuthScheme::Eip712, - }) - } - - fn submit(body: Vec) -> Result { - Self::decode(&body)?; - Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) - } - - fn status(receipt: Vec) -> Result { - if receipt == RECEIPT { - Ok(IntentStatus::Open) - } else { - Err(VenueError::InvalidReceipt) - } - } - - fn cancel(receipt: Vec) -> Result<(), VenueError> { - Self::status(receipt).map(|_| ()) - } -} - -// The acceptance gate proper: the hand-written adapter exports as the -// 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; - -impl IntentPool for InProcessPool { - fn submit(&self, venue: &str, body: Vec) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); - } - DemoAdapter::submit(body) - } - - fn status(&self, venue: &str, receipt: &[u8]) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); - } - DemoAdapter::status(receipt.to_vec()) - } - - fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError> { - if venue != "demo" { - return Err(VenueError::UnknownVenue); - } - DemoAdapter::cancel(receipt.to_vec()) - } -} - -fn v2_body() -> QuoteBody { - QuoteBody::V2(QuoteV2 { - amount_wei: 1_000_000, - memo: "two coffees".to_owned(), - valid_until_ms: Some(1_700_000_000_000), - }) -} - -#[test] -fn versioned_body_round_trips_through_the_derive() { - for body in [ - QuoteBody::V1(QuoteV1 { - amount_wei: 42, - memo: "one".to_owned(), - }), - v2_body(), - ] { - let bytes = body.to_bytes().expect("derived payloads encode"); - assert_eq!(QuoteBody::from_bytes(&bytes).unwrap(), body); - } -} - -#[test] -fn wire_tag_is_the_declaration_index() { - let v1 = QuoteBody::V1(QuoteV1 { - amount_wei: 1, - memo: String::new(), - }) - .to_bytes() - .unwrap(); - let v2 = v2_body().to_bytes().unwrap(); - assert_eq!(v1[0], 0); - assert_eq!(v2[0], 1); -} - -#[test] -fn unknown_version_fails_typedly() { - let mut bytes = v2_body().to_bytes().unwrap(); - bytes[0] = 9; - assert_eq!( - QuoteBody::from_bytes(&bytes), - Err(BodyError::UnknownVersion { version: 9 }) - ); -} - -#[test] -fn empty_and_malformed_bodies_fail_typedly() { - assert_eq!(QuoteBody::from_bytes(&[]), Err(BodyError::Empty)); - - // A known tag with a truncated payload. - let mut bytes = v2_body().to_bytes().unwrap(); - bytes.truncate(bytes.len() - 1); - assert!(matches!( - QuoteBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 1, .. }) - )); - - // A known tag with trailing bytes: borsh requires full consumption. - let mut bytes = v2_body().to_bytes().unwrap(); - bytes.push(0); - assert!(matches!( - QuoteBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 1, .. }) - )); -} - -#[test] -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.authorisation, AuthScheme::Eip712); -} - -#[test] -fn adapter_reports_an_unknown_version_as_invalid_body() { - let mut bytes = v2_body().to_bytes().unwrap(); - bytes[0] = 7; - let err = DemoAdapter::derive_header(bytes).unwrap_err(); - match err { - VenueError::InvalidBody(detail) => assert!(detail.contains("unknown body version 7")), - other => panic!("expected invalid-body, got {other:?}"), - } -} - -#[test] -fn typed_client_round_trips_through_the_pool_seam() { - let client = IntentClient::new(InProcessPool, "demo"); - - let outcome = client.submit(&v2_body()).unwrap(); - let SubmitOutcome::Accepted(receipt) = outcome else { - panic!("demo venue always accepts"); - }; - assert_eq!(receipt, RECEIPT.to_vec()); - - assert_eq!(client.status(&receipt).unwrap(), IntentStatus::Open); - client.cancel(&receipt).unwrap(); - - assert!(matches!( - client.status(&[0, 1]).unwrap_err(), - ClientError::Venue(VenueError::InvalidReceipt) - )); -} - -#[test] -fn unbound_venue_is_unknown_at_the_pool() { - let client = IntentClient::new(InProcessPool, "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 deleted file mode 100644 index 980751be..00000000 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "venue": "nexum-venue-test/reference", - "goldens": [ - { - "name": "v1-small", - "body": "00010000000000000002000000676d", - "header": { - "gives": [ - { - "asset": { - "native-token": { - "evm-chain": 1 - } - }, - "amount": "01" - } - ], - "wants": [], - "settlement": { - "evm-chain": 1 - }, - "authorisation": "eip712" - }, - "notes": "gives chain-1 native token, minimal big-endian amount" - }, - { - "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, - "settlement": { - "evm-chain": 1 - }, - "authorisation": "eip712" - }, - "notes": "v2 adds the expiry and an erc20 want at the recipient address" - } - ] -} diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml new file mode 100644 index 00000000..088d377a --- /dev/null +++ b/crates/nexum-world/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "nexum-world" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Per-module WIT world synthesis: the core capability table, registry-driven extension rows, manifest parsing, and crate-local WIT package resolution." + +[lints] +workspace = true + +[dependencies] +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs new file mode 100644 index 00000000..5ca9e1ca --- /dev/null +++ b/crates/nexum-world/src/lib.rs @@ -0,0 +1,743 @@ +//! Per-module world synthesis: turn a manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! The one non-obvious invariant: the capability rows here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because the imports are derived from the same +//! manifest, a macro-built component passes that check by construction +//! rather than by relying on the toolchain eliding unused imports. +//! +//! The table here carries only the core `nexum:host` rows. Per-namespace +//! rows come from the composition root's `extensions.toml` registry +//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], +//! so this crate carries no downstream name. + +use std::path::{Path, PathBuf}; + +/// Capability name consts: the single source the [`CORE`] table and the +/// runtime's capability registry emit from. +pub mod caps { + /// `nexum:host/chain`. + pub const CHAIN: &str = "chain"; + /// `nexum:host/identity`. + pub const IDENTITY: &str = "identity"; + /// `nexum:host/local-store`. + pub const LOCAL_STORE: &str = "local-store"; + /// `nexum:host/remote-store`. + pub const REMOTE_STORE: &str = "remote-store"; + /// `nexum:host/messaging`. + pub const MESSAGING: &str = "messaging"; + /// `nexum:host/logging`. + pub const LOGGING: &str = "logging"; + /// Gates `wasi:http/*`; no world import. + pub const HTTP: &str = "http"; +} + +/// Snake_case labels of the `nexum:host/types.fault` cases, in +/// declaration order: the single source every label mirror emits from. +pub mod fault_labels { + /// `fault.unsupported`. + pub const UNSUPPORTED: &str = "unsupported"; + /// `fault.unavailable`. + pub const UNAVAILABLE: &str = "unavailable"; + /// `fault.denied`. + pub const DENIED: &str = "denied"; + /// `fault.rate-limited`. + pub const RATE_LIMITED: &str = "rate_limited"; + /// `fault.timeout`. + pub const TIMEOUT: &str = "timeout"; + /// `fault.invalid-input`. + pub const INVALID_INPUT: &str = "invalid_input"; + /// `fault.internal`. + pub const INTERNAL: &str = "internal"; + /// All seven, in declaration order. + pub const ALL: [&str; 7] = [ + UNSUPPORTED, + UNAVAILABLE, + DENIED, + RATE_LIMITED, + TIMEOUT, + INVALID_INPUT, + INTERNAL, + ]; +} + +/// One manifest capability and its world wiring. +pub struct Capability { + /// The name declared under `[capabilities].required` / `optional`. + pub name: &'static str, + /// The WIT import the declaration turns into, or `None` for + /// capabilities with no world import (`http` is granted through the + /// SDK's wasi:http client and the host allowlist, not the world). + pub import: Option<&'static str>, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`. + pub packages: &'static [&'static str], + /// The `bind_host_via_wit_bindgen!` capability ident carrying this + /// capability's host-adapter pieces, if the SDK has a trait seam + /// for it. + pub adapter: Option<&'static str>, +} + +/// The core capability rows, in emission order. Mirrors the runtime's +/// core registry and nothing else; extension rows are the caller's. +pub const CORE: &[Capability] = &[ + Capability { + name: caps::CHAIN, + import: Some("nexum:host/chain@0.1.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: caps::IDENTITY, + import: Some("nexum:host/identity@0.1.0"), + packages: &[], + adapter: Some("identity"), + }, + Capability { + name: caps::LOCAL_STORE, + import: Some("nexum:host/local-store@0.1.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: caps::REMOTE_STORE, + import: Some("nexum:host/remote-store@0.1.0"), + packages: &[], + adapter: Some("remote_store"), + }, + Capability { + name: caps::MESSAGING, + import: Some("nexum:host/messaging@0.1.0"), + packages: &[], + adapter: Some("messaging"), + }, + Capability { + name: caps::LOGGING, + import: Some("nexum:host/logging@0.1.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: caps::HTTP, + import: None, + packages: &[], + adapter: None, + }, +]; + +/// Number of import-bearing [`CORE`] rows. +const fn core_iface_count() -> usize { + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + n += 1; + } + i += 1; + } + n +} + +/// Names of the import-bearing [`CORE`] rows, in emission order: the +/// `nexum:host` interface set the runtime's capability registry +/// enforces. `http` is absent (no world import). +pub const CORE_IFACES: [&str; core_iface_count()] = { + let mut out = [""; core_iface_count()]; + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + out[n] = CORE[i].name; + n += 1; + } + i += 1; + } + out +}; + +/// One registered extension row: a per-namespace capability a +/// composition root declares in its `extensions.toml`. An extension +/// always has a WIT import and never a host-adapter ident (adapter +/// seams are core-only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionRow { + /// The name modules declare under `[capabilities]`. + pub name: String, + /// The WIT import the declaration turns into. + pub import: String, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`, in dependency order. + pub packages: Vec, +} + +/// The synthesized world plus what the `generate!` call and the host +/// adapter need to go with it. +#[derive(Debug)] +pub struct ModuleWorld { + /// Inline WIT text defining `nexum:module-world/module`. + pub wit: String, + /// WIT package directories the resolve path must carry, in + /// dependency order (a package precedes its dependants). Always + /// starts with the base set the host `event` variant needs. + pub packages: Vec, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// Extract the declared capability names (`required` then `optional`) +/// from the manifest text. A missing or malformed `[capabilities]` +/// section is an error: the emitted world is derived from it, so the +/// synthesis has nothing to build from without one. +pub fn manifest_capabilities(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let caps = value.get("capabilities").ok_or_else(|| { + "module.toml has no [capabilities] section; the module/adapter macro derives the \ + component's WIT world from [capabilities].required/optional, so declare it (an empty \ + `required = []` is valid)" + .to_string() + })?; + let list = |key: &str| -> Result, String> { + match caps.get(key) { + None => Ok(Vec::new()), + Some(v) => v + .as_array() + .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) + }) + .collect(), + } + }; + let mut names = list("required")?; + names.extend(list("optional")?); + Ok(names) +} + +/// Extract the declared `[module] kind` from the manifest text, `None` +/// when absent (the runtime defaults an absent kind to the worker). +pub fn manifest_kind(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + match value.get("module").and_then(|module| module.get("kind")) { + None => Ok(None), + Some(kind) => kind + .as_str() + .map(|kind| Some(kind.to_owned())) + .ok_or_else(|| "[module].kind must be a string".to_string()), + } +} + +/// Parse the registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries the WIT `import` the declaration +/// turns into and the extra `packages` its resolve path needs. A file +/// without an `[extensions]` section registers nothing. +pub fn manifest_extensions(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("extensions.toml is not valid TOML: {e}"))?; + let Some(extensions) = value.get("extensions") else { + return Ok(Vec::new()); + }; + let extensions = extensions + .as_table() + .ok_or_else(|| "[extensions] must be a table of `[extensions.]` rows".to_string())?; + extensions + .iter() + .map(|(name, row)| { + let row = row + .as_table() + .ok_or_else(|| format!("[extensions.{name}] must be a table"))?; + let import = row + .get("import") + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("[extensions.{name}] must carry a string `import`"))? + .to_owned(); + let packages = match row.get("packages") { + None => Vec::new(), + Some(value) => value + .as_array() + .ok_or_else(|| { + format!("[extensions.{name}].packages must be an array of strings") + })? + .iter() + .map(|item| { + item.as_str().map(str::to_owned).ok_or_else(|| { + format!("[extensions.{name}].packages must contain only strings") + }) + }) + .collect::>()?, + }; + Ok(ExtensionRow { + name: name.clone(), + import, + packages, + }) + }) + .collect() +} + +/// Find the extension registry for a build rooted at `start`: the +/// nearest ancestor `extensions.toml`. `None` means no registered +/// extensions. +pub fn find_extensions_manifest(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let candidate = cur.join("extensions.toml"); + if candidate.is_file() { + return Some(candidate); + } + dir = cur.parent(); + } + None +} + +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). `extensions` carries the per-namespace rows of the registered +/// extensions, emitted after the core rows. Unknown names are an error +/// so a typo cannot silently drop an import; a registered name that +/// shadows a core row or another registration is an error so a +/// colliding registry cannot emit a duplicate import. +pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { + for (idx, ext) in extensions.iter().enumerate() { + if CORE.iter().any(|c| c.name == ext.name) + || extensions[..idx].iter().any(|prior| prior.name == ext.name) + { + return Err(format!( + "extension capability `{}` collides with an already-registered capability; \ + names must be unique across the core table and the registered extensions", + ext.name + )); + } + } + + let known = || { + CORE.iter() + .map(|c| c.name) + .chain(extensions.iter().map(|e| e.name.as_str())) + }; + for name in declared { + if !known().any(|k| k == name.as_str()) { + let names = known().collect::>().join(", "); + return Err(format!( + "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ + {names}" + )); + } + } + + let mut imports = String::new(); + // `nexum:host` is a leaf package (the `event` variant carries status + // transitions as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host".to_owned()]; + let mut adapters = Vec::new(); + for cap in CORE { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + if let Some(adapter) = cap.adapter { + adapters.push(adapter); + } + } + for ext in extensions { + if !declared.contains(&ext.name) { + continue; + } + imports.push_str(&format!(" import {};\n", ext.import)); + for package in &ext.packages { + if !packages.contains(package) { + packages.push(package.clone()); + } + } + } + + let mut wit = String::from( + "package nexum:module-world;\n\nworld module {\n \ + use nexum:host/types@0.1.0.{config, event, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export on-event: func(event: event) -> result<_, fault>;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + adapters, + }) +} + +/// Resolve each WIT package directory for a component build rooted at +/// `start` (the consuming crate's manifest directory). A package +/// resolves crate-locally, vendored `wit/deps/` before own +/// `wit/`; a crate not carrying it falls back to the nearest +/// ancestor `wit/` that does (the transitional monorepo layout). +pub fn resolve_wit_packages>( + start: &Path, + packages: &[S], +) -> Result, String> { + packages + .iter() + .map(|package| { + let package = package.as_ref(); + resolve_wit_package(start, package).ok_or_else(|| { + format!( + "declared capabilities need the `{package}` WIT package, but neither \ + `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", + start.display() + ) + }) + }) + .collect() +} + +/// Find one package directory: crate-local `wit/deps/` then +/// `wit/`, walking up on a miss. +fn resolve_wit_package(start: &Path, package: &str) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let wit = cur.join("wit"); + for candidate in [wit.join("deps").join(package), wit.join(package)] { + if candidate.is_dir() { + return Some(candidate); + } + } + dir = cur.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base package set every module world resolves against: + /// `nexum:host` is a leaf package, so it stands alone. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// A stand-in extension row, as a registered extension would pass. + fn ext() -> Vec { + vec![ExtensionRow { + name: "acme".to_owned(), + import: "acme:ext/api@0.1.0".to_owned(), + packages: vec!["acme-ext".to_owned()], + }] + } + + #[test] + fn logging_only_world_imports_logging_alone() { + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/chain")); + assert_eq!(world.packages, MODULE_PACKAGES); + assert_eq!(world.adapters, vec!["logging"]); + } + + #[test] + fn extension_row_emits_its_import_and_packages() { + let world = synthesize(&["logging".to_string(), "acme".to_string()], &ext()).unwrap(); + assert!(world.wit.contains("import acme:ext/api@0.1.0;")); + assert_eq!(world.packages, vec!["nexum-host", "acme-ext"]); + } + + #[test] + fn undeclared_extension_row_stays_out_of_the_world() { + let world = synthesize(&["logging".to_string()], &ext()).unwrap(); + assert!(!world.wit.contains("acme")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn extension_shadowing_a_core_name_is_rejected() { + let rows = vec![ExtensionRow { + name: "chain".to_owned(), + import: "acme:ext/chain@0.1.0".to_owned(), + packages: Vec::new(), + }]; + let err = synthesize(&["chain".to_string()], &rows).unwrap_err(); + assert!(err.contains("extension capability `chain` collides")); + } + + #[test] + fn duplicate_extension_registration_is_rejected() { + let mut rows = ext(); + rows.extend(ext()); + let err = synthesize(&[], &rows).unwrap_err(); + assert!(err.contains("extension capability `acme` collides")); + } + + #[test] + fn core_ifaces_are_the_import_bearing_rows() { + assert_eq!( + CORE_IFACES, + [ + caps::CHAIN, + caps::IDENTITY, + caps::LOCAL_STORE, + caps::REMOTE_STORE, + caps::MESSAGING, + caps::LOGGING, + ], + ); + assert!(!CORE_IFACES.contains(&caps::HTTP)); + } + + #[test] + fn fault_labels_are_snake_case_and_distinct() { + for label in fault_labels::ALL { + assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); + } + let mut labels = fault_labels::ALL.to_vec(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), fault_labels::ALL.len()); + } + + #[test] + fn core_table_carries_no_extension_row() { + assert!( + CORE.iter() + .all(|c| c.import.is_none_or(|i| i.starts_with("nexum:host/"))) + ); + assert!(CORE.iter().all(|c| c.packages.is_empty())); + } + + #[test] + fn every_import_bearing_core_row_carries_an_adapter() { + // `http` has no world import (SDK wasi:http client) and no + // adapter; every other core row has both. + for cap in CORE { + assert_eq!(cap.import.is_some(), cap.adapter.is_some(), "{}", cap.name); + } + } + + #[test] + fn full_declaration_emits_the_six_adapters_in_core_order() { + let declared: Vec = CORE.iter().map(|c| c.name.to_string()).collect(); + let world = synthesize(&declared, &[]).unwrap(); + assert_eq!( + world.adapters, + vec![ + "chain", + "identity", + "local_store", + "remote_store", + "messaging", + "logging", + ], + ); + } + + #[test] + fn http_declares_no_world_import() { + let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn duplicate_declarations_emit_one_import() { + let world = synthesize(&["chain".to_string(), "chain".to_string()], &[]).unwrap(); + assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); + assert_eq!(world.adapters, vec!["chain"]); + } + + #[test] + fn unknown_capability_is_rejected_with_the_known_list() { + let err = synthesize(&["telepathy".to_string()], &ext()).unwrap_err(); + assert!(err.contains("unknown capability `telepathy`")); + assert!(err.contains("logging")); + assert!(err.contains("acme")); + } + + #[test] + fn manifest_extensions_reads_rows() { + let rows = manifest_extensions( + r#" +[extensions.acme] +import = "acme:ext/api@0.1.0" +packages = ["acme-base", "acme-ext"] + +[extensions.beta] +import = "beta:ext/api@0.1.0" +"#, + ) + .unwrap(); + assert_eq!(rows, { + let mut expected = ext(); + expected[0].packages = vec!["acme-base".to_owned(), "acme-ext".to_owned()]; + expected.push(ExtensionRow { + name: "beta".to_owned(), + import: "beta:ext/api@0.1.0".to_owned(), + packages: Vec::new(), + }); + expected + }); + } + + #[test] + fn manifest_without_extensions_section_registers_nothing() { + assert_eq!(manifest_extensions("").unwrap(), Vec::new()); + } + + #[test] + fn extension_row_without_an_import_is_an_error() { + let err = manifest_extensions("[extensions.acme]\npackages = []\n").unwrap_err(); + assert!(err.contains("[extensions.acme] must carry a string `import`")); + } + + #[test] + fn extension_row_with_non_string_package_is_an_error() { + let err = + manifest_extensions("[extensions.acme]\nimport = \"a:b/c@0.1.0\"\npackages = [1]\n") + .unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn manifest_capabilities_reads_required_and_optional() { + let caps = manifest_capabilities( + r#" +[capabilities] +required = ["logging", "chain"] +optional = ["remote-store"] + +[capabilities.http] +allow = [] +"#, + ) + .unwrap(); + assert_eq!(caps, vec!["logging", "chain", "remote-store"]); + } + + #[test] + fn manifest_kind_reads_the_module_kind() { + let kind = manifest_kind("[module]\nname = \"x\"\nkind = \"venue-adapter\"\n").unwrap(); + assert_eq!(kind.as_deref(), Some("venue-adapter")); + } + + #[test] + fn manifest_without_a_kind_is_none() { + assert_eq!(manifest_kind("[module]\nname = \"x\"\n").unwrap(), None); + assert_eq!(manifest_kind("").unwrap(), None); + } + + #[test] + fn manifest_with_a_non_string_kind_is_an_error() { + let err = manifest_kind("[module]\nkind = 3\n").unwrap_err(); + assert!(err.contains("[module].kind must be a string")); + } + + #[test] + fn manifest_without_capabilities_section_is_an_error() { + let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); + assert!(err.contains("[capabilities]")); + } + + #[test] + fn manifest_with_non_string_capability_is_an_error() { + let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn world_is_valid_wit_shape() { + // Not a full WIT parse (that is the module build's job); pin the + // structural pieces the runtime contract depends on. + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.starts_with("package nexum:module-world;")); + assert!(world.wit.contains("world module {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!( + world + .wit + .contains("export on-event: func(event: event) -> result<_, fault>;") + ); + } + + #[test] + fn resolution_prefers_vendored_deps_over_own_wit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/deps/pkg")).unwrap(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let paths = resolve_wit_packages(root, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/deps/pkg")]); + } + + #[test] + fn resolution_falls_back_to_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/pkg")]); + } + + #[test] + fn crate_local_package_shadows_the_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(leaf.join("wit/deps/pkg")).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![leaf.join("wit/deps/pkg")]); + } + + #[test] + fn extension_registry_resolves_from_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("extensions.toml"), "").unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + assert_eq!( + find_extensions_manifest(&leaf), + Some(root.join("extensions.toml")) + ); + } + + #[test] + fn absent_extension_registry_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_extensions_manifest(dir.path()), None); + } + + #[test] + fn missing_package_names_the_paths_tried() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); + assert!(err.contains("`pkg` WIT package")); + assert!(err.contains("wit/deps/pkg")); + } +} diff --git a/crates/no-std-probe/Cargo.toml b/crates/no-std-probe/Cargo.toml new file mode 100644 index 00000000..0a5c6279 --- /dev/null +++ b/crates/no-std-probe/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "no-std-probe" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Compile-only #![no_std] probe: the IntentBody derive must expand without the consumer's std prelude." + +[lib] +# Never shipped. Living in the workspace keeps the derive's no_std +# contract under the workspace check/clippy gate. + +[lints] +workspace = true + +[dependencies] +# Source of the `IntentBody` derive and trait the probe enum implements. +videre-sdk = { path = "../videre-sdk" } diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs new file mode 100644 index 00000000..fd455704 --- /dev/null +++ b/crates/no-std-probe/src/lib.rs @@ -0,0 +1,14 @@ +//! Compile-only `#![no_std]` probe: `#[derive(IntentBody)]` must expand +//! without the consumer's std prelude or an `extern crate alloc`. + +#![no_std] +#![warn(missing_docs)] + +use videre_sdk::IntentBody; + +/// The probe schema: one published version over a bare byte payload. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum ProbeBody { + /// First published version. + V1(u8), +} diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml index 2ec14aa6..e424d50c 100644 --- a/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -15,8 +15,10 @@ path = "src/main.rs" # (alongside its wasm cdylib) specifically so this crate can drive # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } +cow-venue = { path = "../cow-venue", features = ["client"] } nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +nexum-sdk-test = { path = "../nexum-sdk-test" } +videre-sdk = { path = "../videre-sdk" } anyhow.workspace = true clap.workspace = true diff --git a/crates/shepherd-backtest/src/main.rs b/crates/shepherd-backtest/src/main.rs index 45d446b6..6d0ff80d 100644 --- a/crates/shepherd-backtest/src/main.rs +++ b/crates/shepherd-backtest/src/main.rs @@ -3,8 +3,8 @@ //! Offline replay harness for Shepherd modules. Loads a fixtures //! JSON produced by `tools/backtest-collect/backtest_collect.py`, //! drives each on-chain event through the production strategy code -//! via `shepherd_sdk_test::MockHost`, classifies the result, and -//! emits a Markdown report at +//! over `nexum_sdk_test::MockHost` and a recording pool transport, +//! classifies the result, and emits a Markdown report at //! `docs/operations/backtest-reports/backtest-7d-YYYY-MM-DD.md`. //! //! ## Scope diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs index 71a0aef1..36649f7b 100644 --- a/crates/shepherd-backtest/src/replay.rs +++ b/crates/shepherd-backtest/src/replay.rs @@ -1,32 +1,37 @@ -//! Per-event replay against `ethflow_watcher::strategy::on_chain_logs`. +//! Per-event replay against the ethflow-watcher strategy pair. //! -//! Each [`EthFlowFixture`] is driven through the production strategy -//! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, a catch-all 200 response is programmed for any -//! `cow_api_request` call (the observe+verify strategy GETs -//! `/api/v1/orders/{uid}` to confirm the orderbook has indexed the -//! order), and `strategy::on_chain_logs` is invoked with an alloy -//! `Log` reconstructed from the raw `eth_getLogs` payload. +//! Each [`EthFlowFixture`] is driven the way the live engine does it, +//! minus the wasm boundary: `strategy::on_chain_logs` runs over a +//! recording venue transport (the pool seam), then the status +//! transition the registry would poll for the watched receipt is +//! delivered through `strategy::on_intent_status`. In backtest context +//! all fixtures are confirmed real orders, so the simulated transition +//! is `open`. //! -//! The classification falls into one of the four buckets defined in -//! the issue: +//! The classification falls into one of four buckets: //! -//! - `Observed`: the strategy verified the order with exactly one -//! `GET /api/v1/orders/{uid}` and wrote `observed:{uid}` to the -//! local store. This is the success case under the observe+verify -//! strategy. -//! - `RejectedExpected`: the strategy returned without observing in a -//! documented case (reserved for fixtures where the mock returns 404 -//! — not applicable when all fixtures program 200). +//! - `Observed`: the strategy registered exactly one `cow` watch whose +//! receipt is the fixture's UID and wrote `observed:{uid}` on the +//! delivered transition. The success case. +//! - `RejectedExpected`: reserved for a documented skip path; not +//! emitted in the current batch. //! - `RejectedUnexpected`: the strategy returned Ok but the observe -//! contract was violated (no `observed:{uid}` marker, an unexpected -//! orderbook call shape, or a `submit_order` attempt); a follow-up -//! should be filed before the report closes. -//! - `StrategyError`: `on_chain_logs` returned `Err(fault)`. A test +//! contract was violated (a wrong watch shape, a non-observe venue +//! call, or a missing `observed:{uid}` marker); a follow-up should +//! be filed before the report closes. +//! - `StrategyError`: a strategy call returned `Err(fault)`. A test //! bug or an `unreachable!` we want to investigate. +use std::cell::RefCell; +use std::rc::Rc; + +use cow_venue::client::CowClient; use ethflow_watcher::strategy; -use shepherd_sdk_test::MockHost; +use nexum_sdk::status_body::{IntentStatus, StatusBody}; +use nexum_sdk_test::{CapturedEvents, MockHost, capture_tracing}; +use videre_sdk::client::{VenueId, VenueTransport}; +use videre_sdk::rt::complete; +use videre_sdk::{Quotation, SubmitOutcome, VenueFault}; use crate::fixtures::{EthFlowFixture, parse_address}; @@ -45,9 +50,8 @@ pub struct ReplayOutcome { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Classification { Observed, - /// Reserved for any documented skip path (e.g. a fixture where the mock - /// returns 404 for an un-indexed order). Not emitted in the current batch; - /// retained so the acceptance-ratio formula is complete. + /// Reserved for any documented skip path. Not emitted in the current + /// batch; retained so the acceptance-ratio formula is complete. #[allow(dead_code)] RejectedExpected(String), RejectedUnexpected(String), @@ -74,16 +78,94 @@ impl Classification { } } -/// Replay one EthFlow fixture through the production strategy. +/// One recorded transport call: the verb, and for `observe` the routed +/// venue and receipt. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, +} + +impl Call { + fn label(&self) -> &'static str { + match self { + Call::Quote => "quote", + Call::Submit => "submit", + Call::Observe(..) => "observe", + Call::Status => "status", + Call::Cancel => "cancel", + } + } +} + +/// Records every pool call; `observe` accepts, the other verbs refuse. +/// Cloneable over shared state so the replay keeps a handle after one +/// moves into the client. +#[derive(Clone, Default)] +struct RecordingVenues { + calls: Rc>>, +} + +impl RecordingVenues { + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } +} + +impl videre_sdk::client::sealed::SealedTransport for RecordingVenues {} + +impl VenueTransport for RecordingVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + Ok(()) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } +} + +/// The `open` transition the registry would report for an indexed order. +fn open_status() -> Result, String> { + StatusBody { + status: IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .map_err(|e| format!("status body encode: {e}")) +} + +/// Replay one EthFlow fixture through the production strategy pair. pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { let host = MockHost::new(); - - // Program a catch-all 200 response for any cow_api_request. In - // the observe+verify strategy the module GETs - // `/api/v1/orders/{uid}` to confirm the orderbook has indexed the - // order. In backtest context all fixtures are confirmed real orders, - // so the mock orderbook always returns 200 (indexed). - host.cow_api.respond_to_request(Ok("{}".to_string())); + let venues = RecordingVenues::default(); + let client = CowClient::with_transport(venues.clone()); // Reconstruct the log fields. Topics + data come straight from the // collector's `raw_log`; the contract address is the EthFlow @@ -120,18 +202,32 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { } .into(); - // Drive the strategy. - let result = strategy::on_chain_logs(&host, chain_id, &[log]); - let log_lines: Vec = host - .logging - .lines() - .into_iter() - .map(|l| format!("[{:?}] {}", l.level, l.message)) - .collect(); + // Drive the strategy: register the watch, then deliver the status + // transition the registry would poll for the watched receipt. + let (result, logs) = capture_tracing(|| { + complete(strategy::on_chain_logs(&host, &client, chain_id, &[log])) + .ok_or_else(|| "strategy future suspended".to_owned()) + .and_then(|r| r.map_err(|e| e.to_string()))?; + let calls = venues.calls(); + let [Call::Observe(venue, receipt)] = calls.as_slice() else { + let shapes: Vec<&str> = calls.iter().map(Call::label).collect(); + return Err(format!( + "expected exactly one observe; saw [{}]", + shapes.join(", ") + )); + }; + if venue != "cow" { + return Err(format!("watch routed to venue `{venue}`, not `cow`")); + } + strategy::on_intent_status(&host, venue, receipt, &open_status()?) + .map_err(|e| e.to_string())?; + Ok(receipt.clone()) + }); + let log_lines = log_lines(&logs); let class = match result { - Err(e) => Classification::StrategyError(e.to_string()), - Ok(()) => classify_ok(&host, &fx.uid, &log_lines), + Err(detail) => classify_err(&venues, detail), + Ok(receipt) => classify_ok(&host, &receipt, &fx.uid, &log_lines), }; ReplayOutcome { @@ -143,6 +239,13 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { } } +fn log_lines(logs: &CapturedEvents) -> Vec { + logs.events() + .into_iter() + .map(|e| format!("[{:?}] {}", e.level, e.message)) + .collect() +} + fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { ReplayOutcome { uid: fx.uid.clone(), @@ -153,31 +256,40 @@ fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { } } -/// Classify an `Ok(())` replay by inspecting the mock's recorded -/// side effects, independent of the strategy's own logging. +/// A failed replay is an observe-contract violation when the strategy +/// itself returned Ok but touched the pool wrongly; otherwise a +/// strategy error. +fn classify_err(venues: &RecordingVenues, detail: String) -> Classification { + if venues + .calls() + .iter() + .any(|c| !matches!(c, Call::Observe(..))) + { + return Classification::RejectedUnexpected(format!( + "strategy called a non-observe venue verb; observe-only must never submit ({detail})" + )); + } + if detail.starts_with("expected exactly one observe") + || detail.starts_with("watch routed to venue") + { + return Classification::RejectedUnexpected(detail); + } + Classification::StrategyError(detail) +} + +/// Classify an `Ok` replay by inspecting the recorded side effects, +/// independent of the strategy's own logging. /// /// `Observed` demands the full observe contract, not just the marker: -/// exactly one orderbook call, shaped `GET /api/v1/orders/{uid}`, -/// zero `submit_order` attempts, and the `observed:{uid}` store key. -/// The exact-UID match (never an `observed:` prefix scan) means a -/// compute_uid divergence between module and collector cannot produce -/// a false `Observed`. -fn classify_ok(host: &MockHost, uid: &str, log_lines: &[String]) -> Classification { - if host.cow_api.call_count() > 0 { - return Classification::RejectedUnexpected( - "strategy called submit_order; observe+verify must never submit".into(), - ); - } - let requests = host.cow_api.request_calls(); - let expected_path = format!("/api/v1/orders/{uid}"); - if requests.len() != 1 || requests[0].method != "GET" || requests[0].path != expected_path { - let shapes: Vec = requests - .iter() - .map(|r| format!("{} {}", r.method, r.path)) - .collect(); +/// the one watch's receipt renders to exactly the fixture UID (never a +/// prefix scan, so a compute_uid divergence between module and +/// collector cannot produce a false `Observed`), and the delivered +/// transition wrote the `observed:{uid}` store key. +fn classify_ok(host: &MockHost, receipt: &[u8], uid: &str, log_lines: &[String]) -> Classification { + let receipt_hex = format!("0x{}", hex::encode(receipt)); + if receipt_hex != uid { return Classification::RejectedUnexpected(format!( - "expected exactly one GET {expected_path}; saw [{}]", - shapes.join(", ") + "watched receipt {receipt_hex} does not match the collector UID" )); } if host diff --git a/crates/shepherd-backtest/src/report.rs b/crates/shepherd-backtest/src/report.rs index 9e64b9fd..a27b28ae 100644 --- a/crates/shepherd-backtest/src/report.rs +++ b/crates/shepherd-backtest/src/report.rs @@ -37,11 +37,12 @@ pub fn render(fx: &Fixtures, outcomes: &[ReplayOutcome], threshold: f64) -> Stri )); out.push_str( "Replays every collected EthFlow `OrderPlacement` event through the production \ - `ethflow_watcher::strategy::on_chain_logs` code path via `shepherd_sdk_test::MockHost`. \ - The orderbook is **never hit**: the MockHost programs a catch-all 200 for all \ - `cow_api_request` calls so the observe+verify strategy sees every fixture as \ - already indexed. Success is measured by whether the strategy wrote the exact \ - `observed:{uid}` marker to the local store after the 200 confirmation.\n\n", + `ethflow_watcher::strategy` pair (`on_chain_logs` then `on_intent_status`) over \ + `nexum_sdk_test::MockHost` and a recording pool transport. The orderbook is \ + **never hit**: the transport accepts every watch and the replay delivers the \ + `open` transition the registry would poll for it, so every fixture reads as \ + indexed. Success is measured by whether the strategy watched exactly the \ + fixture UID and wrote the exact `observed:{uid}` marker on the transition.\n\n", ); out.push_str("## Run metadata\n\n"); out.push_str("| Field | Value |\n|---|---|\n"); diff --git a/crates/shepherd-cow-host/Cargo.toml b/crates/shepherd-cow-host/Cargo.toml index a5113911..47ba5e1e 100644 --- a/crates/shepherd-cow-host/Cargo.toml +++ b/crates/shepherd-cow-host/Cargo.toml @@ -50,4 +50,3 @@ toml.workspace = true tokio.workspace = true wiremock.workspace = true tempfile.workspace = true -alloy-rpc-types-eth.workspace = true diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index 018adaba..b7c93271 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -4,12 +4,14 @@ //! Shape: a local `bindgen!` for the extension world, a `Host` impl for //! the foreign `HostState` reached through [`ExtState`], a payload //! trait ([`CowBackend`]) the lattice `Ext` member satisfies, and an -//! [`Extension`] bundling the linker hook with the capability namespace. +//! [`Extension`] impl carrying the linker hook and capability namespace. //! //! The bindgen shares `nexum:host/types` with the core bindings via //! `with`, so the `fault` the extension's `cow-api-error` embeds is the //! same type the core host constructs. +use std::marker::PhantomData; +use std::sync::Arc; use std::time::Instant; use alloy_chains::Chain; @@ -18,7 +20,7 @@ use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTy use nexum_runtime::host::extension::Extension; use nexum_runtime::host::state::{ExtState, HostState}; use nexum_runtime::manifest::NamespaceCaps; -use wasmtime::component::HasSelf; +use wasmtime::component::{HasSelf, Linker}; use crate::cow::CowApi; use crate::cow_orderbook::{CowApiError, OrderBookPool}; @@ -26,8 +28,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", ], @@ -86,28 +86,45 @@ impl ComponentBuilder for ReferenceExtBuilder { } } +/// The cow-api extension over a lattice whose `Ext` payload carries a cow +/// backend. +struct CowExtension(PhantomData T>); + +impl Extension for CowExtension +where + T: RuntimeTypes, + T::Ext: CowBackend, +{ + fn namespace(&self) -> &'static str { + "cow" + } + + fn capabilities(&self) -> NamespaceCaps { + COW_CAPABILITIES + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + // Link only the cow-api interface. The whole-world + // `CowExt::add_to_linker` would also re-add the shared + // `nexum:host/types` instance, which the core event-module + // linker already provides, tripping a "defined twice" error. + bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } +} + /// Build the cow extension for a lattice whose `Ext` payload carries a cow /// backend. Wired at the composition root into `build_linker` and /// capability enforcement. -pub fn extension() -> Extension +pub fn extension() -> Arc> where T: RuntimeTypes, T::Ext: CowBackend, { - Extension { - link: std::sync::Arc::new(|linker| { - // Link only the cow-api interface. The whole-world - // `CowExt::add_to_linker` would also re-add the shared - // `nexum:host/types` instance, which the core event-module - // linker already provides, tripping a "defined twice" error. - bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - }), - capabilities: COW_CAPABILITIES, - } + Arc::new(CowExtension(PhantomData)) } /// Project the backend [`CowApiError`] into the WIT `cow-api-error`. diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index f3a75d9a..b195b537 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -1,14 +1,13 @@ //! Boot-order coverage for the cow-api extension: a module that imports //! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root. The negative direction (fails to boot without -//! the extension) lives in the runtime's own supervisor tests. +//! at the composition root, and fails to boot without it. //! //! These exercise the real wit-bindgen + supervisor path against pre-built //! wasm artefacts and skip gracefully when the artefact is absent. use std::path::{Path, PathBuf}; +use std::sync::Arc; -use alloy_chains::Chain; use nexum_runtime::bindings::nexum; use nexum_runtime::engine_config::{EngineConfig, ModuleLimits}; use nexum_runtime::host::component::{Components, RuntimeTypes}; @@ -27,13 +26,15 @@ const SEPOLIA: u64 = 11_155_111; #[derive(Debug, Clone, Copy, Default)] struct CowTestTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for CowTestTypes {} + impl RuntimeTypes for CowTestTypes { type Chain = ProviderPool; type Store = LocalStore; type Ext = ReferenceExt; } -fn cow_extensions() -> Vec> { +fn cow_extensions() -> Vec>> { vec![extension::()] } @@ -142,78 +143,65 @@ async fn boot_production_module( .expect("boot_single") } -/// twap-monitor imports `shepherd:cow/cow-api`; with the cow extension -/// registered it boots, and a block dispatch reaches it and keeps it alive. +/// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow +/// extension and a block dispatch reaches it. #[tokio::test] -async fn e2e_twap_monitor_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { +async fn e2e_stop_loss_block_dispatch() { + let Some(wasm) = module_wasm_or_skip("stop-loss") else { return; }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - - // twap-monitor subscribes to Sepolia blocks (poll path). A real poll - // would call chain::request, which ProviderPool::empty() does not - // satisfy - the module surfaces a fault and warns; the supervisor - // must keep the module alive because the strategy catches the error - // and returns Ok(()). let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; assert_eq!(dispatched, 1); assert_eq!(supervisor.alive_count(), 1); } -/// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; -/// it boots with the cow extension and a synthetic log is delivered. -#[tokio::test] -async fn e2e_ethflow_watcher_log_dispatch() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { - return; - }; - let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.alive_count(), 1); - - // A log with an unrecognised topic is silently skipped by the module's - // decoder (returns `None` from `decode_order_placement`), so the test - // only proves: supervisor delivered, module did not trap, module stayed - // alive. - let synthetic_log = alloy_rpc_types_eth::Log::default(); - let dispatched = supervisor - .dispatch_chain_log( - "ethflow-watcher", - Chain::from_id(SEPOLIA), - synthetic_log, - None, - ) - .await; - assert!(dispatched); - assert_eq!(supervisor.alive_count(), 1); -} - -/// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow -/// extension and a block dispatch reaches it. +/// The boot-order invariant, exercised (not merely asserted in prose): +/// a module that imports `shepherd:cow/cow-api` (stop-loss) must NOT +/// boot when the cow extension is absent from the linker AND the +/// capability registry. The paired linker-hook + capability-namespace +/// registration is what makes the same module boot in the tests above; +/// drop the pairing and boot fails. #[tokio::test] -async fn e2e_stop_loss_block_dispatch() { +async fn stop_loss_without_cow_extension_fails_to_boot() { let Some(wasm) = module_wasm_or_skip("stop-loss") else { return; }; let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); + // Core-only: no cow linker hook, no cow capability namespace. + let linker = build_linker::(&engine, &[]).expect("build_linker"); let (_dir, store) = temp_local_store(); + let components = test_components(store).await; + let limits = ModuleLimits::default(); - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &[], + None, + ) + .await; + + let err = result + .err() + .expect("cow-importing module must not boot without the cow extension registered"); + // Pin the failure to its specific cause: ethflow-watcher declares + // the cow-api capability, which a core-only registry does not + // recognise (registering it is exactly what the cow extension does). + // Rules out an unrelated failure masquerading as the invariant. + let chain = format!("{err:#}"); + assert!( + chain.contains(r#"unknown capability "cow-api""#), + "expected the cow-api unknown-capability failure, got: {chain}", + ); } diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index 83a3525c..ea2a4a47 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -20,4 +20,5 @@ serde_json = { workspace = true, features = ["std"] } # Order construction for the MockVenue acceptance tests that drive # the keeper run end to end. alloy-primitives.workspace = true +composable-cow = { path = "../composable-cow" } cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index 2e08b74f..b0a8dd22 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -50,8 +50,14 @@ use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; -use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature}; +use nexum_sdk_test::{ + MockChain, MockIdentity, MockLocalStore, MockLogging, MockMessaging, MockRemoteStore, +}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait @@ -63,8 +69,14 @@ use shepherd_sdk::cow::{CowApiError, CowApiHost}; pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, /// `nexum:host/local-store` mock. pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, /// `shepherd:cow/cow-api` mock. pub cow_api: V, /// `nexum:host/logging` mock. @@ -104,6 +116,59 @@ impl LocalStoreHost for MockHost { fn list_keys(&self, prefix: &str) -> Result, Fault> { self.store.list_keys(prefix) } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: the mock's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } +} + +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } } impl CowApiHost for MockHost { diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index e9b3bef0..ec371ca2 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -4,16 +4,26 @@ //! strategy code polling the venue directly. use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowApiTransport, CowClient, CowHost, CowIntent, CowIntentBody, OrderRejection, + SignedOrder, gpv2_to_order_data, order_data_to_body, order_uid_hex, run, +}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; type VenueHost = MockHost; +/// The typed client every test drives `run` with: the transitional +/// cow-api bridge over the scripted venue host. +fn venue(host: &VenueHost) -> CowClient> { + CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +} + /// Closure-backed source so each test scripts its own outcome. struct FnSource(F); @@ -106,6 +116,18 @@ fn client_uid(order: &GPv2OrderData) -> String { order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") } +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") +} + fn rejection(error_type: &str) -> CowApiError { CowApiError::Rejected(OrderRejection { status: 400, @@ -130,20 +152,20 @@ fn keeper_retries_a_transient_rejection_then_submits() { host.cow_api.enqueue_submit(Ok(client_uid(&order))); let source = ready_source(&order); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); assert!(host.store.snapshot().contains_key(&key), "watch survives"); assert!( !Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); assert_eq!( @@ -169,7 +191,7 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { let t0 = sample_tick(); let source = ready_source(&order); - run(&host, &source, &t0).unwrap(); + run(&host, &venue(&host), &source, &t0).unwrap(); assert_eq!(host.cow_api.call_count(), 1); // 2500ms rounds up to a 3s epoch gate: a tick inside it never @@ -178,18 +200,18 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { epoch_s: t0.epoch_s + 2, ..t0 }; - run(&host, &source, &gated).unwrap(); + run(&host, &venue(&host), &source, &gated).unwrap(); assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); let clear = Tick { epoch_s: t0.epoch_s + 3, ..t0 }; - run(&host, &source, &clear).unwrap(); + run(&host, &venue(&host), &source, &clear).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } @@ -206,7 +228,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); let source = ready_source(&order); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&key)); assert!(!snapshot.contains_key(&watch_key.next_block_key())); @@ -215,11 +237,11 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { host.cow_api.clear_fault(); host.cow_api.enqueue_submit(Ok(client_uid(&order))); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } @@ -233,7 +255,7 @@ fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { host.cow_api .enqueue_submit(Err(rejection("InvalidSignature"))); - run(&host, &ready_source(&order), &sample_tick()).unwrap(); + run(&host, &venue(&host), &ready_source(&order), &sample_tick()).unwrap(); assert!(host.store.is_empty(), "watch and gates must go"); assert_eq!(host.cow_api.call_count(), 1); @@ -260,6 +282,7 @@ fn keeper_sweep_ignores_sibling_namespace_watches() { let polls = std::cell::Cell::new(0_u32); run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index c4ef8455..a2c7f216 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, revert decoding, and prelude on top of cowprotocol types." +description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, and prelude on top of cowprotocol types." [lib] # Plain library - modules link this and emit their own cdylib for the @@ -16,12 +16,17 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # cow-venue default slice; this crate re-exports them under `cow` until # the module ports move off the legacy path. The `client` slice carries # the table-driven retry classification the cow error surface delegates -# to. -cow-venue = { path = "../cow-venue", features = ["client"] } +# to; the `assembly` slice carries the chain-edge order projections the +# keeper run still drives. +cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } +# The structured poll seam the keeper run dispatches on. +composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } +# The typed client seam the keeper run submits through; also the +# `VenueTransport` contract the legacy cow-api bridge implements. +videre-sdk = { path = "../videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true -alloy-sol-types.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true @@ -31,6 +36,7 @@ tracing.workspace = true # `capture_tracing` observes the keeper run's diagnostics in the # acceptance tests. nexum-sdk-test = { path = "../nexum-sdk-test" } +alloy-sol-types.workspace = true proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, # and the keeper run acceptance-tests against the composed MockHost diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index b626ab94..a5e0de03 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -67,6 +67,8 @@ pub enum CowApiError { Rejected(OrderRejection), } +impl nexum_sdk::host::sealed::SealedHostFault for CowApiError {} + impl HostFault for CowApiError { fn fault(&self) -> Option<&Fault> { match self { @@ -168,14 +170,11 @@ mod tests { } #[test] - fn retriable_kinds_yield_try_next_block() { - for kind in ["InsufficientFee", "PriceExceedsMarketPrice"] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::TryNextBlock, - "{kind}", - ); - } + fn retriable_kind_yields_try_next_block() { + assert_eq!( + classify_api_error(&rejection("InsufficientFee")), + RetryAction::TryNextBlock, + ); } /// A throttle errorType backs off rather than retrying next block, diff --git a/crates/shepherd-sdk/src/cow/events.rs b/crates/shepherd-sdk/src/cow/events.rs new file mode 100644 index 00000000..20acdd42 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/events.rs @@ -0,0 +1,111 @@ +//! CoW on-chain event ABIs, mirroring `shepherd:cow/cow-events`. +//! +//! `wit/shepherd-cow/cow-events.wit` is the package of record; the +//! constants here are parity-tested against it, the `cowprotocol` +//! `sol!` types, and each keeper's `module.toml`. + +use alloy_primitives::{B256, b256}; + +/// One on-chain event surface: the canonical Solidity signature and +/// its keccak256 topic-0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventAbi { + /// Canonical Solidity event signature. + pub signature: &'static str, + /// keccak256 of [`Self::signature`]: the log's topic-0. + pub topic0: B256, +} + +/// `ComposableCoW.ConditionalOrderCreated`. +pub const CONDITIONAL_ORDER_CREATED: EventAbi = EventAbi { + signature: "ConditionalOrderCreated(address,(address,bytes32,bytes))", + topic0: b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), +}; + +/// `CoWSwapOnchainOrders.OrderPlacement` (EthFlow). +pub const ORDER_PLACEMENT: EventAbi = EventAbi { + signature: "OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,\ + uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)", + topic0: b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), +}; + +/// Every event surface the keepers decode. +pub const ALL: &[EventAbi] = &[CONDITIONAL_ORDER_CREATED, ORDER_PLACEMENT]; + +#[cfg(test)] +mod tests { + use alloy_primitives::keccak256; + use alloy_sol_types::SolEvent; + use cowprotocol::{CoWSwapOnchainOrders, ComposableCoW}; + + use super::*; + + #[test] + fn topic0_is_keccak_of_signature() { + for abi in ALL { + assert_eq!(abi.topic0, keccak256(abi.signature), "{}", abi.signature); + } + } + + #[test] + fn matches_the_sol_decoder_types() { + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE, + CONDITIONAL_ORDER_CREATED.signature, + ); + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE_HASH, + CONDITIONAL_ORDER_CREATED.topic0, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE, + ORDER_PLACEMENT.signature, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE_HASH, + ORDER_PLACEMENT.topic0, + ); + } + + #[test] + fn wit_package_of_record_pins_every_surface() { + let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); + let flat: String = wit + .lines() + .map(|l| l.trim().trim_start_matches("/// ")) + .collect(); + for abi in ALL { + assert!( + flat.contains(abi.signature), + "cow-events.wit must pin the signature {}", + abi.signature, + ); + assert!( + flat.contains(&format!("{:#x}", abi.topic0)), + "cow-events.wit must pin the topic-0 {:#x}", + abi.topic0, + ); + } + } + + /// Layering gate: no generic WIT package references `shepherd:cow`. + #[test] + fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); + for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { + let pkg = pkg.expect("wit dir entry").path(); + if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { + continue; + } + for file in std::fs::read_dir(&pkg).expect("wit package dir") { + let path = file.expect("wit package entry").path(); + let text = std::fs::read_to_string(&path).expect("read wit file"); + assert!( + !text.contains("shepherd:cow"), + "{} references shepherd:cow", + path.display(), + ); + } + } + } +} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 1d216924..8909134c 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,46 +1,54 @@ //! CoW Protocol bridging. //! -//! Type conversions and ABI decoding helpers that translate between -//! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, -//! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `Verdict`, `RetryAction`), plus [`run()`] - the -//! poll/submit composition over the keeper stores. +//! ABI decoding helpers, the orderbook error surface, and [`run()`] - +//! the poll/submit composition over the keeper stores, submitting +//! through the typed [`CowClient`] on the `videre:venue/client` seam. +//! The chain-edge order projections live in the `cow-venue` `assembly` +//! slice (the venue adapter owns them) and are re-exported here. //! -//! The poll seam is the structured [`Verdict`]; the deployed -//! ComposableCoW 1.x reverting wire is decoded behind the quarantined -//! [`LegacyRevertAdapter`]. +//! The poll seam is the structured +//! [`Verdict`](composable_cow::Verdict), carried by the +//! `composable-cow` keeper crate together with the quarantined revert +//! decoding; only orderbook concerns live here. //! //! The codec submodules stay purely host-neutral: helpers take //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can //! be unit-tested without wit-bindgen scaffolding and re-used //! unchanged by TWAP, EthFlow, and future strategy modules. The -//! keeper run is generic over the host traits alone. +//! keeper run is generic over the host traits and the venue transport +//! alone; [`CowApiTransport`] carries it over the legacy +//! `shepherd:cow/cow-api` import until the module worlds flip. -pub mod composable; pub mod error; -pub mod order; +pub mod events; pub mod run; +pub mod transport; -pub use composable::{IConditionalOrder, LegacyRevertAdapter, Verdict}; +/// Chain-edge order assembly, re-exported from the `cow-venue` +/// `assembly` slice the venue adapter owns. +pub use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, }; -pub use order::{gpv2_to_order_data, order_uid_hex}; pub use run::run; +pub use transport::CowApiTransport; /// The venue-neutral intent body types and their borsh `IntentBody` -/// codec, re-exported from the `cow-venue` default slice. The shim keeps -/// this path stable while the module ports move off the legacy surface. +/// codec, re-exported from the `cow-venue` default slice, plus the +/// typed CoW venue client. The shim keeps this path stable while the +/// module ports move off the legacy surface. pub use cow_venue::{ - BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, OrderKind, - SellTokenSource, + BuyToken, BuyTokenDestination, CowClient, CowIntent, CowIntentBody, CowVenue, OrderBody, + OrderBuilder, OrderKind, OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, }; use nexum_sdk::host::Host; -/// `shepherd:cow/cow-api` - orderbook submission path. The CoW-domain -/// sibling of the core host traits in [`nexum_sdk::host`]. +/// `shepherd:cow/cow-api` - the legacy orderbook submission path, +/// retiring. The keeper [`run()`] submits through the typed +/// [`CowClient`]; [`CowApiTransport`] bridges it onto this seam until +/// the module worlds flip to `videre:venue/client`. pub trait CowApiHost { /// Submit an `OrderCreation` JSON body. The host returns the /// canonical order UID on success. A rejection surfaces as a typed diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs deleted file mode 100644 index d0a79bfb..00000000 --- a/crates/shepherd-sdk/src/cow/order.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! `GPv2OrderData` -> `OrderData` bridging. -//! -//! ComposableCoW and CoWSwapEthFlow both emit / return the 12-field -//! `GPv2OrderData` Solidity tuple, with `kind` / `sellTokenBalance` / -//! `buyTokenBalance` as 32-byte keccak markers. The orderbook signs -//! against the typed `OrderData` shape, with those markers projected -//! into Rust enums. [`gpv2_to_order_data`] is the bridge. - -use alloy_primitives::Address; -use cowprotocol::{ - BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, -}; - -/// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the -/// typed [`OrderData`] shape `OrderCreation::new` expects. -/// -/// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride -/// the wire as `bytes32` markers (the `keccak256` of the lowercase -/// variant name). This helper hands them off to cowprotocol's -/// `from_contract_bytes` classifiers and returns `None` when the on- -/// chain payload carries a marker the SDK doesn't recognise - the -/// caller skips the order rather than ship a malformed body. -/// -/// `receiver = Address::ZERO` is normalised to `None`; -/// `OrderCreation::new` does the same downstream, but doing it here -/// keeps the EIP-712 hash inputs verbatim if a caller bypasses that -/// constructor later. -/// -/// # Example -/// -/// ``` -/// use cowprotocol::{ -/// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, -/// }; -/// use shepherd_sdk::cow::gpv2_to_order_data; -/// use nexum_sdk::prelude::{Address, U256}; -/// -/// let gpv2 = GPv2OrderData { -/// sellToken: Address::repeat_byte(1), -/// buyToken: Address::repeat_byte(2), -/// receiver: Address::ZERO, // normalised to None -/// sellAmount: U256::from(1_000u64), -/// buyAmount: U256::from(999u64), -/// validTo: u32::MAX, -/// appData: cowprotocol::EMPTY_APP_DATA_HASH, -/// feeAmount: U256::ZERO, -/// kind: OrderKind::SELL, -/// partiallyFillable: false, -/// sellTokenBalance: SellTokenSource::ERC20, -/// buyTokenBalance: BuyTokenDestination::ERC20, -/// }; -/// -/// let order = gpv2_to_order_data(&gpv2).expect("known markers"); -/// assert_eq!(order.sell_amount, U256::from(1_000u64)); -/// assert_eq!(order.receiver, None); -/// ``` -#[must_use] -pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { - Some(OrderData { - sell_token: gpv2.sellToken, - buy_token: gpv2.buyToken, - receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), - sell_amount: gpv2.sellAmount, - buy_amount: gpv2.buyAmount, - valid_to: gpv2.validTo, - app_data: gpv2.appData, - fee_amount: gpv2.feeAmount, - kind: OrderKind::from_contract_bytes(gpv2.kind)?, - partially_fillable: gpv2.partiallyFillable, - sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, - buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, - }) -} - -/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain -/// (order, owner, chain) tuple - the same value the orderbook derives -/// server-side from the signed payload, so a client can key -/// idempotency state before any network work. -/// -/// `None` when the chain id has no settlement domain or the order -/// carries an unknown enum marker. Only the unknown-marker case also -/// stops the submit path downstream ([`gpv2_to_order_data`] fails the -/// same way there); an unsupported chain id does not, so a caller -/// keying idempotency on this value alone re-submits until `validTo` -/// on such a chain - bounded, but callers adding new chains should -/// teach `cowprotocol::Chain` about them first. -#[must_use] -pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{B256, U256, address}; - - fn submittable_gpv2() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), - sellAmount: U256::from(1_000_000_u64), - buyAmount: U256::from(999_u64), - validTo: 0xffff_ffff, - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - } - } - - #[test] - fn happy_path_round_trips_markers() { - let g = submittable_gpv2(); - let od = gpv2_to_order_data(&g).expect("known markers"); - assert_eq!(od.sell_token, g.sellToken); - assert_eq!(od.buy_token, g.buyToken); - assert_eq!(od.kind, OrderKind::Sell); - assert_eq!(od.sell_token_balance, SellTokenSource::Erc20); - assert_eq!(od.buy_token_balance, BuyTokenDestination::Erc20); - } - - #[test] - fn zero_receiver_normalises_to_none() { - let mut g = submittable_gpv2(); - g.receiver = Address::ZERO; - assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, None); - } - - #[test] - fn non_zero_receiver_preserved() { - let g = submittable_gpv2(); - assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, Some(g.receiver)); - } - - #[test] - fn unknown_kind_marker_returns_none() { - let mut g = submittable_gpv2(); - g.kind = B256::repeat_byte(0x42); - assert!(gpv2_to_order_data(&g).is_none()); - } - - #[test] - fn unknown_sell_token_balance_returns_none() { - let mut g = submittable_gpv2(); - g.sellTokenBalance = B256::repeat_byte(0x99); - assert!(gpv2_to_order_data(&g).is_none()); - } - - #[test] - fn unknown_buy_token_balance_returns_none() { - let mut g = submittable_gpv2(); - g.buyTokenBalance = B256::repeat_byte(0x55); - assert!(gpv2_to_order_data(&g).is_none()); - } - - // ---- order_uid_hex ---- - - const SEPOLIA: u64 = 11_155_111; - - #[test] - fn uid_hex_is_deterministic_and_canonical_shape() { - let g = submittable_gpv2(); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); - // 56 bytes: 32 digest + 20 owner + 4 validTo. - assert_eq!(uid.len(), 2 + 112); - assert!(uid.starts_with("0x")); - assert!( - uid.to_lowercase() - .contains("00112233445566778899aabbccddeeff00112233",) - ); - assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); - } - - #[test] - fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { - let g = submittable_gpv2(); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); - - let mut bad = submittable_gpv2(); - bad.kind = B256::repeat_byte(0x42); - assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); - } -} diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index dbabd179..0a19bc8e 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -4,37 +4,43 @@ //! [`run`] walks the keeper watch set, polls each gate-ready //! watch through a [`ConditionalSource`], and runs the //! [`Verdict`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Post` drives one submission through the -//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` -//! journal as the idempotency guard and the keeper [`Retrier`] +//! watch stores, `Post` drives one submission through the typed +//! [`CowClient`] onto the `videre:venue/client` seam with the +//! `submitted:` journal as the idempotency guard - keyed on the +//! venue-and-body [`intent_id`] - and the keeper [`Retrier`] //! as the failure dispatch. //! //! Store faults abort the sweep (the next tick replays it); -//! submission failures never do - they classify into a -//! [`RetryAction`], the ledger applies the effect, and the sweep -//! moves on. Diagnostics go through the guest `tracing` facade - +//! submission failures never do - they fold into a +//! [`RetryAction`] through the videre +//! [`retry_action`] table, the ledger applies the effect, and the +//! sweep moves on. Diagnostics go through the guest `tracing` facade - //! the same channel strategy code logs on - so module tests observe //! the composed behaviour with one capture. -use alloy_primitives::{Address, Bytes}; -use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; -use nexum_sdk::host::Fault; +use alloy_primitives::{Address, Bytes, hex}; +use composable_cow::Verdict; +use cowprotocol::GPv2OrderData; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, }; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; use super::{ - CowApiError, CowHost, Verdict, classify_submit_error, gpv2_to_order_data, is_already_submitted, - order_uid_hex, + CowClient, CowIntent, CowIntentBody, SignedOrder, gpv2_to_order_data, intent_id, + order_data_to_body, }; /// Poll every gate-ready watch once at `tick` and run each outcome's /// effect. One source poll per ready watch; a `Post` outcome makes at -/// most one `submit_order` call. -pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +/// most one venue submit through `venue`. +pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> where - H: CowHost, + H: LocalStoreHost, S: ConditionalSource, + T: VenueTransport, { let watches = WatchSet::new(host); let gates = Gates::new(host); @@ -52,7 +58,7 @@ where Verdict::Post { order, signature, .. } => { - submit_ready(host, watch, &order, signature, tick, source.label())?; + submit_ready(host, venue, watch, &order, signature, tick, source.label())?; } Verdict::TryNextBlock { .. } => {} Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, @@ -71,21 +77,27 @@ where Ok(()) } -/// Submit one freshly-polled `Ready` order, guarding on the -/// `submitted:` journal and dispatching any failure through the retry -/// ledger. +/// Submit one freshly-polled `Ready` order through the typed client, +/// guarding on the `submitted:` journal and dispatching any venue +/// refusal through the retry ledger. /// -/// The UID is deterministic from on-chain inputs, so the idempotency -/// check runs before any network work; the same value keys the journal -/// marker after, so the read and write paths agree. -fn submit_ready( +/// The journal keys on the deterministic venue-and-body [`intent_id`], +/// derived before any network work from the same body bytes the venue +/// submit carries, so the guard is independent of where assembly +/// happens. The venue's receipt rides the log only. +fn submit_ready( host: &H, + venue: &CowClient, watch: WatchRef<'_>, order: &GPv2OrderData, signature: Bytes, tick: &Tick, label: &str, -) -> Result<(), Fault> { +) -> Result<(), Fault> +where + H: LocalStoreHost, + T: VenueTransport, +{ let Ok(owner) = watch.owner_hex().parse::

() else { tracing::warn!( "watch {} carries an unparseable owner; skipping submit", @@ -94,15 +106,6 @@ fn submit_ready( return Ok(()); }; - let journal = Journal::submitted(host); - let client_uid = order_uid_hex(tick.chain_id, order, owner); - if let Some(uid) = client_uid.as_deref() - && journal.contains(uid)? - { - tracing::info!("{label} {uid} already submitted; skipping re-submit"); - return Ok(()); - } - let Some(order_data) = gpv2_to_order_data(order) else { // An unknown enum marker means the SDK cannot express this // payload yet; skip rather than drop so an SDK upgrade can @@ -112,102 +115,74 @@ fn submit_ready( ); return Ok(()); }; - let creation = match build_order_creation(&order_data, signature, owner) { - Ok(creation) => creation, + + let intent = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + })); + let intent_id = match intent_id(&intent) { + Ok(id) => id, Err(err) => { - // A constructor rejection (zero `from`, `validTo` beyond - // the client-side max horizon) is deterministic for this - // polled payload: keeping the watch would re-poll and - // re-warn on every block forever. Drop through the ledger - // - the same net effect as the pre-keeper flow, where - // the orderbook rejected the shipped body and the - // classifier dropped the watch. - tracing::warn!("{label} submit dropped watch for {owner:#x}: {err}"); - Retrier::new(host).apply(watch, RetryAction::Drop, tick.epoch_s)?; - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(body) => body, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); + tracing::error!("intent body encode failed: {err}"); return Ok(()); } }; + let journal = Journal::submitted(host); + if journal.contains(&intent_id)? { + tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); + return Ok(()); + } - match host.submit_order(tick.chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID so the guard above reads - // what this writes; a divergence would be a protocol bug - // worth a warning, never a silently split keyspace. - let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; a pending future means a + // foreign transport misbehaved. The watch stays for the next + // tick. + tracing::error!("{label} submit future suspended; retrying next tick"); + return Ok(()); + }; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the // next tick's re-post idempotent. - if let Err(fault) = journal.record(marker) { - tracing::error!("submitted {marker} but journal write failed: {fault}"); - } - if let Some(client) = client_uid.as_deref() - && client != server_uid - { - tracing::warn!( - "{label} UID divergence: client={client} server={server_uid} \ - (marker keyed on the client UID)" - ); - } - tracing::info!("submitted {marker}"); - } - Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { - // Success wearing an error status: the orderbook already - // holds this order. Record the receipt and keep the watch - // so the next tick short-circuits instead of re-posting. - // As above, a journal fault post-submit only forfeits the - // short-circuit; it must not abort the sweep. - if let Some(uid) = client_uid.as_deref() - && let Err(fault) = journal.record(uid) - { - tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); + if let Err(fault) = journal.record(&intent_id) { + tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } tracing::info!( - "orderbook already holds this order ({}); receipt recorded", - rejection.error_type, + "submitted {intent_id} (receipt {})", + hex::encode_prefixed(&receipt), ); } - Err(err) => { - let action = classify_submit_error(&err); + Ok(SubmitOutcome::RequiresSigning(_)) => { + // A sweep cannot sign; nothing is journalled, so the next + // tick surfaces the same ask afresh. + tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); + } + Err(ClientError::Body(err)) => { + tracing::error!("intent body encode failed: {err}"); + } + Err(ClientError::Venue(fault)) => { + let action = retry_action(&fault); Retrier::new(host).apply(watch, action, tick.epoch_s)?; match action { - RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), RetryAction::Backoff { seconds } => { - tracing::warn!("submit backoff {seconds}s: {err}"); + tracing::warn!("submit backoff {seconds}s: {fault}"); } - RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), // `RetryAction` is non-exhaustive; the ledger already // ran the effect, so the log needs only the name. other => { let action_label: &'static str = other.into(); - tracing::warn!("submit retry action {action_label}: {err}"); + tracing::warn!("submit retry action {action_label}: {fault}"); } } } + // `ClientError` is non-exhaustive; a future case leaves the + // watch for the next tick. + Err(err) => tracing::error!("submit failed: {err}"), } Ok(()) } - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// polled conditional order. The signed `appData` digest goes out -/// verbatim in the hash-only wire shape (watch-tower parity), and the -/// signature is EIP-1271 - the conditional-order contract is the -/// verifier. -/// -/// An `Err` is a client-side precondition failure that would recur on -/// every retry of the same payload; the caller drops the watch. -fn build_order_creation( - order_data: &OrderData, - signature: Bytes, - from: Address, -) -> Result { - let signature = Signature::Eip1271(signature.to_vec()); - OrderCreation::new_app_data_hash_only(order_data, signature, from, None) -} diff --git a/crates/shepherd-sdk/src/cow/transport.rs b/crates/shepherd-sdk/src/cow/transport.rs new file mode 100644 index 00000000..ece99f70 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/transport.rs @@ -0,0 +1,226 @@ +//! Transitional venue transport over the legacy `shepherd:cow/cow-api` +//! seam. +//! +//! [`CowApiTransport`] implements the videre [`VenueTransport`] +//! contract by assembling the orderbook `OrderCreation` from the +//! decoded [`CowIntentBody`] and driving +//! [`CowApiHost::submit_order`], so the keeper [`run`](super::run()) +//! submits through the typed [`CowClient`](super::CowClient) while +//! module worlds still import the legacy host extension. Deleted when +//! the worlds flip to `videre:venue/client`. + +use alloy_primitives::{Address, hex}; +use cow_venue::assembly; +use cow_venue::body::{CowIntent, CowIntentBody}; +use cowprotocol::Chain; +use nexum_sdk::host::Fault; +use nexum_sdk::keeper::RetryAction; +use videre_sdk::client::sealed::SealedTransport; +use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, VenueFault, VenueId, VenueTransport, +}; + +use super::{CowApiError, CowApiHost, classify_api_error, is_already_submitted}; + +/// The `videre:venue/client` verbs carried over the legacy +/// `shepherd:cow/cow-api` import: submit only, pre-bound to one chain's +/// orderbook. Quote, status, and cancel have no legacy submission-path +/// counterpart and refuse as `unsupported`. +pub struct CowApiTransport<'h, H> { + host: &'h H, + chain_id: u64, +} + +impl<'h, H: CowApiHost> CowApiTransport<'h, H> { + /// Bind the legacy seam to one chain's orderbook. + #[must_use] + pub const fn new(host: &'h H, chain_id: u64) -> Self { + Self { host, chain_id } + } +} + +impl SealedTransport for CowApiTransport<'_, H> {} + +impl VenueTransport for CowApiTransport<'_, H> { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + Err(VenueFault::Unsupported) + } + + async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + let CowIntentBody::V1(intent) = + CowIntentBody::from_bytes(&body).map_err(|e| VenueFault::InvalidBody(e.to_string()))?; + // The legacy seam posts EIP-1271 only; the pre-sign flow needs + // the adapter. + let CowIntent::Signed(signed) = intent else { + return Err(VenueFault::Unsupported); + }; + let order = assembly::body_to_order_data(&signed.order); + let owner = Address::from(signed.owner); + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueFault::InvalidBody(e.to_string()))?; + let json = serde_json::to_vec(&creation) + .map_err(|e| VenueFault::Unavailable(format!("order encode failed: {e}")))?; + match self.host.submit_order(self.chain_id, &json) { + Ok(uid) => Ok(SubmitOutcome::Accepted(receipt_bytes(&uid))), + // Already-held is success wearing an error status; the + // receipt is the client-derived UID (empty on a chain the + // SDK cannot derive for). + Err(CowApiError::Rejected(r)) if is_already_submitted(&r) => { + let receipt = Chain::try_from(self.chain_id) + .map(|chain| { + assembly::order_uid(chain, &order, owner) + .as_slice() + .to_vec() + }) + .unwrap_or_default(); + Ok(SubmitOutcome::Accepted(receipt)) + } + Err(err) => Err(venue_fault(&err)), + } + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + Err(VenueFault::Unsupported) + } +} + +/// The server UID at its wire spelling; a non-hex receipt rides through +/// as raw bytes rather than failing an accepted submit. +fn receipt_bytes(uid: &str) -> Vec { + hex::decode(uid).unwrap_or_else(|_| uid.as_bytes().to_vec()) +} + +/// Project a legacy submission failure onto the venue fault the typed +/// client reports, mirroring the adapter: throttles keep their hint, +/// host and server failures stay retryable, and only a structured +/// rejection, folded through the shipped classification table, carries +/// a permanent venue verdict. +fn venue_fault(err: &CowApiError) -> VenueFault { + match err { + CowApiError::Fault(Fault::RateLimited(limit)) => VenueFault::RateLimited { + retry_after_ms: limit.retry_after_ms, + }, + CowApiError::Fault(Fault::Timeout) => VenueFault::Timeout, + // Any other host fault is infrastructure, not a venue verdict: + // it stays retryable so an unprovisioned capability or unknown + // chain never drops a still-valid order. + CowApiError::Fault(fault) => VenueFault::Unavailable(fault.to_string()), + CowApiError::Http(http) if http.status == 429 => VenueFault::RateLimited { + retry_after_ms: None, + }, + CowApiError::Http(http) => { + VenueFault::Unavailable(format!("orderbook http {}", http.status)) + } + CowApiError::Rejected(rejection) => { + let detail = format!("{}: {}", rejection.error_type, rejection.description); + match classify_api_error(rejection) { + RetryAction::TryNextBlock => VenueFault::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueFault::RateLimited { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }, + _ => VenueFault::Denied(detail), + } + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host::RateLimit; + use videre_sdk::keeper::retry_action; + + use super::super::{HttpFailure, OrderRejection}; + use super::*; + + fn rejected(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "d".into(), + data: None, + }) + } + + #[test] + fn legacy_failures_project_onto_the_venue_fault_by_shape() { + assert!(matches!( + venue_fault(&rejected("InsufficientFee")), + VenueFault::Unavailable(detail) if detail.contains("InsufficientFee") + )); + assert!(matches!( + venue_fault(&rejected("TooManyLimitOrders")), + VenueFault::RateLimited { + retry_after_ms: Some(30_000) + } + )); + assert!(matches!( + venue_fault(&rejected("InvalidSignature")), + VenueFault::Denied(detail) if detail.contains("InvalidSignature") + )); + assert!(matches!( + venue_fault(&CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + }))), + VenueFault::RateLimited { + retry_after_ms: Some(2_500) + } + )); + assert!(matches!( + venue_fault(&CowApiError::Fault(Fault::Timeout)), + VenueFault::Timeout + )); + assert!(matches!( + venue_fault(&CowApiError::Http(HttpFailure { + status: 429, + body: None, + })), + VenueFault::RateLimited { + retry_after_ms: None + } + )); + assert!(matches!( + venue_fault(&CowApiError::Http(HttpFailure { + status: 502, + body: None, + })), + VenueFault::Unavailable(_) + )); + } + + #[test] + fn host_faults_stay_retryable_and_never_drop_the_watch() { + for fault in [ + Fault::Unsupported("cow-api not provisioned".into()), + Fault::Denied("allowlist".into()), + Fault::Unavailable("rpc down".into()), + Fault::Internal("host bug".into()), + Fault::InvalidInput("mangled".into()), + ] { + let projected = venue_fault(&CowApiError::Fault(fault)); + assert!(matches!(projected, VenueFault::Unavailable(_))); + assert_eq!(retry_action(&projected), RetryAction::TryNextBlock); + } + assert_eq!( + retry_action(&venue_fault(&CowApiError::Fault(Fault::Timeout))), + RetryAction::TryNextBlock + ); + assert_eq!( + retry_action(&venue_fault(&CowApiError::Fault(Fault::RateLimited( + RateLimit { + retry_after_ms: Some(2_500), + } + )))), + RetryAction::Backoff { seconds: 3 } + ); + } + + #[test] + fn server_uid_decodes_to_wire_bytes_with_a_raw_fallback() { + assert_eq!(receipt_bytes("0xc0ffee"), vec![0xC0, 0xFF, 0xEE]); + assert_eq!(receipt_bytes("not-hex"), b"not-hex".to_vec()); + } +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 491e7752..2201ad35 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -13,15 +13,16 @@ //! [`OrderData`], [`OrderUid`], [`OrderKind`], [`Signature`], //! [`Chain`], [`GPv2OrderData`], [`EMPTY_APP_DATA_JSON`]). //! -//! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` -//! (and the [`CowHost`] bound over the core [`Host`]), -//! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! the structured poll seam ([`Verdict`]) with the deployed 1.x -//! revert decoding quarantined behind [`LegacyRevertAdapter`], the -//! classifiers mapping submit failures into -//! the keeper [`RetryAction`], and [`run`] - the poll -> -//! outcome -> gate/journal/submit composition over the keeper -//! stores. +//! - [`cow`] - [`run`], the poll -> outcome -> gate/journal/submit +//! composition over the keeper stores, dispatching the structured +//! [`Verdict`] from the `composable-cow` keeper crate and submitting +//! through the typed [`CowClient`] on the `videre:venue/client` +//! seam; `GPv2OrderData` -> `OrderData` bridging +//! ([`gpv2_to_order_data`]) and the classifiers mapping submit +//! failures into the keeper [`RetryAction`]. The legacy +//! [`CowApiHost`] trait for `shepherd:cow/cow-api` (and the +//! [`CowHost`] bound over the core [`Host`]) stays for the read +//! paths and the transitional [`CowApiTransport`] bridge. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -48,10 +49,11 @@ //! [`EMPTY_APP_DATA_JSON`]: cowprotocol::EMPTY_APP_DATA_JSON //! [`CowApiHost`]: cow::CowApiHost //! [`CowHost`]: cow::CowHost +//! [`CowClient`]: cow::CowClient +//! [`CowApiTransport`]: cow::CowApiTransport //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`Verdict`]: cow::Verdict -//! [`LegacyRevertAdapter`]: cow::LegacyRevertAdapter +//! [`Verdict`]: composable_cow::Verdict //! [`RetryAction`]: cow::RetryAction //! [`run`]: cow::run() diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index 6d5c1453..c300c65f 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -4,29 +4,16 @@ //! //! Covered here: //! -//! - `LegacyRevertAdapter::decode` selector dispatch (no-panic guard). //! - `gpv2_to_order_data` marker mapping (no-panic guard). //! //! The generic properties (`eth_call` round-trip, `scale_decimal`) -//! live in `nexum-sdk`. +//! live in `nexum-sdk`; the revert-decode guard lives in +//! `composable-cow`. #![cfg(test)] use proptest::prelude::*; -proptest! { - /// `LegacyRevertAdapter::decode` on arbitrary revert bytes must - /// never panic and must return `None` for inputs shorter than the - /// 4-byte EVM selector. - #[test] - fn legacy_revert_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::LegacyRevertAdapter::decode(&bytes); - if bytes.len() < 4 { - prop_assert!(outcome.is_none()); - } - } -} - proptest! { /// `gpv2_to_order_data` is exhaustive over the marker enum; /// fuzzing the inputs as raw u8 (not the typed enum) is the only @@ -45,7 +32,7 @@ proptest! { // We do not call `gpv2_to_order_data` here because building // a `GPv2OrderData` requires a full alloy-sol-encoded struct // and the generators for that are extensive. The property - // test for the marker dispatch lives in `cow::order::tests` + // test for the marker dispatch lives in `cow_venue::assembly` // example-based; this proptest stands in as a no-panic // guard for the inputs the strategy ABI can produce. } diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs index 0a4c9b14..ff4b7e5d 100644 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ b/crates/shepherd-sdk/src/wit_bindgen_macro.rs @@ -2,11 +2,10 @@ //! CoW modules: the generic adapter plus the `CowApiHost` impl. //! //! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the -//! core adapter (`WitBindgenHost`, the `ChainHost` / `LocalStoreHost` -//! / `LoggingHost` impls, the error and level converters, and the -//! tracing wiring). This macro invokes it and adds the -//! [`CowApiHost`](crate::cow::CowApiHost) impl over the -//! `shepherd:cow/cow-api` import shims. +//! core adapter (`WitBindgenHost`, the six core host trait impls, the +//! fault and level `From` impls, and the tracing wiring). This macro +//! invokes it and adds the [`CowApiHost`](crate::cow::CowApiHost) +//! impl over the `shepherd:cow/cow-api` import shims. //! //! The macro assumes the module compiles against `shepherd:cow/shepherd` //! with `wit_bindgen::generate!({ ..., generate_all })`, so both the @@ -37,7 +36,7 @@ macro_rules! bind_cow_host_via_wit_bindgen { fn convert_cow_err(e: shepherd::cow::cow_api::CowApiError) -> $crate::cow::CowApiError { match e { shepherd::cow::cow_api::CowApiError::Fault(f) => { - $crate::cow::CowApiError::Fault(convert_fault(f)) + $crate::cow::CowApiError::Fault(::core::convert::Into::into(f)) } shepherd::cow::cow_api::CowApiError::Http(h) => { $crate::cow::CowApiError::Http($crate::cow::HttpFailure { diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index cda235ba..f4e32081 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -7,15 +7,25 @@ use std::cell::Cell; use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{CowApiError, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowApiTransport, CowClient, CowIntent, CowIntentBody, CowVenue, OrderRejection, + SignedOrder, gpv2_to_order_data, order_data_to_body, run, +}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; +/// The typed client every test drives `run` with: the transitional +/// cow-api bridge over the composed mock host. +fn venue(host: &MockHost) -> CowClient> { + CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +} + /// Closure-backed source so each test scripts its own outcome and /// observes its own poll calls. struct FnSource(F); @@ -98,8 +108,16 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -fn client_uid(order: &GPv2OrderData) -> String { - order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") } // ---- lifecycle outcomes ---- @@ -112,6 +130,7 @@ fn try_next_block_leaves_the_store_untouched() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) @@ -129,6 +148,7 @@ fn try_on_block_sets_the_block_gate() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::WaitBlock { wait_until: 2_000, reason: [0; 4], @@ -151,6 +171,7 @@ fn try_at_epoch_sets_the_epoch_gate() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::WaitTimestamp { wait_until: 1_800_000_000, reason: [0; 4], @@ -174,6 +195,7 @@ fn invalid_removes_the_watch_and_its_gates() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) @@ -195,6 +217,7 @@ fn gated_watch_is_not_polled() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -214,6 +237,7 @@ fn malformed_watch_rows_are_skipped() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -228,30 +252,30 @@ fn malformed_watch_rows_are_skipped() { // ---- ready -> submission ---- #[test] -fn ready_submits_once_and_journals_the_client_uid() { +fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok(client_uid(&order))); + host.cow_api.respond(Ok("0xserveruid".to_string())); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "submitted:{{client_uid}} receipt must be recorded", + "submitted:{{intent_id}} marker must be recorded", ); assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); } #[test] -fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { +fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); @@ -261,30 +285,29 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); - result.unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); assert!( !snapshot.contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID", + "marker must key on the pre-submit intent-id, not the server receipt", ); - assert!(logs.any(|e| e.message.contains("UID divergence"))); } #[test] -fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { +fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); Journal::submitted(&host) - .record(&client_uid(&order)) + .record(&intent_id(&order)) .unwrap(); let polls = Cell::new(0_u32); run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) @@ -310,6 +333,7 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -333,7 +357,7 @@ fn ready_beyond_the_valid_to_horizon_drops_the_watch() { order.validTo = valid_to_in(2 * 365 * 24 * 3_600); let source = src(move |_, _, _, _| ready_outcome(&order)); - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + let (result, logs) = capture_tracing(|| run(&host, &venue(&host), &source, &sample_tick())); result.unwrap(); assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); @@ -367,6 +391,7 @@ fn transient_rejection_keeps_the_watch_ungated() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -391,6 +416,7 @@ fn permanent_rejection_drops_the_watch_through_the_ledger() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -416,21 +442,59 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert!(host.store.snapshot().contains_key(&key)); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "already-submitted must record the receipt", + "already-submitted must journal the intent-id", ); // The next tick must not touch the orderbook again. - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); } +/// Restart regression: a keeper that posted, journalled, and then +/// restarted over the same persistent local store must not post the +/// same order again - one orderbook POST across both lives. +#[test] +fn restart_with_a_journalled_intent_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xserveruid".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // A restarted keeper: fresh instance, the local store carried over. + let restarted = MockHost::new(); + for (key, value) in host.store.snapshot() { + restarted.store.set(&key, &value).unwrap(); + } + restarted.cow_api.respond(Ok("0xserveruid".to_string())); + + run(&restarted, &venue(&restarted), &source, &sample_tick()).unwrap(); + + assert_eq!( + host.cow_api.call_count() + restarted.cow_api.call_count(), + 1, + "resubmit after restart must make no second orderbook POST", + ); + assert!( + Journal::submitted(&restarted) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + /// A rate-limit fault with server guidance backs the watch off on the /// epoch clock - `RetryAction::Backoff` reached through the ledger. #[test] @@ -445,7 +509,13 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { })))); let tick = sample_tick(); - run(&host, &src(move |_, _, _, _| ready_outcome(&order)), &tick).unwrap(); + run( + &host, + &venue(&host), + &src(move |_, _, _, _| ready_outcome(&order)), + &tick, + ) + .unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&key), "backoff must keep the watch"); @@ -456,3 +526,93 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { ); assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } + +// ---- the generic seam ---- + +/// The seam proof: a `Post` verdict reaches the venue transport as the +/// encoded `CowIntentBody` under the CoW venue id, the journal keys on +/// the generic submission key, and the legacy cow-api seam is never +/// touched. +#[test] +fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { + use std::cell::RefCell; + + use videre_sdk::keeper::submission_key; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + VenueTransport, + }; + + /// Records the venue and wire bytes of every submit. + struct SpyTransport { + calls: RefCell)>>, + } + + impl videre_sdk::client::sealed::SealedTransport for &SpyTransport {} + + impl VenueTransport for &SpyTransport { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.calls.borrow_mut().push((venue.to_string(), body)); + Ok(SubmitOutcome::Accepted(vec![0xAA])) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let spy = SpyTransport { + calls: RefCell::new(Vec::new()), + }; + let client = CowClient::with_transport(&spy); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client, &source, &sample_tick()).unwrap(); + + let order_data = gpv2_to_order_data(&order).expect("known markers"); + let expected = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + })) + .to_bytes() + .expect("body encodes"); + + let calls = spy.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, CowVenue::ID.as_str()); + assert_eq!(calls[0].1, expected, "the wire carries the intent body"); + assert!( + Journal::submitted(&host) + .contains(&submission_key(&CowVenue::ID, &expected)) + .unwrap(), + "the journal keys on the generic submission key", + ); + assert_eq!( + host.cow_api.call_count(), + 0, + "the legacy cow-api seam is never touched", + ); +} diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml new file mode 100644 index 00000000..2b5ed588 --- /dev/null +++ b/crates/shepherd/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "shepherd" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "shepherd" +path = "src/main.rs" + +[dependencies] +nexum-launch = { path = "../nexum-launch" } +nexum-runtime = { path = "../nexum-runtime" } +shepherd-cow-host = { path = "../shepherd-cow-host" } +videre-host = { path = "../videre-host" } + +anyhow.workspace = true +tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs new file mode 100644 index 00000000..f7c629cd --- /dev/null +++ b/crates/shepherd/src/main.rs @@ -0,0 +1,69 @@ +//! The `shepherd` binary: the cow composition root. Binds the reference +//! lattice with the cow-api extension payload in the `Ext` slot, registers +//! the videre venue platform, and hands it all to the generic launcher; +//! the engine itself stays venue- and cow-free. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +use std::sync::Arc; + +use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::{ + ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, +}; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::host::local_store_redb::LocalStore; +use nexum_runtime::host::provider_pool::ProviderPool; +use nexum_runtime::preset::Runtime; +use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; + +/// The reference lattice: the core backends with the cow-api payload in +/// the `Ext` slot. +#[derive(Debug, Clone, Copy, Default)] +struct ReferenceTypes; + +impl nexum_runtime::sealed::SealedRuntimeTypes for ReferenceTypes {} + +impl RuntimeTypes for ReferenceTypes { + type Chain = ProviderPool; + type Store = LocalStore; + type Ext = ReferenceExt; +} + +/// The cow preset: reference backends, the videre venue platform, the +/// cow-api extension, and the Prometheus add-on. +#[derive(Debug, Clone, Copy, Default)] +struct ShepherdRuntime; + +impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} + +impl Runtime for ShepherdRuntime { + type Types = ReferenceTypes; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = ReferenceExtBuilder; + type LogsBuilder = LogPipelineBuilder; + + fn components( + self, + ) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ReferenceExtBuilder) + } + + fn add_ons(&self) -> AddOns { + vec![Box::new(PrometheusAddOn)] + } + + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![ + Arc::new(videre_host::platform(config)), + extension::(), + ] + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nexum_launch::run("shepherd", ShepherdRuntime).await +} diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml new file mode 100644 index 00000000..8db90362 --- /dev/null +++ b/crates/videre-host/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "videre-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The videre venue platform as one nexum-runtime extension: the venue-adapter provider kind, the venue registry service, the advisory egress-guard seam, and the videre:venue/client interface." + +[lints] +workspace = true + +[dependencies] +# The runtime seam this crate plugs into; the only nexum crate edge. +nexum-runtime = { path = "../nexum-runtime" } +# Encoder for the opaque status body the host event stream carries; the +# registry lowers an adapter-reported status through it. +nexum-status-body = { path = "../nexum-status-body" } + +wasmtime.workspace = true +anyhow.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +serde.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true + +[dev-dependencies] +nexum-runtime = { path = "../nexum-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../nexum-tasks" } +tempfile.workspace = true diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs new file mode 100644 index 00000000..06e2b887 --- /dev/null +++ b/crates/videre-host/src/bindings.rs @@ -0,0 +1,275 @@ +//! WIT bindings for the venue platform, generated by +//! `wasmtime::component::bindgen!`. +//! +//! The shared `nexum:host` interfaces are reused from the runtime's +//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` +//! impls and the `fault` type an adapter sees are the very ones the core +//! host constructs. The `videre:types` and `videre:value-flow` types +//! generate in the adapter bindgen and the client bindgen remaps onto +//! them, 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. + +/// The provider face: the `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) and +/// exports the `videre:venue/adapter` face plus `init`. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + world: "videre:venue/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": nexum_runtime::bindings::nexum::host::types, + "nexum:host/chain": nexum_runtime::bindings::nexum::host::chain, + "nexum:host/messaging": nexum_runtime::bindings::nexum::host::messaging, + }, + additional_derives: [PartialEq], + }); +} + +pub use venue_adapter::VenueAdapter; + +/// 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. Async, because the +/// `Host` impl awaits the per-adapter mutex and the adapter's own async +/// guest calls. +mod client_host { + wasmtime::component::bindgen!({ + inline: " + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + imports: { default: async }, + with: { + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, + }, + }); +} + +/// The host-bound client interface: the `Host` trait the registry implements +/// and the `add_to_linker` the videre extension's linker hook calls. +pub use client_host::videre::venue::client; +/// 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, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Operator-log rendering of the wire `venue-error`: the bindgen +/// `Display` is the `{0:?}` debug form, so logs render through this +/// instead and the rate-limit `retry-after-ms` hint survives. +pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + match err { + VenueError::UnknownVenue => Cow::Borrowed("unknown venue"), + VenueError::InvalidBody(detail) => Cow::Owned(format!("invalid body: {detail}")), + VenueError::Unsupported => Cow::Borrowed("unsupported"), + VenueError::Denied(detail) => Cow::Owned(format!("denied: {detail}")), + VenueError::RateLimited(rate_limit) => match rate_limit.retry_after_ms { + Some(ms) => Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => Cow::Borrowed("rate limited"), + }, + VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), + VenueError::Timeout => Cow::Borrowed("timeout"), + } +} + +#[cfg(test)] +mod display_smoke { + use super::{RateLimit, VenueError, venue_error_message}; + + #[test] + fn venue_error_message_keeps_the_rate_limit_hint() { + let hinted = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!( + venue_error_message(&hinted), + "rate limited, retry after 250 ms" + ); + let unhinted = VenueError::RateLimited(RateLimit { + retry_after_ms: None, + }); + assert_eq!(venue_error_message(&unhinted), "rate limited"); + } +} + +/// 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 videre:value-flow-smoke; + world smoke { + import videre:value-flow/types@0.1.0; + } + ", + path: ["../../wit/videre-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; + + let erc20 = Erc20 { + token: vec![0u8; 20], + }; + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} + +/// 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 five function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[cfg(test)] +mod client_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:client-smoke; + world smoke { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + }); + + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, + }; + use videre::value_flow::types::{Asset, AssetAmount}; + + struct DummyClient; + + impl videre::venue::client::Host for DummyClient { + fn quote(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn observe(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + + #[test] + fn identifiers_bind_unescaped() { + use videre::venue::client::Host; + + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Eip712; + + let header = IntentHeader { + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }; + assert!(header.wants.amount.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Fulfilled; + let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; + + let tx = UnsignedTx { + chain: 1, + to: Vec::new(), + value: Vec::new(), + data: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let quotation = Quotation { + gives: amount(vec![1]), + wants: amount(Vec::new()), + fee: amount(Vec::new()), + valid_until_ms: 0, + }; + assert!(quotation.fee.amount.is_empty()); + + let _ = VenueError::UnknownVenue; + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::Unsupported; + let _ = VenueError::Denied(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::Timeout; + + let mut client = DummyClient; + assert!(client.quote(String::new(), Vec::new()).is_err()); + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.observe(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/videre-host/src/client.rs b/crates/videre-host/src/client.rs new file mode 100644 index 00000000..cbe27e70 --- /dev/null +++ b/crates/videre-host/src/client.rs @@ -0,0 +1,56 @@ +//! `videre:venue/client`: the keeper-facing venue import. Every method +//! resolves the shared [`VenueRegistry`] from the store's service map under +//! the videre namespace and delegates; the registry owns the venue +//! resolution, per-adapter serialisation, guard seam (advisory-only for +//! now), and quota. The caller identity the registry meters against is this +//! store's module namespace. No registry service means no venues, so every +//! call resolves to `unknown-venue`. The `Host` trait is local to this +//! crate's bindgen, so implementing it for the runtime's `HostState` is +//! orphan-legal. + +use std::sync::Arc; + +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::state::HostState; + +use crate::bindings::client::Host; +use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; +use crate::registry::{VenueId, VenueRegistry}; + +/// The registry published under the videre service namespace. +fn registry(state: &HostState) -> Result, VenueError> { + state + .services + .get::(VenueRegistry::NAMESPACE) + .ok_or(VenueError::UnknownVenue) +} + +impl Host for HostState { + async fn quote(&mut self, venue: String, body: Vec) -> Result { + registry(self)? + .quote(&self.run.module, &VenueId::from(venue), body) + .await + } + + async fn submit(&mut self, venue: String, body: Vec) -> Result { + registry(self)? + .submit(&self.run.module, &VenueId::from(venue), body) + .await + } + + async fn observe(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + registry(self)?.observe(&VenueId::from(venue), receipt) + } + + async fn status( + &mut self, + venue: String, + receipt: Vec, + ) -> Result { + registry(self)?.status(&VenueId::from(venue), receipt).await + } + + async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + registry(self)?.cancel(&VenueId::from(venue), receipt).await + } +} diff --git a/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs new file mode 100644 index 00000000..a1aa23d7 --- /dev/null +++ b/crates/videre-host/src/handshake.rs @@ -0,0 +1,285 @@ +//! Install-time body-version handshake over the `[venue]` manifest +//! section: a keeper declares the one body-schema version it encodes, +//! an adapter the set it decodes, and a keeper boots only when every +//! installed venue adapter decodes its version, since any of them is a +//! legal runtime submit target. + +use std::collections::BTreeSet; + +use anyhow::{anyhow, bail}; +use nexum_runtime::host::extension::ProviderManifest; +use nexum_runtime::manifest::ExtensionSections; +use serde::Deserialize; +use tracing::error; + +use crate::registry::VenueAdapterKind; + +/// The manifest section the videre platform claims. +pub(crate) const SECTION: &str = "venue"; + +/// The claimed-section list handed to the runtime. +pub(crate) const SECTIONS: &[&str] = &[SECTION]; + +/// Keeper-side `[venue]`: the one body-schema version it encodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KeeperSection { + body_version: u32, +} + +/// Adapter-side `[venue]`: the body-schema versions it decodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterSection { + body_versions: BTreeSet, +} + +/// Parse one `[venue]` value as `S`, tagging failures with the owner. +fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyhow::Result { + value + .clone() + .try_into() + .map_err(|e| anyhow!("{owner} [venue]: {e}")) +} + +/// Admit one provider: a `[venue]` section, when present, must be the +/// adapter shape with a non-empty version set. +pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let section: AdapterSection = parse(provider, value)?; + if section.body_versions.is_empty() { + bail!("{provider} [venue]: body_versions must not be empty"); + } + Ok(()) +} + +/// An adapter's declared decode set: its `[venue] body_versions`, empty +/// when the section is absent. +pub(crate) fn declared_versions( + provider: &str, + sections: &ExtensionSections, +) -> anyhow::Result> { + let Some(value) = sections.get(SECTION) else { + return Ok(BTreeSet::new()); + }; + let section: AdapterSection = parse(provider, value)?; + Ok(section.body_versions) +} + +/// Assert an adapter's `body-versions()` export equals its manifest +/// claim, refusing the install on divergence so the two sources of the +/// decode set cannot drift. +pub(crate) fn verify_exported_versions( + provider: &str, + declared: &BTreeSet, + exported: Vec, +) -> anyhow::Result<()> { + let exported: BTreeSet = exported.into_iter().collect(); + if exported != *declared { + bail!( + "{provider} exports body versions {exported:?}; the manifest [venue] \ + body_versions declares {declared:?}" + ); + } + Ok(()) +} + +/// The membership install predicate: a worker declaring `[venue] +/// body_version` is admitted only when every installed venue adapter's +/// `[venue] body_versions` set contains that version. Every installed +/// venue is a legal runtime submit target, so one non-decoding adapter +/// refuses the keeper. +pub(crate) fn admit_worker( + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], +) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let KeeperSection { body_version } = parse(worker, value)?; + let adapters: Vec<&ProviderManifest> = providers + .iter() + .filter(|p| p.kind == VenueAdapterKind::KIND) + .collect(); + if adapters.is_empty() { + return Err(refuse( + worker, + body_version, + "no venue adapter declares [venue] body_versions", + )); + } + for provider in adapters { + let Some(value) = provider.sections.get(SECTION) else { + return Err(refuse( + worker, + body_version, + &format!("{} declares no [venue] body_versions", provider.name), + )); + }; + let section: AdapterSection = parse(&provider.name, value)?; + if !section.body_versions.contains(&body_version) { + return Err(refuse( + worker, + body_version, + &format!("{} decodes {:?}", provider.name, section.body_versions), + )); + } + } + Ok(()) +} + +/// Log and build the refusal for one keeper/adapter pairing. +fn refuse(worker: &str, body_version: u32, decoded: &str) -> anyhow::Error { + error!( + keeper = %worker, + body_version, + %decoded, + "body-version handshake refused the keeper/adapter pair", + ); + anyhow!("keeper {worker} encodes body version {body_version}; {decoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sections(toml: &str) -> ExtensionSections { + let table: toml::Table = toml.parse().expect("parse"); + table.into_iter().collect() + } + + fn adapter(name: &str, toml: &str) -> ProviderManifest { + ProviderManifest { + name: name.to_owned(), + kind: VenueAdapterKind::KIND, + sections: sections(toml), + } + } + + /// A keeper whose version every installed adapter decodes is admitted. + #[test] + fn matching_pair_is_admitted() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [2, 3]"), + ]; + admit_worker("keeper", &keeper, &adapters).expect("admitted"); + } + + /// A keeper whose version no adapter decodes is refused, and the + /// refusal names the version and the declared set. + #[test] + fn mismatched_pair_is_refused() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [adapter("cow", "[venue]\nbody_versions = [1]")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("cow decodes {1}"), "{msg}"); + } + + /// Membership is a conjunction over the installed adapters: one + /// non-decoding adapter refuses the keeper even when another decodes + /// its version, since either is a legal runtime submit target. + #[test] + fn one_non_decoding_adapter_refuses_the_keeper() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [1]"), + ]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("uni decodes {1}"), "{msg}"); + } + + /// A declaring keeper with no installed venue adapter is refused. + #[test] + fn undeclared_adapters_refuse_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let err = admit_worker("keeper", &keeper, &[]).expect_err("refused"); + assert!(err.to_string().contains("no venue adapter declares")); + } + + /// An installed adapter without a `[venue]` section refuses a + /// declaring keeper: its decode set is undeclared, not universal. + #[test] + fn a_section_less_adapter_refuses_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let adapters = [adapter("cow", "")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + assert!(err.to_string().contains("cow declares no [venue]")); + } + + /// A provider of another kind never satisfies the membership check. + #[test] + fn other_provider_kinds_are_ignored() { + let keeper = sections("[venue]\nbody_version = 1"); + let mut other = adapter("oracle", "[venue]\nbody_versions = [1]"); + other.kind = "price-oracle"; + admit_worker("keeper", &keeper, &[other]).expect_err("refused"); + } + + /// Workers and providers without a `[venue]` section are admitted. + #[test] + fn undeclared_sections_are_admitted() { + admit_worker("keeper", &ExtensionSections::new(), &[]).expect("worker admitted"); + admit_provider("venue", &ExtensionSections::new()).expect("provider admitted"); + } + + /// The wrong-side spelling fails loudly on both faces. + #[test] + fn wrong_side_spelling_is_refused() { + let keeper = sections("[venue]\nbody_versions = [1]"); + admit_worker("keeper", &keeper, &[]).expect_err("keeper with the adapter key"); + + let venue = sections("[venue]\nbody_version = 1"); + admit_provider("venue", &venue).expect_err("adapter with the keeper key"); + } + + /// An adapter declaring an empty decode set is refused at install. + #[test] + fn empty_adapter_set_is_refused() { + let venue = sections("[venue]\nbody_versions = []"); + let err = admit_provider("venue", &venue).expect_err("refused"); + assert!(err.to_string().contains("must not be empty")); + } + + /// A well-formed adapter declaration is admitted. + #[test] + fn adapter_declaration_is_admitted() { + let venue = sections("[venue]\nbody_versions = [1, 2]"); + admit_provider("venue", &venue).expect("admitted"); + } + + /// The exported set must equal the manifest claim exactly; either + /// direction of drift refuses the install. + #[test] + fn exported_versions_must_equal_the_manifest_claim() { + let declared = declared_versions("venue", §ions("[venue]\nbody_versions = [1, 2]")) + .expect("declared"); + verify_exported_versions("venue", &declared, vec![2, 1]).expect("equal sets"); + + let err = verify_exported_versions("venue", &declared, vec![1]).expect_err("narrower"); + assert!( + err.to_string().contains("exports body versions {1}"), + "{err}" + ); + verify_exported_versions("venue", &declared, vec![1, 2, 3]).expect_err("wider"); + } + + /// A section-less adapter must export an empty set: an undeclared + /// manifest with a declaring export is drift, not a default. + #[test] + fn a_section_less_adapter_must_export_no_versions() { + let declared = declared_versions("venue", &ExtensionSections::new()).expect("declared"); + assert!(declared.is_empty()); + verify_exported_versions("venue", &declared, Vec::new()).expect("both undeclared"); + verify_exported_versions("venue", &declared, vec![1]).expect_err("export-only drift"); + } +} diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs new file mode 100644 index 00000000..d228e27a --- /dev/null +++ b/crates/videre-host/src/lib.rs @@ -0,0 +1,169 @@ +//! The videre venue platform, packaged as one [`nexum_runtime`] +//! extension: the venue-adapter provider kind, the [`VenueRegistry`] +//! service, the advisory [`EgressGuard`] seam, and the keeper-facing +//! `videre:venue/client` interface. A composition root registers it all +//! with `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod bindings; +mod client; +mod handshake; +mod registry; + +use std::sync::Arc; +use std::time::Duration; + +use nexum_runtime::bindings::nexum::host::types::Event; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, + ProviderManifest, +}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; +use tokio::sync::mpsc; +use wasmtime::component::{HasSelf, Linker}; + +pub use registry::{ + DuplicateVenue, EgressGuard, GuardContext, GuardVerdict, IntentStatusUpdate, VenueActor, + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, +}; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS_KIND: &str = "intent-status"; + +/// Buffer for the status poll channel; small because the event loop +/// drains in real time. +const STATUS_CHANNEL_BUF: usize = 64; + +/// The venue platform over the config-resolved quota and watch policy, +/// with the unit guard. The single registration entrypoint. +pub fn platform(config: &EngineConfig) -> Videre { + Videre::from_registry( + VenueRegistryBuilder::new(config.limits.quota()) + .with_watch_limit(config.limits.watch()) + .build(), + ) +} + +/// The videre platform as one runtime extension. Registers the +/// `videre:venue/client` interface and its capability namespace on every +/// worker linker, publishes the [`VenueRegistry`] service, installs the +/// venue-adapter provider kind, and opens the status-poll event source. +pub struct Videre { + registry: Arc, +} + +impl Videre { + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] + /// or policy; [`platform`] covers the config-resolved default. + pub fn from_registry(registry: VenueRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } +} + +impl Extension for Videre { + fn namespace(&self) -> &'static str { + VenueRegistry::NAMESPACE + } + + /// Only the keeper-facing `client` interface is a capability; the + /// `videre:types` and `videre:value-flow` packages are type-only and + /// need no declaration. + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "videre:venue/", + ifaces: &["client"], + } + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + bindings::client::add_to_linker::, HasSelf>>(linker, |state| { + state + })?; + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::clone(&self.registry) as Arc) + } + + fn provider(&self) -> Option>> { + Some(Box::new(VenueAdapterKind)) + } + + fn manifest_sections(&self) -> &'static [&'static str] { + handshake::SECTIONS + } + + /// Adapter-side handshake shape check: a `[venue]` section must + /// declare a non-empty `body_versions` set. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + handshake::admit_provider(provider, sections) + } + + /// The body-version membership predicate: a keeper declaring + /// `[venue] body_version` boots only when every installed venue + /// adapter's `[venue] body_versions` set contains it. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + handshake::admit_worker(worker, sections, providers) + } + + fn subscriptions(&self) -> &'static [&'static str] { + &[INTENT_STATUS_KIND] + } + + /// The status poll source: on every cadence tick, poll each installed + /// adapter's status export through the shared registry and forward the + /// observed transitions. Opened only when a module subscribes and at + /// least one venue is installed. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + if !sources.subscribed.contains(INTENT_STATUS_KIND) { + return Ok(Vec::new()); + } + let registry = (*self.registry).clone(); + if registry.venue_count() == 0 { + return Ok(Vec::new()); + } + let cadence = sources.config.limits.status_poll_interval(); + let (tx, rx) = mpsc::channel::(STATUS_CHANNEL_BUF); + sources.spawn(status_poll_task(registry, cadence, tx)); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(vec![Box::pin(stream)]) + } +} + +/// Poll loop behind [`Extension::events`]. Sleeps the cadence first so +/// the engine's boot dispatch settles before the first poll; ends when +/// the event loop drops its receiver. +async fn status_poll_task( + registry: VenueRegistry, + cadence: Duration, + tx: mpsc::Sender, +) { + loop { + tokio::time::sleep(cadence).await; + for update in registry.poll_status_transitions().await { + let event = ExtensionEvent { + kind: INTENT_STATUS_KIND, + attrs: vec![("venue", update.venue.clone())], + event: Event::IntentStatus(update), + }; + if tx.send(event).await.is_err() { + // Receiver dropped -> engine shutting down. + return; + } + } + } +} diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs new file mode 100644 index 00000000..815205e6 --- /dev/null +++ b/crates/videre-host/src/registry.rs @@ -0,0 +1,1786 @@ +//! The venue registry: the keeper-facing `videre:venue/client` import +//! resolved to installed venue adapters. +//! +//! 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 (advisory-only for now: +//! see [`EgressGuard`]), and only then submit. +//! Status and cancel are pass-throughs; they are not submissions, so they +//! skip the header, the guard, and the quota. Observe puts an +//! externally-obtained receipt under status watch without touching the +//! adapter at all. +//! +//! Invocation is serialised per adapter through the supervised-actor +//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent +//! client calls to the same venue queue 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. +//! +//! Fuel cannot cross stores, so a module that spams undecodable bodies would +//! otherwise burn an adapter's budget for free. Two mechanisms close that: +//! a per-caller quota gates every quote and submit before the adapter is +//! touched, and a decode failure (the adapter's `invalid-body`) is charged +//! to the calling module's quota, so a caller feeding garbage exhausts its +//! 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}; + +use anyhow::{Context, anyhow}; +use async_trait::async_trait; +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{SubmitQuota, WatchLimit}; +use nexum_runtime::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; +use nexum_runtime::host::state::HostState; +use nexum_status_body::StatusBody; +use tokio::sync::Mutex as AsyncMutex; +use tracing::{info, warn}; +use wasmtime::Store; +use wasmtime::component::HasSelf; + +/// The registry-observed status transition delivered through the host +/// `event` variant, re-exported at the spelling the registry names. +pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; + +use crate::bindings::{ + IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, +}; + +/// 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) + } +} + +/// The guard interposition seam. The registry runs this on the +/// adapter-derived header after `derive-header` and before `submit`. +/// +/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is +/// logged as a would-deny and the submission proceeds. The shipped policy is +/// the unit guard, which allows every egress; the egress-guard epic installs +/// the real facts-plus-analysers pipeline and turns the verdict enforcing, +/// 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 the submission is routed to. + pub venue: &'a VenueId, + /// Adapter-derived header for the body. + pub header: &'a IntentHeader, +} + +/// The guard's decision on one egress. +pub enum GuardVerdict { + /// Forward the submission to the adapter. + Allow, + /// Refuse the egress with an operator-facing reason. Logged, not + /// enforced, while the seam is advisory-only. + Deny(String), +} + +/// The per-adapter invocation seam. One installed adapter answers for exactly +/// 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 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>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result>; + + /// Price the opaque body at this adapter's venue. + fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result>; + + /// Submit the opaque body to this adapter's venue. + fn submit<'a>(&'a mut self, body: &'a [u8]) + -> BoxFuture<'a, Result>; + + /// Report where a previously submitted intent is in its life. The receipt + /// is owned: it is used once, unlike the body a submission re-decodes. + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; + + /// Ask the venue to withdraw an intent. + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; +} + +/// The live adapter: a [`SupervisedStore`] plus the `venue-adapter` +/// bindings. Each guest call is refuelled by the primitive; a trap is +/// projected onto `unavailable` rather than propagated, because a +/// misbehaving adapter must not be the caller's fault and must not unwind +/// through the registry into the calling module's store. +pub struct VenueActor { + actor: SupervisedStore, + bindings: VenueAdapter, +} + +impl VenueActor { + /// Wrap an instantiated adapter store for routing, reporting traps on + /// the shared `liveness`. + pub fn new( + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, + liveness: Liveness, + ) -> Self { + Self { + actor: SupervisedStore::new(store, fuel_per_call, liveness), + bindings, + } + } +} + +/// Project an actor fault into the venue-error space. The fault carries +/// the root cause only, so an operator sees why the adapter died without +/// the wasm frame list leaking to the calling module. +fn venue_fault(fault: ActorFault) -> VenueError { + VenueError::Unavailable(format!("adapter {fault}")) +} + +impl VenueInvoker for VenueActor { + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_derive_header(store, body).await) + .await + .map_err(venue_fault)? + }) + } + + fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_quote(store, body).await) + .await + .map_err(venue_fault)? + }) + } + + fn submit<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_submit(store, body).await) + .await + .map_err(venue_fault)? + }) + } + + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_status(store, &receipt).await) + .await + .map_err(venue_fault)? + }) + } + + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_cancel(store, &receipt).await) + .await + .map_err(venue_fault)? + }) + } +} + +/// One installed adapter behind its serialising slot. +type AdapterSlot = ActorSlot; + +/// One installed venue: the adapter slot plus the liveness the supervisor's +/// sweep shares with the actor. A dead entry stays installed, so the venue +/// resolves to `unavailable` (temporarily dead) rather than `unknown-venue` +/// (never installed) until the sweep restarts it. +struct InstalledVenue { + slot: AdapterSlot, + liveness: Liveness, +} + +/// Per-caller charge history, pruned to the quota window on each touch. +#[derive(Default)] +struct QuotaLedger { + per_caller: HashMap>, +} + +/// 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. +/// `expires_at` is the eviction deadline, pushed a full window out on +/// every successful non-terminal poll; `None` (deadline arithmetic +/// overflowed) never expires. +struct WatchedIntent { + venue: VenueId, + receipt: Vec, + last: Option, + expires_at: Option, +} + +/// A polled status is terminal when the intent can never change again: +/// the registry stops watching the receipt after reporting it. +fn is_terminal(status: IntentStatus) -> bool { + matches!( + status, + IntentStatus::Fulfilled | IntentStatus::Cancelled | IntentStatus::Expired + ) +} + +/// 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 = 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: None, + reason: None, + } +} + +/// 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. Adapters +/// install through the shared handle at provider boot, before any client +/// call routes. +struct VenueRegistryInner { + adapters: Mutex>, + guard: Arc, + quota: SubmitQuota, + ledger: Mutex, + watch_limit: WatchLimit, + /// Receipts under status watch, appended by accepted submissions and + /// explicit observes, pruned as they reach a terminal status, expire, + /// or overflow [`WatchLimit`]. + watched: Mutex>, +} + +/// The keeper-facing venue registry, cheap to clone and shared across every +/// module store. +#[derive(Clone)] +pub struct VenueRegistry { + inner: Arc, +} + +/// The registry is the venue-routing host service. +impl HostService for VenueRegistry {} + +impl VenueRegistry { + /// Service namespace the registry publishes under: the videre + /// extension's. + pub const NAMESPACE: &'static str = "videre"; + + /// Install an adapter under its venue id, sharing `liveness` with its + /// invoker. Rejects a duplicate id while the incumbent is alive: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. A dead incumbent is + /// replaced: that is the sweep restarting a trapped adapter. + pub fn install( + &self, + venue: VenueId, + liveness: Liveness, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + if adapters.get(&venue).is_some_and(|v| v.liveness.is_alive()) { + return Err(DuplicateVenue { venue }); + } + adapters.insert( + venue, + InstalledVenue { + slot: Arc::new(AsyncMutex::new(invoker)), + liveness, + }, + ); + Ok(()) + } + + /// Resolve a venue id to its installed adapter slot. An uninstalled + /// venue is `unknown-venue`; an installed but dead one is `unavailable` + /// pending the supervisor's restart sweep, without touching its + /// poisoned store. + fn resolve(&self, venue: &VenueId) -> Result { + let adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + let installed = adapters.get(venue).ok_or(VenueError::UnknownVenue)?; + if !installed.liveness.is_alive() { + return Err(VenueError::Unavailable(format!( + "venue {venue} is dead pending restart" + ))); + } + Ok(Arc::clone(&installed.slot)) + } + + /// Whether `caller` has budget left in the current window. Read-only: it + /// prunes aged charges but does not record one. + fn quota_admits(&self, caller: &str) -> bool { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + (history.len() as u32) < self.inner.quota.max_charges + } + + /// Record one charge against `caller`'s budget. + fn charge(&self, caller: &str) { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + history.push_back(Instant::now()); + } + + /// Submit an opaque body to `venue` on behalf of `caller`: resolve the + /// adapter, gate on the caller's quota, derive the header, run the guard + /// seam (advisory-only: a deny logs and the submission proceeds), then + /// forward to the adapter. A decode failure is charged to the + /// caller before returning, so a caller feeding garbage exhausts its own + /// budget and is stopped at the gate on the next call rather than + /// re-invoking the adapter. + /// + /// Charged once the header derives, ahead of the guard and adapter, so + /// a deny (when enforcing) or a venue outage is never a free retry. + /// Derive-stage venue errors other than a decode failure are left + /// uncharged and retryable. + pub async fn submit( + &self, + caller: &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. Exhaustion is retryable + // once the window slides, so it is rate-limited, never denied. + if !self.quota_admits(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 { + Ok(header) => header, + Err(e) => { + // Charge decode failures to the caller before the adapter is + // invoked again; other venue errors are not the caller's fault. + if matches!(e, VenueError::InvalidBody(_)) { + self.charge(caller); + } + return Err(e); + } + }; + let ctx = GuardContext { + caller, + venue, + header: &header, + }; + // Charge before the guard so an enforcing deny stays non-free. + self.charge(caller); + // Advisory-only checkpoint: a deny is logged, never enforced. + if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { + warn!( + caller, + venue = %venue, + reason, + "egress guard would deny - advisory-only, submission proceeds", + ); + } + let outcome = adapter.submit(&body).await?; + // An accepted receipt goes under status watch so subscribers see + // its transitions; requires-signing has no receipt to watch yet. + if let SubmitOutcome::Accepted(receipt) = &outcome { + self.watch(venue, receipt.clone()); + } + Ok(outcome) + } + + /// Price an opaque body at `venue` on behalf of `caller`. Not a + /// submission, so the header and guard are skipped (a quotation moves + /// no value), but it is adapter work on a caller-supplied body: the + /// caller's quota gates it and every quote spends one unit, so a + /// quote spammer exhausts its own budget, not the adapter's. + pub async fn quote( + &self, + caller: &str, + venue: &VenueId, + body: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + if !self.quota_admits(caller) { + return Err(VenueError::RateLimited(RateLimit { + retry_after_ms: Some(window_ms(self.inner.quota.window)), + })); + } + self.charge(caller); + let mut adapter = slot.lock().await; + adapter.quote(&body).await + } + + /// Put an externally-obtained `(venue, receipt)` pair under status + /// watch: the `observe` half of the client face, for receipts the + /// pool never submitted (an accepted submit is watched implicitly). + /// Not a submission: no header, no guard, no quota; the watch cap + /// bounds it. Idempotent. + pub fn observe(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { + let _ = self.resolve(venue)?; + if self.watch(venue, receipt) { + Ok(()) + } else { + Err(VenueError::Unavailable("status watch set full".to_owned())) + } + } + + /// Put a `(venue, receipt)` pair under status watch, reporting + /// whether it is watched. Idempotent: a re-submitted receipt keeps + /// its existing watch entry. Bounded: expired entries evict first, + /// and at the cap the new watch is refused and logged rather than + /// an existing live watch dropped. + fn watch(&self, venue: &VenueId, receipt: Vec) -> bool { + let (evicted, admitted) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + if watched + .iter() + .any(|w| w.venue == *venue && w.receipt == receipt) + { + (evicted, true) + } else if watched.len() < self.inner.watch_limit.max_entries { + watched.push(WatchedIntent { + venue: venue.clone(), + receipt, + last: None, + expires_at: Instant::now().checked_add(self.inner.watch_limit.expiry), + }); + (evicted, true) + } else { + (evicted, false) + } + }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } + if !admitted { + warn!( + venue = %venue, + "status watch set full - transitions for this receipt will not be reported", + ); + } + admitted + } + + /// Number of receipts currently under status watch. + pub fn watched_count(&self) -> usize { + self.inner + .watched + .lock() + .expect("watch list poisoned") + .len() + } + + /// Poll every watched receipt against its adapter's status export and + /// 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 failure leaves the entry untouched for + /// the next cadence. Expired entries are evicted unpolled and + /// unreported. + pub async fn poll_status_transitions(&self) -> Vec { + // Snapshot so the std mutex is never held across the guest await. + let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + let snapshot = watched + .iter() + .map(|w| (w.venue.clone(), w.receipt.clone())) + .collect(); + (evicted, snapshot) + }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } + let mut updates = Vec::new(); + for (venue, receipt) in snapshot { + // A dead venue fails to resolve; its watch stays for the + // cadence after the sweep restarts the adapter. + let Ok(slot) = self.resolve(&venue) else { + continue; + }; + let polled = { + let mut adapter = slot.lock().await; + adapter.status(receipt.clone()).await + }; + match polled { + Ok(status) => { + if let Some(update) = self.record_polled_status(&venue, &receipt, status) { + updates.push(update); + } + } + Err(err) => { + warn!( + venue = %venue, + error = %crate::bindings::venue_error_message(&err), + "status poll failed - retrying on the next cadence", + ); + } + } + } + updates + } + + /// Fold one polled status into the watch entry: `Some(update)` when it + /// differs from the last reported status, pruning the entry when the + /// status is terminal and refreshing its eviction deadline otherwise, + /// so expiry only fires on a venue that has gone silent. `None` also + /// covers an entry that disappeared while the poll was in flight, and + /// an update whose status body failed to encode (the entry is left + /// untouched for the next cadence). + fn record_polled_status( + &self, + 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 != Some(status); + let update = if changed { + match status_body(status).encode() { + Ok(body) => Some(IntentStatusUpdate { + venue: venue.as_str().to_owned(), + receipt: receipt.to_vec(), + status: body, + }), + Err(err) => { + warn!( + venue = %venue, + error = %err, + "status body failed to encode - retrying on the next cadence", + ); + return None; + } + } + } else { + None + }; + if is_terminal(status) { + watched.remove(pos); + } else { + watched[pos].last = Some(status); + watched[pos].expires_at = Instant::now().checked_add(self.inner.watch_limit.expiry); + } + update + } + + /// 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: &VenueId, + receipt: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.status(receipt).await + } + + /// 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: &VenueId, receipt: Vec) -> Result<(), VenueError> { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.cancel(receipt).await + } + + /// Number of installed, routable adapters. + pub fn venue_count(&self) -> usize { + self.inner + .adapters + .lock() + .expect("adapter map poisoned") + .len() + } +} + +/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` +/// component and installs its actor in the venue registry. Registered +/// through the videre extension's provider slot. +pub struct VenueAdapterKind; + +impl VenueAdapterKind { + /// The manifest kind spelling. + pub const KIND: &'static str = "venue-adapter"; +} + +#[async_trait] +impl ProviderKind for VenueAdapterKind { + fn kind(&self) -> &'static str { + Self::KIND + } + + fn link(&self, linker: &mut wasmtime::component::Linker>) -> anyhow::Result<()> { + // The scoped transport only; the WASI base is the host's, and the + // withheld core interfaces fail instantiation. + nexum::host::chain::add_to_linker::, HasSelf>>(linker, |s| s)?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } + + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result { + let registry = downcast_service::(service) + .ok_or_else(|| anyhow!("the venue-adapter kind requires the venue-registry service"))?; + let ProviderInstance { + component, + linker, + mut store, + config, + sections, + fuel_per_call, + liveness, + } = instance; + let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) + .await + .map_err(anyhow::Error::from) + .context("instantiate adapter")?; + // The venue id is the adapter's namespace: its manifest name. + let venue_id = VenueId::from(&*store.data().run.module); + // The manifest `[venue] body_versions` is the install-time + // authority the keeper handshake reads; the export must agree, + // so a manifest claiming versions the code does not decode never + // installs. + let declared = crate::handshake::declared_versions(venue_id.as_str(), sections)?; + let exported = bindings + .videre_venue_adapter() + .call_body_versions(&mut store) + .await + .map_err(anyhow::Error::from) + .context("read adapter body-versions")?; + crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; + match bindings + .call_init(&mut store, &config) + .await + .map_err(anyhow::Error::from)? + { + Ok(()) => info!(adapter = %venue_id, "adapter init succeeded"), + Err(e) => { + warn!( + adapter = %venue_id, + kind = nexum_runtime::host::error::fault_label(&e), + fault = %nexum_runtime::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + return Ok(Installed::Dead); + } + } + registry + .install( + venue_id.clone(), + liveness.clone(), + VenueActor::new(store, bindings, fuel_per_call, liveness), + ) + .with_context(|| format!("install adapter {venue_id}"))?; + Ok(Installed::Live) + } +} + +/// 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 watch entries whose eviction deadline has passed, returning how +/// many were evicted. +fn prune_expired(watched: &mut Vec) -> usize { + let now = Instant::now(); + let before = watched.len(); + watched.retain(|w| w.expires_at.is_none_or(|at| now < at)); + before - watched.len() +} + +/// Drop charge timestamps that have aged out of the window. +fn prune(history: &mut VecDeque, window: Duration) { + let now = Instant::now(); + while let Some(&front) = history.front() { + if now.duration_since(front) > window { + history.pop_front(); + } else { + break; + } + } +} + +/// Assembles a [`VenueRegistry`]'s policy: guard, quota, and watch bounds +/// freeze at build; adapters install afterwards through the shared handle +/// at provider boot. The guard defaults to the unit guard; the egress-guard +/// epic overrides it here. +pub struct VenueRegistryBuilder { + guard: Arc, + quota: SubmitQuota, + watch_limit: WatchLimit, +} + +impl VenueRegistryBuilder { + /// Start an empty builder with the given quota, the unit guard, and + /// the default watch limit. + pub fn new(quota: SubmitQuota) -> Self { + Self { + guard: Arc::new(()), + quota, + watch_limit: WatchLimit::default(), + } + } + + /// Override the guard policy. The egress-guard epic wires the real + /// pipeline through here; tests inject a denying policy to prove the + /// advisory seam. + pub fn with_guard(mut self, guard: Arc) -> Self { + self.guard = guard; + self + } + + /// Override the status-watch bounds. + pub fn with_watch_limit(mut self, watch_limit: WatchLimit) -> Self { + self.watch_limit = watch_limit; + self + } + + /// Freeze the builder into a shared registry. + pub fn build(self) -> VenueRegistry { + if self.quota.max_charges == 0 { + // 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!("submission quota max_charges is 0; clamping to 1"); + } + let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); + if self.watch_limit.max_entries == 0 { + // A zero cap would refuse every watch; saturate up to one so a + // misconfigured bound still tracks a single receipt. + warn!("watch limit max_entries is 0; clamping to 1"); + } + let watch_limit = + WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); + VenueRegistry { + inner: Arc::new(VenueRegistryInner { + adapters: Mutex::new(HashMap::new()), + guard: self.guard, + quota, + watch_limit, + ledger: Mutex::new(QuotaLedger::default()), + watched: Mutex::new(Vec::new()), + }), + } + } +} + +/// Two installed adapters claimed the same venue id. +#[derive(Debug, thiserror::Error)] +#[error("venue id {venue} is claimed by more than one installed adapter")] +pub struct DuplicateVenue { + /// The colliding venue id. + pub venue: VenueId, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use nexum_status_body::IntentStatus as Lifecycle; + + 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") + } + + /// A body carrying a bare lifecycle state. + fn plain(status: Lifecycle) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + + /// A programmable adapter that records call counts and returns canned + /// outcomes, so the registry's sequencing, guard seam, and quota are + /// tested without a wasmtime store. + #[derive(Default)] + struct StubCalls { + derive: AtomicUsize, + quote: AtomicUsize, + submit: AtomicUsize, + status: AtomicUsize, + cancel: AtomicUsize, + /// Highest number of overlapping invocations observed; proves the + /// per-adapter mutex serialises access. + max_concurrency: AtomicUsize, + live: AtomicUsize, + } + + struct StubAdapter { + calls: Arc, + derive: Result, + submit: Result, + /// Accept each submission with its body as the receipt, so one + /// stub can mint distinct receipts. + echo_receipt: bool, + /// Statuses served front-first by consecutive `status` calls; + /// once drained, every further call reports `open`. + status_script: VecDeque>, + } + + impl StubAdapter { + fn new(calls: Arc) -> Self { + Self { + calls, + derive: Ok(header()), + submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + echo_receipt: false, + status_script: VecDeque::new(), + } + } + + fn with_receipt_echo(mut self) -> Self { + self.echo_receipt = true; + self + } + + fn with_derive(mut self, derive: Result) -> Self { + self.derive = derive; + self + } + + fn with_submit(mut self, submit: Result) -> Self { + self.submit = submit; + self + } + + fn with_status_script( + mut self, + script: impl IntoIterator>, + ) -> Self { + self.status_script = script.into_iter().collect(); + self + } + + async fn enter(&self) { + let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; + self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); + // Yield inside the critical section so any missing serialisation + // would let a second call observe `live == 2`. + tokio::task::yield_now().await; + self.calls.live.fetch_sub(1, Ordering::SeqCst); + } + } + + impl VenueInvoker for StubAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.derive.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.derive.clone() + }) + } + + fn quote<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.quote.fetch_add(1, Ordering::SeqCst); + self.enter().await; + Ok(quotation()) + }) + } + + fn submit<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.submit.fetch_add(1, Ordering::SeqCst); + self.enter().await; + if self.echo_receipt { + return Ok(SubmitOutcome::Accepted(body.to_vec())); + } + self.submit.clone() + }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls.status.fetch_add(1, Ordering::SeqCst); + self.status_script + .pop_front() + .unwrap_or(Ok(IntentStatus::Open)) + }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.calls.cancel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } + } + + /// A guard that refuses every egress with a fixed reason. + struct DenyGuard; + impl EgressGuard for DenyGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Deny("blocked by test policy".to_owned()) + } + } + + fn quotation() -> Quotation { + Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: 1_700_000_000_000, + } + } + + fn header() -> IntentHeader { + IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + } + } + + fn registry_with( + quota: SubmitQuota, + guard: Option>, + adapter: StubAdapter, + ) -> VenueRegistry { + let mut builder = VenueRegistryBuilder::new(quota); + if let Some(guard) = guard { + builder = builder.with_guard(guard); + } + let registry = builder.build(); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); + registry + } + + #[tokio::test] + async fn submit_round_trips_through_derive_guard_submit() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + let outcome = registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn unknown_venue_is_rejected_without_touching_an_adapter() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + let err = registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await + .expect_err("unknown venue rejected"); + + assert!(matches!(err, VenueError::UnknownVenue)); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn guard_deny_is_advisory_and_does_not_block_submit() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let outcome = registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("advisory deny does not block"); + + // The seam runs on the derived header but only logs: derive ran and + // the submission still reached the adapter. + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn repeated_guard_denies_exhaust_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with( + quota, + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + // Each denied submit spends exactly one unit: the second is still + // admitted, so a deny is never double-charged. + 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() + ); + // The deny loop is rate-limited at the gate, not free. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn quote_reaches_the_adapter_without_header_or_guard() { + let calls = Arc::new(StubCalls::default()); + // A denying guard proves quotes skip the seam: no value moves. + let registry = registry_with( + SubmitQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let quoted = registry + .quote("mod-a", &cow(), b"body".to_vec()) + .await + .expect("quote succeeds"); + + assert_eq!(quoted, quotation()); + assert_eq!(calls.quote.load(Ordering::SeqCst), 1); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn quote_spends_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(registry.quote("mod-a", &cow(), b"b".to_vec()).await.is_ok()); + // The quote spent the only unit: both a further quote and a + // submit are stopped at the gate. + assert!(matches!( + registry.quote("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.quote.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn quote_to_an_unknown_venue_is_rejected() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + assert!(matches!( + registry + .quote("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.quote.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn submission_quota_rate_limits_once_the_budget_is_spent() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + 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"); + + // 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); + } + + #[tokio::test] + async fn quota_is_per_caller() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + 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_err(), + "mod-a is over its own budget" + ); + // A different caller has its own budget. + assert!( + registry + .submit("mod-b", &cow(), b"b".to_vec()) + .await + .is_ok(), + "mod-b has an independent budget" + ); + } + + #[tokio::test] + async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let adapter = + StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); + let registry = registry_with(quota, None, adapter); + + // First garbage body: derive fails, the failure is charged. + 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 = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; + assert!(matches!(second, Err(VenueError::RateLimited(_)))); + assert_eq!( + calls.derive.load(Ordering::SeqCst), + 1, + "adapter derive-header was invoked exactly once", + ); + } + + #[tokio::test] + async fn non_decode_venue_errors_are_not_charged() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let adapter = StubAdapter::new(calls.clone()) + .with_derive(Err(VenueError::Unavailable("rpc down".into()))); + let registry = registry_with(quota, None, adapter); + + assert!(matches!( + 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!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn status_and_cancel_pass_through_without_quota() { + let calls = Arc::new(StubCalls::default()); + // A spent budget must not block reads: status and cancel are not + // submissions. + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(matches!( + registry.status(&cow(), b"r".to_vec()).await, + Ok(IntentStatus::Open) + )); + 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); + } + + #[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 = 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 registry = registry.clone(); + handles.push(tokio::spawn(async move { + let _ = registry.submit("mod-a", &cow(), b"b".to_vec()).await; + })); + } + for h in handles { + h.await.expect("task joins"); + } + // The adapter mutex is held across the guest await, so no two calls + // ever overlapped inside the adapter. + assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); + } + + #[test] + fn duplicate_venue_id_is_rejected() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + let a = Arc::new(StubCalls::default()); + let b = Arc::new(StubCalls::default()); + registry + .install(cow(), Liveness::default(), StubAdapter::new(a)) + .expect("first install"); + let err = registry + .install(cow(), Liveness::default(), StubAdapter::new(b)) + .expect_err("second install collides"); + assert_eq!(err.venue, cow()); + } + + #[tokio::test] + async fn dead_venue_is_unavailable_not_unknown() { + let calls = Arc::new(StubCalls::default()); + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install(cow(), liveness.clone(), StubAdapter::new(calls.clone())) + .expect("install adapter"); + liveness.mark_dead(); + + // Temporarily dead resolves distinctly from never installed, and + // the dead adapter's slot is never entered. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_dead_incumbent_is_replaced_on_reinstall() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + let liveness = Liveness::default(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("first install"); + liveness.mark_dead(); + registry + .install( + cow(), + Liveness::default(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("a restart replaces the dead incumbent"); + assert_eq!(registry.venue_count(), 1); + } + + #[test] + fn zero_quota_saturates_to_one() { + let registry = + VenueRegistryBuilder::new(SubmitQuota::new(0, Duration::from_secs(60))).build(); + assert_eq!(registry.inner.quota.max_charges, 1); + } + + #[test] + fn zero_watch_cap_saturates_to_one() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(WatchLimit::new(0, Duration::from_secs(60))) + .build(); + assert_eq!(registry.inner.watch_limit.max_entries, 1); + } + + // ── status watch + polling ──────────────────────────────────────── + + #[tokio::test] + async fn accepted_submission_goes_under_status_watch() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with(SubmitQuota::default(), None, StubAdapter::new(calls)); + + assert_eq!(registry.watched_count(), 0); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + + // Re-submitting the same receipt does not double-watch it. + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + } + + #[tokio::test] + async fn observe_watches_an_externally_obtained_receipt() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls.clone()).with_status_script([Ok(IntentStatus::Fulfilled)]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe succeeds"); + // Re-observing keeps the existing entry. + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe is idempotent"); + assert_eq!(registry.watched_count(), 1); + // No adapter work happened at observe time. + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + + // The watch polls like a submitted one: the terminal status + // reports once and prunes the entry. + let updates = registry.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].receipt, b"onchain"); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Fulfilled)); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_rejects_an_unknown_venue() { + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(Arc::new(StubCalls::default())), + ); + assert!(matches!( + registry.observe(&VenueId::from("unlisted"), b"r".to_vec()), + Err(VenueError::UnknownVenue) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_of_a_dead_venue_is_unavailable() { + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("install adapter"); + liveness.mark_dead(); + assert!(matches!( + registry.observe(&cow(), b"r".to_vec()), + Err(VenueError::Unavailable(_)) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_at_the_watch_cap_is_refused_typedly() { + let limit = WatchLimit::new(1, Duration::from_secs(3600)); + let registry = + watch_bounded_registry(limit, StubAdapter::new(Arc::new(StubCalls::default()))); + + registry.observe(&cow(), b"a".to_vec()).expect("admitted"); + let err = registry + .observe(&cow(), b"b".to_vec()) + .expect_err("overflow refused"); + assert!(matches!(err, VenueError::Unavailable(_))); + // The live watch is kept; the overflow was refused. + assert_eq!(registry.watched_count(), 1); + } + + #[tokio::test] + async fn requires_signing_outcome_is_not_watched() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: 1, + to: vec![0u8; 20], + value: Vec::new(), + data: Vec::new(), + }))); + let registry = registry_with(SubmitQuota::default(), None, adapter); + + 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!(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 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 = 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!(registry.poll_status_transitions().await.is_empty()); + assert_eq!(calls.status.load(Ordering::SeqCst), 2); + assert_eq!(registry.watched_count(), 1, "open is not terminal"); + } + + #[tokio::test] + async fn poll_reports_each_transition_and_prunes_on_terminal() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Open), + Ok(IntentStatus::Fulfilled), + ]); + 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(registry.poll_status_transitions().await); + } + let statuses: Vec = seen.iter().map(decoded).collect(); + assert_eq!( + statuses, + vec![ + plain(Lifecycle::Pending), + plain(Lifecycle::Open), + plain(Lifecycle::Fulfilled), + ], + "the repeated pending is deduplicated; each transition reports once", + ); + assert_eq!(registry.watched_count(), 0, "fulfilled prunes the watch"); + // A further poll has nothing left to ask the adapter about. + assert!(registry.poll_status_transitions().await.is_empty()); + } + + #[tokio::test] + async fn poll_failure_keeps_the_watch_for_the_next_cadence() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_status_script([Err(VenueError::Unavailable("venue down".into()))]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!( + registry.watched_count(), + 1, + "transient failure keeps the entry" + ); + + // The venue recovered: the next poll reports the current status. + let updates = registry.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); + } + + /// A registry with the given watch bounds and one echo-receipt-capable + /// stub adapter under `cow`. + fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(watch_limit) + .build(); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); + registry + } + + #[tokio::test] + async fn watch_cap_refuses_the_overflow_and_never_drops_live_watches() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_receipt_echo() + .with_status_script([Ok(IntentStatus::Pending), Ok(IntentStatus::Pending)]); + let limit = WatchLimit::new(2, Duration::from_secs(3600)); + let registry = watch_bounded_registry(limit, adapter); + + for body in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] { + registry + .submit("mod-a", &cow(), body) + .await + .expect("submit succeeds"); + } + assert_eq!(registry.watched_count(), 2, "the cap bounds the set"); + + // The live pending watches kept their tracking; only the overflow + // watch was refused. + let updates = registry.poll_status_transitions().await; + let receipts: Vec<&[u8]> = updates.iter().map(|u| u.receipt.as_slice()).collect(); + assert_eq!(receipts, vec![b"a".as_slice(), b"b".as_slice()]); + assert!( + updates + .iter() + .all(|u| decoded(u) == plain(Lifecycle::Pending)) + ); + } + + #[tokio::test] + async fn pending_polls_keep_a_live_watch_across_expiry_windows() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Fulfilled), + ]); + let expiry = Duration::from_secs(1); + let registry = watch_bounded_registry(WatchLimit::new(8, expiry), adapter); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let deadline_at = |registry: &VenueRegistry| { + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + watched[0].expires_at + }; + let inserted = deadline_at(®istry); + + // Two pending polls, each pushing the deadline a full window out. + let mut reported = Vec::new(); + for _ in 0..2 { + reported.extend(registry.poll_status_transitions().await); + assert_eq!( + registry.watched_count(), + 1, + "a reporting venue stays watched" + ); + assert!( + deadline_at(®istry) > inserted, + "the poll refreshed the deadline" + ); + tokio::time::sleep(expiry * 7 / 10).await; + } + + // Well past the insert-time window, the terminal transition still + // reports and prunes the watch. + reported.extend(registry.poll_status_transitions().await); + let statuses: Vec = reported.iter().map(decoded).collect(); + assert_eq!( + statuses, + vec![plain(Lifecycle::Pending), plain(Lifecycle::Fulfilled)], + ); + assert_eq!(registry.watched_count(), 0); + } + + #[tokio::test] + async fn expired_watches_are_evicted_unpolled() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(8, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls.clone())); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + + // The entry expired before the cadence: evicted without a venue call. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!(registry.watched_count(), 0); + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn expiry_frees_room_at_the_cap() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(1, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls).with_receipt_echo()); + + registry + .submit("mod-a", &cow(), b"a".to_vec()) + .await + .expect("submit succeeds"); + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .expect("submit succeeds"); + + // The expired first watch was evicted at insert, admitting the second. + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + assert_eq!(watched.len(), 1); + assert_eq!(watched[0].receipt, b"b"); + } + + #[test] + 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/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs new file mode 100644 index 00000000..eadd9165 --- /dev/null +++ b/crates/videre-host/tests/platform.rs @@ -0,0 +1,1053 @@ +//! E2E coverage for the videre platform over the generic runtime seam: +//! the venue-adapter provider boot, the client -> registry -> adapter +//! round trip, the status-poll event source, and the trap-to-recovery +//! sweeps. Exercises pre-built wasm artefacts and skips gracefully when +//! an artefact is absent. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, +}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::CapabilityRegistry; +use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; +use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; +use videre_host::bindings::{ + IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, value_flow, +}; +use videre_host::{ + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, Videre, platform, +}; +use wasmtime::component::Linker; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS: &str = "intent-status"; + +// ── fixtures + assembly ─────────────────────────────────────────────── + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir, +/// or `None` with a skip message when it is not built. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None + } +} + +fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") +} + +/// The platform under test plus the extension slice the boot paths take. +/// The concrete handle stays available for the event-source calls. +fn videre_assembly(videre: &Arc) -> Vec>> { + vec![Arc::clone(videre) as Arc>] +} + +fn make_linker( + engine: &wasmtime::Engine, + extensions: &[Arc>], +) -> Linker> { + build_linker::(engine, extensions).expect("build_linker") +} + +/// The registry the booted supervisor publishes under the videre +/// namespace. +fn registry_of(supervisor: &Supervisor) -> Arc { + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service") +} + +/// A test block that drives dispatch and the dispatch-time sweeps. +fn block(chain_id: u64) -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + } +} + +/// Wrap a polled transition as the extension event the platform emits. +fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + ExtensionEvent { + kind: INTENT_STATUS, + attrs: vec![("venue", update.venue.clone())], + event: nexum::host::types::Event::IntentStatus(update), + } +} + +// ── world contract ──────────────────────────────────────────────────── + +/// The per-component venue-adapter world contract: an adapter built +/// 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. +#[test] +fn e2e_echo_venue_component_imports_equal_declared_capabilities() { + let Some(wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["chain"]), + "imports were: {imports:?}" + ); + + // No host key-material or persistence interface leaks in: an adapter + // structurally cannot reach messaging it never declared, local-store, + // identity, or logging. + assert!( + imports.iter().all(|name| !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The shipped cow adapter honours the same contract: outbound HTTP is +/// its only capability, so the component structurally cannot reach +/// chain, messaging, host key material, or persistence. +#[test] +fn e2e_cow_venue_component_imports_equal_declared_capabilities() { + let wasm = workspace_path("target/wasm32-wasip2/release/cow_venue.wasm"); + if !wasm.exists() { + eprintln!( + "SKIP: {} not found - build with `just build-cow-venue`", + wasm.display() + ); + return; + } + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["http"]), + "imports were: {imports:?}" + ); + assert!( + imports.iter().all(|name| !name.contains("nexum:host/chain") + && !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn provider_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + build_provider_linker::(&engine, &VenueAdapterKind) + .expect("provider linker assembles"); +} + +// ── intent-status subscription E2E ──────────────────────────────────── + +/// A scripted venue adapter for the registry: accepts every submission +/// with a fixed receipt and serves statuses front-first from a script; +/// once drained, every further call reports `open`. +struct ScriptedAdapter { + statuses: VecDeque, +} + +impl ScriptedAdapter { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: statuses.into_iter().collect(), + } + } +} + +fn native(bytes: Vec) -> value_flow::AssetAmount { + value_flow::AssetAmount { + asset: value_flow::Asset::Native, + amount: bytes, + } +} + +impl VenueInvoker for ScriptedAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(IntentHeader { + gives: native(vec![1]), + wants: native(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: videre_host::bindings::AuthScheme::Eip712, + }) + }) + } + + fn quote<'a>(&'a mut self, _body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(Quotation { + gives: native(vec![1]), + wants: native(Vec::new()), + fee: native(Vec::new()), + valid_until_ms: 1_700_000_000_000, + }) + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(SubmitOutcome::Accepted(b"receipt".to_vec())) }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(self.statuses.pop_front().unwrap_or(IntentStatus::Open)) }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { Ok(()) }) + } +} + +/// A registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(Default::default()).build(); + registry + .install( + VenueId::from("cow"), + nexum_runtime::host::actor::Liveness::default(), + adapter, + ) + .expect("install scripted adapter"); + registry +} + +/// Write a manifest subscribing the example module to intent-status +/// events from the `cow` venue. +fn intent_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + +/// Boot the example module against the given videre platform. +async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Supervisor { + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let limits = ModuleLimits::default(); + Supervisor::boot_single( + &engine, + &linker, + wasm, + Some(manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single") +} + +/// The acceptance path: a module subscribed to `intent-status` receives +/// 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] +async fn e2e_intent_status_subscription_receives_polled_transitions() { + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([ + IntentStatus::Pending, + IntentStatus::Fulfilled, + ])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // The registry watches the receipt of an accepted submission and polls + // the adapter's status export; each poll here observes a transition. + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + 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. + let foreign = videre_host::IntentStatusUpdate { + venue: "other".to_owned(), + receipt: b"receipt".to_vec(), + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(foreign)) + .await, + 0 + ); +} + +/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots on the venue +/// platform with its shipped manifest and handles a delivered cow status +/// transition without trapping. +#[tokio::test] +async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { + return; + }; + let manifest = workspace_path("modules/ethflow-watcher/module.toml"); + let videre = Arc::new(platform(&EngineConfig::default())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert_eq!(supervisor.alive_count(), 1); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + let update = videre_host::IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: vec![0xAB; 56], + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(update)) + .await, + 1 + ); + assert_eq!(supervisor.alive_count(), 1); +} + +/// The event-loop wiring, through the real seam: the platform's `events` +/// source opens against the booted service map, its poll task drives the +/// supervisor, and the module's handler observably ran (its log line is +/// retained). +#[tokio::test] +async fn e2e_intent_status_flows_through_the_event_loop() { + use nexum_tasks::{TaskManager, TaskSet}; + + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + // A fast cadence so the 300 ms window sees the first poll. + let mut config = EngineConfig::default(); + config.limits.status_poll.interval_ms = Some(10); + + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let subscribed = supervisor.extension_subscription_kinds(); + let streams = { + let mut sources = EventSources::new( + &config, + supervisor.services(), + &subscribed, + &executor, + &mut tasks, + ); + Extension::::events(&*videre, &mut sources).expect("open event source") + }; + assert_eq!(streams.len(), 1, "one status-poll stream opened"); + + nexum_runtime::runtime::event_loop::run( + &mut supervisor, + Vec::new(), + Vec::new(), + streams, + tasks, + tokio::time::sleep(Duration::from_millis(300)), + ) + .await; + + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + let runs = logs.list_runs("example"); + assert_eq!(runs.len(), 1, "one run recorded for the example module"); + let page = logs.read(&runs[0].run, 0); + assert!( + page.records + .iter() + .any(|r| r.message.contains("intent status update from venue cow")), + "the module's on_intent_status handler ran; records were: {:?}", + page.records + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + ); +} + +/// With no subscriber (or no installed venue) the platform opens no +/// event source. +#[tokio::test] +async fn event_source_stays_closed_without_subscribers_or_venues() { + use nexum_tasks::{TaskManager, TaskSet}; + + let config = EngineConfig::default(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let services = nexum_runtime::host::extension::HostServices::default(); + + // A venue is installed but nothing subscribes. + let with_venue = Arc::new(Videre::from_registry(scripted_registry( + ScriptedAdapter::new([]), + ))); + let empty = std::collections::BTreeSet::new(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &empty, &executor, &mut tasks); + let streams = Extension::::events(&*with_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no subscriber, no stream"); + + // A subscriber exists but no venue is installed. + let no_venue = Arc::new(platform(&config)); + let subscribed: std::collections::BTreeSet = + std::iter::once(INTENT_STATUS.to_owned()).collect(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &subscribed, &executor, &mut tasks); + let streams = Extension::::events(&*no_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no venue, no stream"); +} + +// ── echo round trip ─────────────────────────────────────────────────── + +/// The acceptance path, end to end over two real components: 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_registry_adapter_round_trip() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. The response body is + // discarded by the adapter, so any Ok value serves. + 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-client/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-client is alive"); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // A block drives the module's on_block, which submits to the echo venue + // through the shared registry; the registry watches the accepted receipt. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // 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 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"); + let body = + nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + assert_eq!( + body.status, + nexum_status_body::IntentStatus::Fulfilled, + "echo settles instantly", + ); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!( + delivered, 1, + "one terminal status delivered to the subscriber" + ); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // The module observably completed the round trip: it quoted, it + // submitted, and it received the settled status from the echo venue. + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quoted") && m.contains("echo-venue")), + "module quoted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submitted") && m.contains("echo-venue")), + "module submitted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("intent status from venue echo-venue")), + "module received the settled status; records were: {messages:?}", + ); +} + +/// 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` - +/// 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 shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` +/// worker) boots against the installed cow adapter - the body-version +/// handshake admits the pair - and a Sepolia block dispatch reaches it +/// and keeps it alive. The chainless poll surfaces a fault the strategy +/// absorbs, so no orderbook traffic occurs. +#[tokio::test] +async fn e2e_twap_monitor_boots_against_the_cow_adapter() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("cow-venue"), + module_wasm_or_skip("twap-monitor"), + ) else { + return; + }; + + let components = mock_components(); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("crates/cow-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/twap-monitor/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, "cow is routable"); + assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); + + // twap-monitor subscribes to Sepolia blocks (poll path); with no + // watches indexed the sweep is empty and the keeper stays alive. + assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); + assert_eq!(supervisor.alive_count(), 1); +} + +/// 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. +#[tokio::test] +async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1] +"#, + ) + .expect("write adapter manifest"); + let keeper_manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &keeper_manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[venue] +body_version = 2 +"#, + ) + .expect("write keeper manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(keeper_manifest), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("mismatched pair must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("body version 2"), "{chain}"); + assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); +} + +/// An adapter whose manifest claims versions its code does not decode +/// fails its own install: the `body-versions()` export must equal the +/// manifest `[venue] body_versions` set. +#[tokio::test] +async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { + let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1, 2] +"#, + ) + .expect("write adapter manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("a diverging adapter must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("exports body versions {1}"), "{chain}"); + assert!(chain.contains("declares {1, 2}"), "{chain}"); +} + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: ModuleLimits, +) -> (Supervisor, MockChainProvider) { + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = + nexum_runtime::test_utils::mock_components_from(chain.clone(), MockStateStore::new()); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/fixtures/flaky-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = boot_flaky_venue(wasm, ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(Duration::from_millis(1_500)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +} diff --git a/crates/videre-host/tests/zero_leak.rs b/crates/videre-host/tests/zero_leak.rs new file mode 100644 index 00000000..43f32ea5 --- /dev/null +++ b/crates/videre-host/tests/zero_leak.rs @@ -0,0 +1,155 @@ +//! Zero-leak oracle: the host boots the echo venue and routes a worker's +//! submission purely through the generic extension seam, while the +//! `nexum-runtime` crate graph reaches no venue-shaped crate. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::supervisor::{Supervisor, build_linker}; +use nexum_runtime::test_utils::{ + MockChainProvider, MockStateStore, MockTypes, mock_components_from, +}; +use videre_host::{VenueRegistry, platform}; + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir. +/// A missing artefact is a hard failure under CI (the gate may not skip +/// itself) and a soft skip locally. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + return Some(p); + } + assert!( + std::env::var_os("CI").is_none(), + "{} must be prebuilt in CI", + p.display() + ); + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None +} + +/// The boot oracle: the venue adapter installs and a worker's submission +/// reaches it, with the platform supplied only as a generic extension. +#[tokio::test] +async fn e2e_echo_venue_boots_and_submits_through_the_generic_seam() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = mock_components_from(chain, MockStateStore::new()); + + let mut engine_config = wasmtime::Config::new(); + engine_config.wasm_component_model(true); + engine_config.consume_fuel(true); + let engine = wasmtime::Engine::new(&engine_config).expect("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-client/module.toml")), + }], + ..Default::default() + }; + let extensions: Vec>> = vec![Arc::new(platform(&config))]; + let linker = build_linker::(&engine, &extensions).expect("build_linker"); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "echo-venue installed"); + assert_eq!(supervisor.alive_count(), 1, "echo-client alive"); + + // One block drives the worker's on_block submission; the registry the + // extension published on the service map observes the accepted receipt. + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + assert_eq!(supervisor.dispatch_block(block).await, 1); + let registry = supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service"); + let updates = registry.poll_status_transitions().await; + assert!( + updates.iter().any(|u| u.venue == "echo-venue"), + "the submission reached the venue; updates were: {updates:?}" + ); +} + +/// The graph oracle: `cargo tree` for the host crate (normal + build +/// edges) names no videre, intent, venue, or cow crate. +#[test] +fn host_crate_graph_reaches_no_venue_shaped_crate() { + let output = Command::new(env!("CARGO")) + .args([ + "tree", + "-p", + "nexum-runtime", + "-e", + "normal,build", + "--all-features", + "--prefix", + "none", + "--locked", + ]) + .current_dir(workspace_path("")) + .output() + .expect("cargo tree runs"); + assert!( + output.status.success(), + "cargo tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let tree = String::from_utf8_lossy(&output.stdout); + let reached: Vec<&str> = tree + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|name| { + let name = name.to_lowercase(); + ["videre", "intent", "venue", "cow"] + .iter() + .any(|word| name.contains(word)) + }) + .collect(); + assert!( + reached.is_empty(), + "venue-shaped crates reached: {reached:?}" + ); +} diff --git a/crates/videre-macros/Cargo.toml b/crates/videre-macros/Cargo.toml new file mode 100644 index 00000000..7bb32f49 --- /dev/null +++ b/crates/videre-macros/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "videre-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +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 + +[lints] +workspace = true + +[dependencies] +nexum-world = { path = "../nexum-world" } +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/videre-macros/src/intent_body.rs similarity index 74% rename from crates/nexum-macros/src/intent_body.rs rename to crates/videre-macros/src/intent_body.rs index 79b074cd..8ebbc3d6 100644 --- a/crates/nexum-macros/src/intent_body.rs +++ b/crates/videre-macros/src/intent_body.rs @@ -11,8 +11,10 @@ //! 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 -//! crate's re-export. +//! (`::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`. use proc_macro2::TokenStream; use quote::quote; @@ -80,12 +82,12 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { encode_arms.push(quote! { Self::#ident(payload) => { - let mut out = ::std::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: ::std::string::ToString::to_string(&err), + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string(&err), }, )?; ::core::result::Result::Ok(out) @@ -93,10 +95,12 @@ 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: ::std::string::ToString::to_string(&err), + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string( + &err, + ), })?, )), }); @@ -104,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< - ::std::vec::Vec, - ::nexum_venue_sdk::body::BodyError, + ::videre_sdk::body::__private::alloc::vec::Vec, + ::videre_sdk::body::BodyError, > { match self { #(#encode_arms)* @@ -118,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/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs new file mode 100644 index 00000000..568796d8 --- /dev/null +++ b/crates/videre-macros/src/keeper.rs @@ -0,0 +1,292 @@ +//! Expansion for `#[keeper]`: the keeper-worker mirror of `#[module]`. +//! +//! Same world synthesis and event dispatch as the plain module macro, +//! with the keeper deltas: the `client` capability is required, the +//! videre interfaces remap onto the SDK bindings (one shim set, one +//! type identity for the typed client), async handlers complete via +//! `videre_sdk::rt::complete`, and `ClientError` folds into the wire +//! fault so `?` works in handlers. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl}; + +/// The handler names recognised on a `#[keeper]` impl; the `#[module]` +/// set, since a keeper is a plain worker. +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_intent_status", +]; + +/// The manifest capability granting the client import. +const CLIENT_CAPABILITY: &str = "client"; + +/// The import the `client` capability must map to. +const CLIENT_IMPORT: &str = "videre:venue/client@0.1.0"; + +/// WIT packages the client import needs on the resolve path, in +/// dependency order. +const CLIENT_PACKAGES: [&str; 3] = ["videre-value-flow", "videre-types", "videre-venue"]; + +/// The fault detail for a handler future that suspended. +const SUSPENDED: &str = "keeper handler suspended: guest futures complete in one poll"; + +/// Expand the handler impl into the keeper module glue, or a compile +/// error naming the rule the input broke. +pub(crate) fn expand(input: &ItemImpl) -> syn::Result { + let self_ty = &input.self_ty; + if !crate::is_plain_type(self_ty) { + return Err(syn::Error::new_spanned( + self_ty, + "#[videre_sdk::keeper] must be applied to an inherent impl of a named type", + )); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return Err(syn::Error::new_spanned( + trait_path, + "#[videre_sdk::keeper] must be applied to an inherent impl, not a trait impl", + )); + } + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::keeper] must be applied to a non-generic impl", + )); + } + + // Reserve the `on_` prefix for the recognised handler set, exactly + // as `#[module]` does: a typo'd handler must not silently no-op. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return Err(syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[videre_sdk::keeper] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + )); + } + } + } + + // Present handlers with their asyncness: async ones are completed + // on the synchronous guest boundary by the emitted dispatch. + let present: Vec<(&str, bool)> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS + .into_iter() + .find(|h| *h == name) + .map(|h| (h, f.sig.asyncness.is_some())) + } + _ => None, + }) + .collect(); + if present.is_empty() { + return Err(syn::Error::new_spanned( + self_ty, + "#[videre_sdk::keeper] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", + )); + } + let handler = |name: &str| present.iter().find(|(h, _)| *h == name).copied(); + + let (anchors, module_world) = derive_keeper_world() + .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; + let wit_paths = crate::resolve_wit_packages(&module_world.packages) + .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; + let inline_world = &module_world.wit; + let adapter_caps: Vec = module_world + .adapters + .iter() + .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) + .collect(); + + // Complete an async handler's future in one poll; a suspension is a + // typed internal fault, never a hang. + let drive = |call: TokenStream| { + quote! { + match ::videre_sdk::rt::complete(#call) { + ::core::option::Option::Some(result) => result, + ::core::option::Option::None => ::core::result::Result::Err( + nexum::host::types::Fault::Internal( + ::std::string::String::from(#SUSPENDED), + ), + ), + } + } + }; + + let init_impl = match handler("init") { + Some((_, is_async)) => { + let call = quote! { <#self_ty>::init(config) }; + let body = if is_async { drive(call) } else { call }; + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + #body + } + } + } + None => quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + }, + }; + + let arm = |name: &str, variant: &str| -> TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + match handler(name) { + Some((_, is_async)) => { + let call = syn::Ident::new(name, proc_macro2::Span::call_site()); + let call = quote! { <#self_ty>::#call(payload) }; + let body = if is_async { drive(call) } else { call }; + quote! { nexum::host::types::Event::#variant(payload) => #body, } + } + None => quote! { + nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), + }, + } + }; + let block_arm = arm("on_block", "Block"); + let logs_arm = arm("on_chain_logs", "ChainLogs"); + let tick_arm = arm("on_tick", "Tick"); + let message_arm = arm("on_message", "Message"); + let intent_status_arm = arm("on_intent_status", "IntentStatus"); + + Ok(quote! { + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", + generate_all, + with: { + "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, + "videre:value-flow/types@0.1.0": + ::videre_sdk::bindings::videre::value_flow::types, + "videre:venue/client@0.1.0": + ::videre_sdk::bindings::videre::venue::client, + }, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + + #input + + // Folds a typed client failure into the wire fault, so `?` + // applies to client calls inside handlers. + impl ::core::convert::From<::videre_sdk::ClientError> for nexum::host::types::Fault { + fn from(err: ::videre_sdk::ClientError) -> Self { + ::core::convert::Into::into(::nexum_sdk::host::Fault::from(err)) + } + } + + #[doc(hidden)] + struct __VidereKeeperExport; + + impl Guest for __VidereKeeperExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + #intent_status_arm + } + } + } + + export!(__VidereKeeperExport); + }) +} + +/// The canonical `client` extension row, injected when the composition +/// root's registry does not carry one. +fn client_row() -> nexum_world::ExtensionRow { + nexum_world::ExtensionRow { + name: CLIENT_CAPABILITY.to_owned(), + import: CLIENT_IMPORT.to_owned(), + packages: CLIENT_PACKAGES.map(str::to_owned).into(), + } +} + +/// Read the consuming crate's `module.toml`, require the worker shape +/// (no `[module] kind`) and the `client` capability, and synthesize the +/// per-module world with the client extension row guaranteed. Returns +/// the rebuild anchor paths alongside the world. +fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { + let manifest_path = crate::manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[videre_sdk::keeper] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + if let Some(kind) = nexum_world::manifest_kind(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))? + { + return Err(format!( + "{}: a #[videre_sdk::keeper] module is a plain worker; drop `[module] kind = \ + \"{kind}\"`", + manifest_path.display() + )); + } + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + if !declared.iter().any(|cap| cap == CLIENT_CAPABILITY) { + return Err(format!( + "{}: a keeper drives venues through `{CLIENT_IMPORT}`; declare the \ + `{CLIENT_CAPABILITY}` capability under [capabilities]", + manifest_path.display() + )); + } + let manifest_path = manifest_path.to_string_lossy().into_owned(); + + let mut anchors = vec![manifest_path.clone()]; + let mut extensions = match nexum_world::find_extensions_manifest(&crate::manifest_dir()?) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = nexum_world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + match extensions.iter().find(|row| row.name == CLIENT_CAPABILITY) { + None => extensions.push(client_row()), + Some(row) if row.import == CLIENT_IMPORT => {} + Some(row) => { + return Err(format!( + "the registered `{CLIENT_CAPABILITY}` extension imports `{}`; \ + #[videre_sdk::keeper] requires `{CLIENT_IMPORT}`", + row.import + )); + } + } + let module_world = nexum_world::synthesize(&declared, &extensions) + .map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) +} diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs new file mode 100644 index 00000000..665fb88a --- /dev/null +++ b/crates/videre-macros/src/lib.rs @@ -0,0 +1,270 @@ +//! Proc-macro glue for the two videre personas. +//! +//! [`venue`] is the single blessed venue authoring path: applied to an +//! `impl VenueAdapter` block it emits the per-cdylib wit-bindgen for a +//! manifest-derived world exporting `videre:venue/adapter`, asserts the +//! manifest kind, and expands to the SDK's internal export codegen. +//! +//! [`keeper`] is its worker mirror: applied to a handler impl it emits +//! the per-cdylib wit-bindgen for a manifest-derived module world, +//! wires the `videre:venue/client` import onto the SDK's shared shims, +//! and dispatches events to the handlers, completing async ones on the +//! synchronous guest boundary. +//! +//! [`derive@IntentBody`] implements the venue SDK's versioned body codec +//! over a per-venue version enum. +//! +//! The plain module macro (`#[module]`) lives in `nexum-module-macros`. +//! +//! Consumers reach these through the SDK re-exports +//! (`videre_sdk::venue`, `videre_sdk::keeper`, `videre_sdk::IntentBody`) +//! rather than depending on this crate directly. + +mod intent_body; +mod keeper; +mod world; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, ItemImpl, Type}; + +/// Derive the venue SDK's `IntentBody` codec on the outer per-venue +/// version enum: one newtype variant per published body version, each +/// payload a borsh type. +/// +/// The wire form is the borsh enum layout (a one-byte tag, the variant's +/// declaration index, then the borsh payload), so the tag order is the +/// schema: append new versions, never reorder. Decoding an unknown tag +/// fails typedly as `BodyError::UnknownVersion`. +/// +/// Generated code resolves the SDK by crate path, so use the +/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a +/// direct dependency. +#[proc_macro_derive(IntentBody)] +pub fn derive_intent_body(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + intent_body::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// The manifest `kind` a venue adapter must declare. Mirrors the +/// venue-adapter provider kind's manifest spelling. +const VENUE_KIND: &str = "venue-adapter"; + +/// Generate the per-cdylib glue for a venue adapter. +/// +/// Apply to the adapter's `impl VenueAdapter for MyVenue` block: the +/// macro reads the crate's `module.toml`, asserts its `[module] kind` +/// is `venue-adapter`, synthesizes a per-component world exporting the +/// `videre:venue/adapter` face and importing exactly the manifest's +/// declared scoped transport, then emits `wit_bindgen::generate!`, the +/// untouched trait impl, and the SDK's internal export codegen wiring +/// the world's `Guest` faces through the trait. So the built component +/// imports what the manifest declares and nothing else, by construction +/// of the emitted world. +/// +/// The generated world remaps `videre:types/types`, +/// `videre:value-flow/types`, and `nexum:host/types` onto the SDK's +/// bindings, so the impl speaks `videre_sdk` types directly and shares +/// type identity with the conformance kit and the client core. +/// +/// A venue's capabilities are scoped transport only: an undeclared +/// capability's bindings do not exist (using one is a compile error), +/// and a capability outside the venue-permitted set (`chain`, +/// `messaging`, `http`) is rejected at expansion. +/// +/// The same crate-root resolution invariants as `#[module]` apply: the +/// wit-bindgen output lands at the module crate root (so the export +/// codegen resolves `Guest`, `exports`, and `export!` there), and the +/// consuming crate must declare `wit-bindgen` and `videre-sdk` as +/// direct dependencies. +#[proc_macro_attribute] +pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[videre_sdk::venue] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let Some((None, trait_path, _)) = &input.trait_ else { + return syn::Error::new_spanned( + &input.self_ty, + "#[videre_sdk::venue] must be applied to an `impl VenueAdapter for ...` block", + ) + .to_compile_error() + .into(); + }; + if trait_path + .segments + .last() + .is_none_or(|segment| segment.ident != "VenueAdapter") + { + return syn::Error::new_spanned( + trait_path, + "#[videre_sdk::venue] must be applied to an impl of `videre_sdk::VenueAdapter`", + ) + .to_compile_error() + .into(); + } + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[videre_sdk::venue] must be applied to an impl on a named type", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::venue] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + let (manifest_path, venue_world) = match derive_venue_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match resolve_wit_packages(&venue_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &venue_world.wit; + + quote! { + // Anchor a rebuild on the manifest: the emitted world is derived + // from it, so an edited [capabilities] must recompile the adapter. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:venue-world/venue-adapter", + generate_all, + with: { + "nexum:host/types@0.1.0": ::videre_sdk::bindings::nexum::host::types, + "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, + "videre:value-flow/types@0.1.0": + ::videre_sdk::bindings::videre::value_flow::types, + }, + }); + + #input + + ::videre_sdk::__export_venue_adapter!(#self_ty); + } + .into() +} + +/// Generate the per-cdylib glue for a keeper: a worker that drives +/// venues through the typed client. +/// +/// The keeper-author mirror of `#[module]`. Apply to an `impl` block +/// whose associated functions are the event handlers (`init`, +/// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, +/// `on_intent_status`); handlers may be `async` and are completed on +/// the synchronous guest boundary (`videre_sdk::rt::complete`), so a +/// handler can await the typed `VenueClient` directly. +/// +/// The macro reads the crate's `module.toml`, requires the `client` +/// capability (the `videre:venue/client` import is what makes a keeper +/// a keeper), synthesizes the per-module world exactly as `#[module]` +/// does, and remaps the videre interfaces onto the SDK bindings: the +/// module's client import resolves to the SDK's shared shims, so the +/// `VenueClient` a handler drives and the wire speak one set of types. +/// A `From` impl onto the wire fault is emitted, so `?` +/// applies to client calls inside handlers. +/// +/// The same crate-root resolution invariants as `#[module]` apply, and +/// the consuming crate must declare `wit-bindgen`, `videre-sdk`, and +/// `nexum-sdk` as direct dependencies. +#[proc_macro_attribute] +pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[videre_sdk::keeper] takes no arguments", + ) + .to_compile_error() + .into(); + } + let input = syn::parse_macro_input!(item as ItemImpl); + keeper::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +pub(crate) fn is_plain_type(ty: &Type) -> bool { + matches!(ty, Type::Path(tp) if tp.qself.is_none()) +} + +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +pub(crate) fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + +/// Read the consuming crate's `module.toml`, assert it declares the +/// venue-adapter kind, and synthesize the per-component venue-adapter +/// world from its `[capabilities]` declarations. Returns the manifest +/// path (for the rebuild anchor) alongside the world. +fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { + let manifest_path = manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[videre_sdk::venue] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + let kind = nexum_world::manifest_kind(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + if kind.as_deref() != Some(VENUE_KIND) { + return Err(format!( + "{}: [module] kind must be \"{VENUE_KIND}\" for a #[videre_sdk::venue] adapter, \ + found {}", + manifest_path.display(), + kind.map_or_else(|| "none".to_owned(), |kind| format!("\"{kind}\"")), + )); + } + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let manifest_path = manifest_path.to_string_lossy().into_owned(); + let venue_world = + world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((manifest_path, venue_world)) +} + +/// Resolve each needed WIT package directory crate-locally (vendored +/// `wit/deps/`, then own `wit/`), falling back through +/// ancestors for the transitional monorepo layout. +pub(crate) fn resolve_wit_packages(packages: &[String]) -> Result, String> { + Ok( + nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) +} diff --git a/crates/videre-macros/src/world.rs b/crates/videre-macros/src/world.rs new file mode 100644 index 00000000..30a059ad --- /dev/null +++ b/crates/videre-macros/src/world.rs @@ -0,0 +1,155 @@ +//! World wiring for the venue macro: the venue-adapter world synthesis. +//! The module world synthesis, the core capability table, and the +//! extension registry parsing live in `nexum-world`. + +pub use nexum_world::ModuleWorld; + +/// Capabilities a venue adapter may import. A venue speaks one venue's +/// protocol over scoped transport and nothing else: chain RPC, +/// messaging, and outbound HTTP (granted through the SDK's wasi:http +/// client, so no world import). It structurally cannot reach host key +/// material or persistent state, so `local-store`, `remote-store`, +/// `identity`, and `logging` are refused rather than silently imported. +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 +/// `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 +/// persistent state is rejected at expansion, not at boot. +pub fn synthesize_venue(declared: &[String]) -> Result { + for name in declared { + if !VENUE_CAPABILITIES.contains(&name.as_str()) { + let permitted = VENUE_CAPABILITIES.join(", "); + return Err(format!( + "capability `{name}` is not available to a venue adapter; a venue may import \ + only scoped transport ({permitted}) and structurally cannot touch local-store, \ + remote-store, identity, or logging" + )); + } + } + + let mut imports = String::new(); + // 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 = [ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ] + .map(str::to_owned) + .into(); + for cap in nexum_world::CORE { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + // Accumulate any extra WIT packages a venue capability needs, + // exactly as the module synthesis does. All venue-permitted + // capabilities are packageless today, so this leaves the base set + // untouched; mirroring the loop keeps a future venue capability + // from silently failing to reach its package onto the resolve + // path. + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + } + + let mut wit = String::from( + "package nexum:venue-world;\n\nworld venue-adapter {\n \ + use nexum:host/types@0.1.0.{config, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export videre:venue/adapter@0.1.0;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + // The venue export glue wires the adapter's associated functions + // to the world's Guest traits directly; there is no host-trait + // adapter to bind, so no capability idents to pass on. + adapters: Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The package set every venue world resolves against: the exported + /// 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 venue_world_exports_the_adapter_face() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.starts_with("package nexum:venue-world;")); + assert!(world.wit.contains("world venue-adapter {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); + assert_eq!(world.packages, VENUE_PACKAGES); + assert!(world.adapters.is_empty()); + } + + #[test] + fn venue_world_imports_only_declared_transport() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.contains("import nexum:host/chain@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/messaging")); + + let both = synthesize_venue(&["chain".to_string(), "messaging".to_string()]).unwrap(); + assert!(both.wit.contains("import nexum:host/chain@0.1.0;")); + assert!(both.wit.contains("import nexum:host/messaging@0.1.0;")); + } + + #[test] + fn venue_world_grants_http_without_a_world_import() { + let world = synthesize_venue(&["http".to_string()]).unwrap(); + assert!(!world.wit.contains("import")); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, VENUE_PACKAGES); + } + + #[test] + fn venue_world_with_no_capabilities_imports_nothing() { + let world = synthesize_venue(&[]).unwrap(); + assert!(!world.wit.contains("import")); + 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", + "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-venue-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml similarity index 57% rename from crates/nexum-venue-sdk/Cargo.toml rename to crates/videre-sdk/Cargo.toml index f3fd250a..bbf43928 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 mirroring the venue-adapter world, the borsh-versioned IntentBody codec, the typed venue client over the native-AFIT transport seam, 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 @@ -21,13 +21,20 @@ workspace = true # the derive's generated code so an adapter crate needs no direct borsh # declaration unless its payloads derive the borsh traits themselves. borsh.workspace = true -# Source of the `IntentBody` derive, re-exported at the crate root next -# to the trait it implements. -nexum-macros = { path = "../nexum-macros" } +# Source of `#[venue]` and the `IntentBody` derive, re-exported at the +# crate root next to the trait they implement against. +videre-macros = { path = "../videre-macros" } # Host-neutral SDK layer this crate builds on: the `ChainHost` seam the # chain wrapper implements, the shared `Fault` vocabulary, and the # wasi:http `fetch` surface re-exported as `transport::http`. nexum-sdk = { path = "../nexum-sdk" } +# The standard request/response types the `Fetch` seam (and the +# `TimedFetch` middleware over it) is expressed in. +http.workspace = true 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 59% rename from crates/nexum-venue-sdk/src/adapter.rs rename to crates/videre-sdk/src/adapter.rs index 58fc8ffc..0e6f3edb 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -1,17 +1,18 @@ -//! The [`VenueAdapter`] trait and the export glue that turns an impl of -//! it into the component's `venue-adapter` world surface. +//! The [`VenueAdapter`] trait and the internal export codegen that turns +//! an impl of 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 intent functions and the body-version declaration +//! 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. -use crate::{Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; +use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; /// One venue's protocol speaker: the guest-side face of the -/// `venue-adapter` world. Implement it on a unit struct and hand that to -/// [`export_venue_adapter!`](crate::export_venue_adapter); bodies and +/// `venue-adapter` world. Implement it on a unit struct and apply +/// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and /// receipts arrive as the opaque bytes the wire carries, and impls /// recover typing through [`IntentBody`](crate::IntentBody) (whose /// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). @@ -21,12 +22,24 @@ pub trait VenueAdapter { /// boots both component kinds through the same machinery. fn init(config: Config) -> Result<(), Fault>; + /// Body-schema versions this adapter decodes. Install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to + /// declaring none. + fn body_versions() -> Vec { + Vec::new() + } + /// Project an opaque intent body onto the stable header guard /// policy runs on. Must be a pure derivation: no transport, no side /// effects, so the host can inspect a header before deciding to - /// submit. + /// submit. The host's guard checkpoint is advisory-only until the + /// egress-guard epic lands: a would-deny is logged, not enforced. fn derive_header(body: Vec) -> Result; + /// Price an opaque intent body: an indicative quotation, not an + /// offer the venue is bound to fill. + fn quote(body: Vec) -> 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. @@ -41,20 +54,20 @@ pub trait VenueAdapter { fn cancel(receipt: Vec) -> Result<(), VenueError>; } -/// Export a [`VenueAdapter`] impl as the crate's `venue-adapter` world. -/// -/// Invoke once at the top level of the adapter's cdylib crate. Emits a -/// hidden shim type wiring the world's `Guest` traits to the adapter's -/// associated functions, then the wit-bindgen export glue; the linker -/// rejects a second invocation in one component (duplicate export -/// symbols), matching the one-adapter-per-component contract. +/// Internal codegen `#[videre_sdk::venue]` expands to: a hidden shim +/// wiring a [`VenueAdapter`] impl to the macro-synthesized world's +/// `Guest` faces, then that world's `export!`. `Guest`, `exports`, and +/// `export!` resolve at the expansion site (the adapter crate root, +/// where the attribute put the world's bindgen), so the macro is +/// meaningful only inside the attribute's output. Not public API. +#[doc(hidden)] #[macro_export] -macro_rules! export_venue_adapter { +macro_rules! __export_venue_adapter { ($adapter:ty) => { #[doc(hidden)] - struct __NexumVenueAdapterExport; + struct __VidereVenueAdapterExport; - impl $crate::bindings::Guest for __NexumVenueAdapterExport { + impl Guest for __VidereVenueAdapterExport { fn init( config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, ) -> ::core::result::Result<(), $crate::Fault> { @@ -62,15 +75,23 @@ macro_rules! export_venue_adapter { } } - impl $crate::bindings::exports::nexum::intent::adapter::Guest - for __NexumVenueAdapterExport - { + impl exports::videre::venue::adapter::Guest for __VidereVenueAdapterExport { + fn body_versions() -> ::std::vec::Vec { + <$adapter as $crate::VenueAdapter>::body_versions() + } + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { <$adapter as $crate::VenueAdapter>::derive_header(body) } + fn quote( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::Quotation, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::quote(body) + } + fn submit( body: ::std::vec::Vec, ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { @@ -90,8 +111,6 @@ macro_rules! export_venue_adapter { } } - $crate::bindings::__export_venue_adapter_world!( - __NexumVenueAdapterExport with_types_in $crate::bindings - ); + export!(__VidereVenueAdapterExport); }; } diff --git a/crates/videre-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs new file mode 100644 index 00000000..9f57dde8 --- /dev/null +++ b/crates/videre-sdk/src/bindings.rs @@ -0,0 +1,43 @@ +//! Guest bindings for the videre SDK, generated once from an +//! import-only inline world carrying every interface both personas +//! speak: the videre type vocabulary, the host types and scoped +//! transport, and the keeper-facing `videre:venue/client` shims. +//! +//! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, +//! the SDK generates these bindings once: the +//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport +//! wrappers, and the client core are all expressed over them. The +//! per-cdylib bindgens (`#[videre_sdk::venue]`, `#[videre_sdk::keeper]`) +//! remap the shared interfaces onto these modules with `with`, so a +//! macro-built component shares type identity (and, for the keeper's +//! client, one shim set) with the SDK and the conformance kit. +//! +//! The world is import-only on purpose: an embedded world section is +//! unioned into every linking module at componentization, where an +//! unused import prunes but an export is a hard obligation. Keeping the +//! adapter export out of the SDK world is what lets a keeper module +//! link this crate without being asked to be a venue; the export face +//! is emitted per-cdylib by `#[videre_sdk::venue]` alone. + +wit_bindgen::generate!({ + inline: "package videre:sdk-shims; + +world sdk-imports { + import videre:types/types@0.1.0; + import videre:value-flow/types@0.1.0; + import nexum:host/types@0.1.0; + import nexum:host/chain@0.1.0; + import nexum:host/messaging@0.1.0; + import videre:venue/client@0.1.0; +} +", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + world: "videre:sdk-shims/sdk-imports", + generate_all, + additional_derives: [PartialEq], +}); diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/videre-sdk/src/body.rs similarity index 80% rename from crates/nexum-venue-sdk/src/body.rs rename to crates/videre-sdk/src/body.rs index d542aca2..33c40378 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>; @@ -37,6 +38,7 @@ pub trait IntentBody: Sized { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum BodyError { /// No bytes at all: not even a version tag. #[error("empty body: missing the version tag")] @@ -72,23 +74,29 @@ 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()), } } } /// Re-exports for `#[derive(IntentBody)]` generated code only; not a -/// public surface. +/// public surface. `alloc` rides along so the expansion resolves in a +/// `#![no_std]` consumer without its own `extern crate alloc`. #[doc(hidden)] 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/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs new file mode 100644 index 00000000..63ff375d --- /dev/null +++ b/crates/videre-sdk/src/client.rs @@ -0,0 +1,310 @@ +//! The typed venue client: [`VenueClient`] binds one [`Venue`] over the +//! byte-level [`VenueTransport`] seam. +//! +//! The wire carries opaque bodies and a stringly venue selector; typing +//! is recovered here. A venue is named once, as a [`Venue`] marker +//! carrying its [`VenueId`] and body schema, and every call encodes +//! through [`IntentBody`] before the seam, so keeper code never handles +//! wire bytes. [`HostVenues`] is the seam bound to the module's own +//! `videre:venue/client` import; tests and in-process adapters +//! implement [`VenueTransport`] directly. The transport methods are +//! native AFIT, so dispatch is static and nothing on the call path +//! boxes. + +use std::borrow::Cow; +use std::fmt; +use std::future::Future; +use std::marker::PhantomData; + +use strum::IntoStaticStr; + +use crate::bindings::videre::venue::client as shims; +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(Cow<'static, str>); + +impl VenueId { + /// Wrap a static id without allocating: the [`Venue::ID`] spelling. + #[must_use] + pub const fn from_static(id: &'static str) -> Self { + Self(Cow::Borrowed(id)) + } + + /// 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(Cow::Owned(id)) + } +} + +impl From<&str> for VenueId { + fn from(id: &str) -> Self { + Self(Cow::Owned(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) + } +} + +/// One venue as a keeper types it: the id its adapter registers under +/// and the body schema it decodes. Implement on a unit marker +/// (`struct CowVenue;`) and drive it through [`VenueClient`]. +pub trait Venue { + /// The id the venue's adapter registers under. + const ID: VenueId; + + /// The versioned body schema the venue decodes. + type Body: IntentBody; +} + +/// Sealing marker for [`VenueTransport`]: a transport opts in by also +/// implementing it. +#[doc(hidden)] +pub mod sealed { + pub trait SealedTransport {} +} + +/// The byte-level seam under the typed client: `videre:venue/client` +/// with the venue named per call. Native AFIT, so a [`VenueClient`] +/// over any transport dispatches statically. [`HostVenues`] binds it to +/// the module's own import; tests implement it in memory. +/// +/// Sealed: a transport opts in by also implementing the sealing marker. +pub trait VenueTransport: sealed::SealedTransport { + /// Price an opaque intent body at the named venue. + fn quote( + &self, + venue: &VenueId, + body: Vec, + ) -> impl Future>; + + /// Submit an opaque intent body to the named venue. + fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> impl Future>; + + /// Put an externally-obtained receipt under the host's status + /// watch; an accepted submit is watched implicitly. Defaults to + /// `unsupported`: a transport that can watch foreign receipts opts + /// in. + fn observe( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future> { + let _ = (venue, receipt); + async { Err(VenueFault::Unsupported) } + } + + /// Report where a previously submitted intent is in its life. + fn status( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future>; + + /// 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: &VenueId, + receipt: &[u8], + ) -> impl Future>; +} + +/// The module's `videre:venue/client` import behind the +/// [`VenueTransport`] seam: the transport every guest-side +/// [`VenueClient`] defaults to. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostVenues; + +impl sealed::SealedTransport for HostVenues {} + +impl VenueTransport for HostVenues { + async fn quote(&self, venue: &VenueId, body: Vec) -> Result { + shims::quote(venue.as_str(), &body).map_err(VenueFault::from) + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + shims::submit(venue.as_str(), &body).map_err(VenueFault::from) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::observe(venue.as_str(), receipt).map_err(VenueFault::from) + } + + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + shims::status(venue.as_str(), receipt).map_err(VenueFault::from) + } + + async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::cancel(venue.as_str(), receipt).map_err(VenueFault::from) + } +} + +/// A typed client bound to one [`Venue`]: encodes the venue's +/// [`IntentBody`] to wire bytes and forwards through the +/// [`VenueTransport`] seam under [`Venue::ID`]. Zero-sized over the +/// default [`HostVenues`] transport. +pub struct VenueClient { + transport: T, + venue: PhantomData, +} + +impl VenueClient { + /// Bind the venue over the module's own `videre:venue/client` + /// import. + #[must_use] + pub const fn new() -> Self { + Self { + transport: HostVenues, + venue: PhantomData, + } + } +} + +impl Default for VenueClient { + fn default() -> Self { + Self::new() + } +} + +impl VenueClient { + /// Bind the venue over a caller-supplied transport (tests, + /// in-process adapters). + pub const fn with_transport(transport: T) -> Self { + Self { + transport, + venue: PhantomData, + } + } + + /// The venue id every call on this client routes to. + #[must_use] + pub fn venue(&self) -> VenueId { + V::ID + } + + /// Encode the typed body and price it at the bound venue. The + /// returned [`Quoted`] carries the encoded bytes, so `submit` sends + /// exactly the body the venue priced. + pub async fn quote(&self, body: &V::Body) -> Result, ClientError> { + let bytes = body.to_bytes()?; + let quotation = self.transport.quote(&V::ID, bytes.clone()).await?; + Ok(Quoted { + client: self, + bytes, + quotation, + }) + } + + /// Encode the typed body and submit it to the bound venue. + pub async fn submit(&self, body: &V::Body) -> Result { + let bytes = body.to_bytes()?; + Ok(self.transport.submit(&V::ID, bytes).await?) + } + + /// Put an externally-obtained receipt under the host's status + /// watch at the bound venue. + pub async fn observe(&self, receipt: &[u8]) -> Result<(), ClientError> { + Ok(self.transport.observe(&V::ID, receipt).await?) + } + + /// Report where a previously submitted intent is in its life. + pub async fn status(&self, receipt: &[u8]) -> Result { + Ok(self.transport.status(&V::ID, receipt).await?) + } + + /// Ask the bound venue to withdraw an intent. + pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + Ok(self.transport.cancel(&V::ID, receipt).await?) + } +} + +impl Clone for VenueClient { + fn clone(&self) -> Self { + Self { + transport: self.transport.clone(), + venue: PhantomData, + } + } +} + +impl fmt::Debug for VenueClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VenueClient") + .field("venue", &V::ID) + .finish_non_exhaustive() + } +} + +/// A priced intent: the quotation plus the exact bytes it prices, bound +/// to the client that fetched it. Consuming it with +/// [`submit`](Self::submit) is the only way from a quote to a +/// submission, so a keeper cannot submit a body other than the one +/// quoted. +pub struct Quoted<'a, V: Venue, T: VenueTransport> { + client: &'a VenueClient, + bytes: Vec, + quotation: Quotation, +} + +impl Quoted<'_, V, T> { + /// The venue's indicative quotation for the body. + #[must_use] + pub fn quotation(&self) -> &Quotation { + &self.quotation + } + + /// Submit the quoted body to the venue that priced it. + pub async fn submit(self) -> Result { + Ok(self.client.transport.submit(&V::ID, self.bytes).await?) + } +} + +impl fmt::Debug for Quoted<'_, V, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Quoted") + .field("venue", &V::ID) + .field("quotation", &self.quotation) + .finish_non_exhaustive() + } +} + +/// Why a typed client call failed: before the wire (the body failed to +/// 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, 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 refused the call. + #[error(transparent)] + Venue(#[from] VenueFault), +} diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs new file mode 100644 index 00000000..ac57203f --- /dev/null +++ b/crates/videre-sdk/src/faults.rs @@ -0,0 +1,233 @@ +//! 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; 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 +//! adapter always preserves the most structured form the target +//! vocabulary can carry. + +use nexum_sdk::host; +use strum::IntoStaticStr; + +use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; +use crate::client::ClientError; +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. +pub fn fault_into_sdk(fault: Fault) -> host::Fault { + match fault { + Fault::Unsupported(s) => host::Fault::Unsupported(s), + Fault::Unavailable(s) => host::Fault::Unavailable(s), + Fault::Denied(s) => host::Fault::Denied(s), + Fault::RateLimited(rl) => host::Fault::RateLimited(host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + Fault::Timeout => host::Fault::Timeout, + Fault::InvalidInput(s) => host::Fault::InvalidInput(s), + Fault::Internal(s) => host::Fault::Internal(s), + } +} + +/// Lower the SDK-neutral fault back into the wire fault an adapter's +/// `init` returns, so a helper's `host::Fault` propagates with `?`. +/// +/// Carries a wildcard arm because `host::Fault` is `#[non_exhaustive]`: +/// a future SDK case lands as `internal` carrying its `Display` detail. +impl From for Fault { + fn from(fault: host::Fault) -> Self { + match fault { + host::Fault::Unsupported(s) => Fault::Unsupported(s), + host::Fault::Unavailable(s) => Fault::Unavailable(s), + host::Fault::Denied(s) => Fault::Denied(s), + host::Fault::RateLimited(rl) => Fault::RateLimited(WireRateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => Fault::Timeout, + host::Fault::InvalidInput(s) => Fault::InvalidInput(s), + host::Fault::Internal(s) => Fault::Internal(s), + other => Fault::Internal(other.to_string()), + } + } +} + +/// Fold a transport fault into the venue error an intent function +/// 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(_) => 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 typed client failure into the SDK-neutral fault a keeper +/// handler returns: an encode failure and a misnamed venue are the +/// caller's `invalid-input`; venue refusals map structurally. +impl From for host::Fault { + fn from(err: ClientError) -> Self { + match err { + ClientError::Body(body) => host::Fault::InvalidInput(body.to_string()), + ClientError::Venue(fault) => match fault { + VenueFault::UnknownVenue => host::Fault::InvalidInput(fault.to_string()), + VenueFault::InvalidBody(s) => host::Fault::InvalidInput(s), + VenueFault::Unsupported => host::Fault::Unsupported(fault.to_string()), + VenueFault::Denied(s) => host::Fault::Denied(s), + VenueFault::RateLimited { retry_after_ms } => { + host::Fault::RateLimited(host::RateLimit { retry_after_ms }) + } + VenueFault::Unavailable(s) => host::Fault::Unavailable(s), + VenueFault::Timeout => host::Fault::Timeout, + }, + } + } +} + +/// Fold a wasi:http fetch failure into the venue error an intent +/// 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(_) => VenueError::Timeout, + // `FetchError` is `#[non_exhaustive]`: a future transport + // case folds to retryable `unavailable` with its detail. + _ => VenueError::Unavailable(err.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host; + + use crate::{Fault, VenueError}; + + #[test] + fn wire_fault_round_trips_through_sdk() { + let cases = [ + Fault::Unsupported("u".into()), + Fault::Unavailable("u".into()), + Fault::Denied("d".into()), + Fault::RateLimited(crate::bindings::nexum::host::types::RateLimit { + retry_after_ms: Some(250), + }), + Fault::Timeout, + Fault::InvalidInput("i".into()), + Fault::Internal("i".into()), + ]; + for case in cases { + let there = super::fault_into_sdk(case.clone()); + assert_eq!(Fault::from(there), case); + } + } + + #[test] + fn transport_fault_folds_to_venue_error_by_shape() { + assert_eq!( + 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::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::Unavailable(_) + )); + } + + #[test] + fn fetch_error_folds_to_venue_error_by_shape() { + use nexum_sdk::http::FetchError; + assert!(matches!( + VenueError::from(FetchError::Denied), + VenueError::Denied(_) + )); + assert!(matches!( + VenueError::from(FetchError::Transport("reset".into())), + VenueError::Unavailable(_) + )); + assert!(matches!( + VenueError::from(FetchError::InvalidRequest("bad url".into())), + VenueError::Unavailable(_) + )); + } +} diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs new file mode 100644 index 00000000..8ca91a3e --- /dev/null +++ b/crates/videre-sdk/src/keeper.rs @@ -0,0 +1,483 @@ +//! 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 +//! [`VenueTransport`] 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::{VenueId, VenueTransport}; +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 seam, and + /// run every other outcome and every venue refusal through the + /// [`Retrier`]. The [`submission_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 async 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).await { + 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. Public so a +/// keeper journalling outside [`Keeper::sweep`] writes the key the +/// sweep checks. +pub 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. Public so a +/// keeper sweeping outside [`Keeper::sweep`] folds refusals the same +/// way. +pub 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::{VenueId, VenueTransport}; + use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; + + /// Drive a sweep on the test's synchronous boundary. + fn run(future: F) -> F::Output { + crate::rt::complete(future).expect("sweep futures complete in one poll") + } + + /// 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 crate::client::sealed::SealedTransport for &StubVenue {} + + impl VenueTransport for &StubVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + _venue: &VenueId, + body: Vec, + ) -> Result { + self.submitted.borrow_mut().push(body); + self.outcome.clone() + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async 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 = run(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 = run(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!( + run(keeper.sweep(&host, &TICK)) + .expect("sweep runs") + .submitted, + 1 + ); + assert_eq!( + run(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 = run(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 = run(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 = run(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 = run(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 = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.retried, 1); + + // Still inside the backoff window: gated, not polled. + let report = run(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 = run(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 = run(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!( + run(keeper.sweep(&host, &at_2s)).expect("sweep runs").gated, + 1 + ); + let at_3s = Tick { + epoch_s: TICK.epoch_s + 3, + ..TICK + }; + assert_eq!( + run(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 = run(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 = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.retried, 1); + assert_eq!( + run(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 = run(keeper(Sweep::WaitBlock, &venue).sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report, SweepReport::default()); + } +} diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs new file mode 100644 index 00000000..dd96575c --- /dev/null +++ b/crates/videre-sdk/src/lib.rs @@ -0,0 +1,115 @@ +//! # videre-sdk +//! +//! 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 +//! +//! - [`VenueAdapter`] - the trait mirroring the world's export face +//! (`init` plus the five intent functions). `#[videre_sdk::venue]` +//! on the impl turns it into the component's export glue: the single +//! blessed authoring path. +//! +//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh +//! codec over the outer per-venue version enum. The wire form is a +//! one-byte version tag plus the borsh payload; an unknown tag fails +//! typedly rather than as a stringly decode error. +//! +//! - [`client`] - the typed venue client: a [`Venue`] marker (its +//! [`VenueId`] plus body schema) drives [`VenueClient`], which +//! encodes through [`IntentBody`] before the byte-level, native-AFIT +//! [`VenueTransport`] seam ([`HostVenues`] binds it to the module's +//! own `videre:venue/client` import). Lives here (not in the +//! strategy SDK) so the codec and the client that speaks it version +//! together. `#[videre_sdk::keeper]` on a handler impl wires the +//! import and drives async handlers; [`rt`] completes their futures +//! on the synchronous guest boundary. +//! +//! - [`keeper`](mod@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 +//! [`VenueTransport`] seam. +//! +//! - [`transport`] - typed wrappers over the world's scoped imports: +//! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] +//! seam (plus batch), [`HostMessaging`](transport::HostMessaging) +//! behind [`MessagingHost`](transport::MessagingHost), the +//! wasi:http surface re-exported as [`transport::http`], and +//! [`TimedFetch`](transport::TimedFetch), the per-request timeout +//! middleware every adapter request should ride. +//! +//! - [`faults`] - the conversions that make `?` work across the wire +//! fault, the SDK-neutral fault, and [`VenueError`]; plus +//! [`VenueFault`], the owned client-side mirror. +//! +//! ## Why the bindgen lives in this crate +//! +//! The shared interfaces generate once, in [`bindings`], from an +//! import-only world: the trait, wrappers, and client core are all +//! typed over them. The per-cdylib bindgens (`#[venue]`, `#[keeper]`) +//! remap the shared interfaces onto [`bindings`], so a macro-built +//! component speaks these types while its world stays derived from its +//! own manifest. +//! +//! [`ChainHost`]: nexum_sdk::host::ChainHost +//! [`VenueClient`]: client::VenueClient +//! [`VenueTransport`]: client::VenueTransport + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +#[allow(missing_docs)] +pub mod bindings; + +pub mod adapter; +pub mod body; +pub mod client; +pub mod faults; +pub mod keeper; +pub mod rt; +pub mod transport; + +pub use adapter::VenueAdapter; +pub use body::{BodyError, IntentBody}; +pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; +pub use faults::VenueFault; +pub use keeper::{Keeper, Sweep, SweepReport, retry_action}; +/// Derive [`IntentBody`] on the outer per-venue version enum. See +/// [`videre_macros::IntentBody`]. +pub use videre_macros::IntentBody; +/// The blessed keeper authoring path. Apply to a worker's handler impl: +/// emits the per-cdylib bindgen for a world derived from `module.toml` +/// (asserting the `client` capability), remaps the videre interfaces +/// onto the SDK bindings so the module drives a [`VenueClient`] with +/// shared type identity, dispatches events to the handlers (async ones +/// completed through [`rt::complete`]), and folds [`ClientError`] into +/// the wire fault so `?` works in handlers. See +/// [`videre_macros::keeper`]. +pub use videre_macros::keeper; +/// The single blessed venue authoring path. Apply to the adapter's +/// `impl VenueAdapter for MyVenue` block: emits the per-cdylib bindgen +/// for a world derived from `module.toml` (asserting its +/// `kind = "venue-adapter"`), the `videre:venue/adapter` export glue, +/// and `export!`. The built component imports exactly the manifest's +/// declared scoped transport. See [`videre_macros::venue`]. +pub use videre_macros::venue; + +/// The intent ontology at its plain spellings: the types the +/// [`VenueAdapter`] face and the client core speak. +pub use bindings::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary intent headers are expressed in. +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; +/// The wire fault (`nexum:host/types.fault`) `init` returns. Transport +/// seams speak the SDK-neutral [`nexum_sdk::host::Fault`] instead; the +/// [`faults`] conversions bridge the two. +pub use bindings::nexum::host::types::Fault; diff --git a/crates/videre-sdk/src/rt.rs b/crates/videre-sdk/src/rt.rs new file mode 100644 index 00000000..6a62f4f3 --- /dev/null +++ b/crates/videre-sdk/src/rt.rs @@ -0,0 +1,38 @@ +//! Futures on the synchronous guest boundary. + +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +/// Complete a future in one poll. Guest host imports are synchronous, +/// so every await in a keeper future resolves immediately and one poll +/// runs it to completion. `None` reports a future that suspended, +/// which nothing built over the host imports does; the keeper macro's +/// emitted glue folds it to a typed fault. +pub fn complete(future: F) -> Option { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => Some(output), + Poll::Pending => None, + } +} + +#[cfg(test)] +mod tests { + use super::complete; + + #[test] + fn ready_chain_completes_in_one_poll() { + async fn two() -> u8 { + let one = async { 1u8 }.await; + one + async { 1u8 }.await + } + assert_eq!(complete(two()), Some(2)); + } + + #[test] + fn suspending_future_reports_none() { + assert_eq!(complete(std::future::pending::<()>()), None); + } +} diff --git a/crates/nexum-venue-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs similarity index 56% rename from crates/nexum-venue-sdk/src/transport.rs rename to crates/videre-sdk/src/transport.rs index a6b8f905..f1971e92 100644 --- a/crates/nexum-venue-sdk/src/transport.rs +++ b/crates/videre-sdk/src/transport.rs @@ -10,7 +10,10 @@ //! `messaging_topics`, and HTTP to its `http_allow` list, each refusal //! surfacing as a typed `denied`. +use core::time::Duration; + use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; +use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; use crate::bindings::nexum::host::{chain, messaging}; use crate::faults::fault_into_sdk; @@ -18,10 +21,45 @@ use crate::faults::fault_into_sdk; /// Outbound HTTP for adapters: the SDK's wasi:http surface re-exported. /// [`fetch`](nexum_sdk::http::Fetch::fetch) speaks the standard `http` /// crate's request/response types; an off-allowlist request fails as -/// [`FetchError::Denied`](nexum_sdk::http::FetchError::Denied), which +/// [`FetchError::Denied`], which /// converts into [`VenueError`](crate::VenueError) via `?`. pub use nexum_sdk::http; +/// Per-request timeout middleware over any [`Fetch`]: every request +/// through it, including plain [`Fetch::fetch`], carries phase timeouts +/// clamped to the configured bound, so a hung endpoint errors within it +/// rather than stalling the adapter's export call. +#[derive(Clone, Copy, Debug)] +pub struct TimedFetch { + inner: F, + bound: Duration, +} + +impl TimedFetch { + /// Bound every phase (connect, first byte, between bytes) of every + /// request to at most `bound`. + pub const fn new(inner: F, bound: Duration) -> Self { + Self { inner, bound } + } +} + +impl Fetch for TimedFetch { + fn fetch_with( + &self, + request: ::http::Request>, + options: FetchOptions, + ) -> Result<::http::Response>, FetchError> { + self.inner.fetch_with( + request, + FetchOptions { + connect_timeout: options.connect_timeout.min(self.bound), + first_byte_timeout: options.first_byte_timeout.min(self.bound), + between_bytes_timeout: options.between_bytes_timeout.min(self.bound), + }, + ) + } +} + /// The adapter's `nexum:host/chain` import behind the SDK's /// [`ChainHost`] seam. Unit-struct handle: hold it where strategy logic /// takes `&impl ChainHost` and slot a mock in host-side tests. @@ -87,26 +125,9 @@ fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { } } -/// `nexum:host/messaging` - publish to and query the venue's content -/// topics. The seam between adapter logic and the messaging transport; -/// [`HostMessaging`] is the bound impl. -pub trait MessagingHost { - /// Publish a payload to a content topic - /// (`////`). A topic outside the - /// adapter's `messaging_topics` scope fails as [`Fault::Denied`]. - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; - - /// Query historical messages from the store protocol, newest window - /// bounded by the optional `start_time` / `end_time` (ms since the - /// Unix epoch, UTC) and `limit`. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault>; -} +/// The messaging seam and its message mirror, canonical in the module +/// SDK; [`HostMessaging`] is this crate's bound impl. +pub use nexum_sdk::host::{Message, MessagingHost}; /// The adapter's `nexum:host/messaging` import behind the /// [`MessagingHost`] seam. @@ -131,21 +152,6 @@ impl MessagingHost for HostMessaging { } } -/// One delivered message, mirrored from `nexum:host/types.message` so -/// the [`MessagingHost`] seam stays mockable without naming bindgen -/// types. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Message { - /// Content topic the message arrived on. - pub content_topic: String, - /// Opaque payload bytes. - pub payload: Vec, - /// Delivery timestamp, ms since the Unix epoch, UTC. - pub timestamp: u64, - /// Optional sender identity (protocol-dependent). - pub sender: Option>, -} - impl From for Message { fn from(message: crate::bindings::nexum::host::types::Message) -> Self { Self { @@ -156,3 +162,74 @@ impl From for Message { } } } + +#[cfg(test)] +mod tests { + use core::cell::Cell; + use core::time::Duration; + + use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; + + use super::TimedFetch; + + struct Spy { + seen: Cell>, + } + + impl Fetch for Spy { + fn fetch_with( + &self, + _request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.seen.set(Some(options)); + Ok(http::Response::new(Vec::new())) + } + } + + fn request() -> http::Request> { + http::Request::get("https://api.cow.fi/") + .body(Vec::new()) + .expect("test request builds") + } + + #[test] + fn plain_fetch_is_bounded() { + let bound = Duration::from_secs(5); + let timed = TimedFetch::new( + Spy { + seen: Cell::new(None), + }, + bound, + ); + timed.fetch(request()).expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, bound); + assert_eq!(seen.first_byte_timeout, bound); + assert_eq!(seen.between_bytes_timeout, bound); + } + + #[test] + fn caller_options_clamp_to_the_bound_but_tighter_ones_pass() { + let timed = TimedFetch::new( + Spy { + seen: Cell::new(None), + }, + Duration::from_secs(5), + ); + timed + .fetch_with( + request(), + FetchOptions { + connect_timeout: Duration::from_secs(60), + first_byte_timeout: Duration::from_secs(1), + between_bytes_timeout: Duration::from_secs(60), + }, + ) + .expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, Duration::from_secs(5)); + assert_eq!(seen.first_byte_timeout, Duration::from_secs(1)); + assert_eq!(seen.between_bytes_timeout, Duration::from_secs(5)); + } +} diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs new file mode 100644 index 00000000..0c490331 --- /dev/null +++ b/crates/videre-sdk/tests/adapter.rs @@ -0,0 +1,314 @@ +//! Acceptance surface for the venue SDK: a hand-written adapter +//! compiles against [`VenueAdapter`] and round-trips a versioned body +//! through `#[derive(IntentBody)]` - including the typed +//! unknown-version failure and the typed [`VenueClient`] driving the +//! adapter through the [`VenueTransport`] seam. The world-export glue +//! is `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. + +use borsh::{BorshDeserialize, BorshSerialize}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentHeader, IntentStatus, + Quotation, Settlement, SubmitOutcome, Venue, VenueAdapter, VenueClient, VenueError, VenueFault, + VenueId, VenueTransport, +}; + +/// Drive a client future on the test's synchronous boundary. +fn run(future: F) -> F::Output { + videre_sdk::rt::complete(future).expect("client futures complete in one poll") +} + +/// First published body version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV1 { + amount_wei: u64, + memo: String, +} + +/// Second published version: v1 plus an expiry. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV2 { + amount_wei: u64, + memo: String, + valid_until_ms: Option, +} + +/// The outer per-venue version enum: the schema the demo venue +/// publishes. Tag order is the schema; versions append. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +enum QuoteBody { + V1(QuoteV1), + V2(QuoteV2), +} + +/// The hand-written adapter: enough venue to exercise every trait +/// function without a live transport. +struct DemoAdapter; + +/// The receipt the demo venue issues for every accepted intent. +const RECEIPT: [u8; 4] = [0xA5, 0x5A, 0xC3, 0x3C]; + +impl DemoAdapter { + fn decode(body: &[u8]) -> Result<(u64, Option), VenueError> { + // `BodyError` converts through `?`: malformed and + // unknown-version bodies surface as `invalid-body`. + let body = QuoteBody::from_bytes(body)?; + Ok(match body { + QuoteBody::V1(quote) => (quote.amount_wei, None), + QuoteBody::V2(quote) => (quote.amount_wei, quote.valid_until_ms), + }) + } +} + +impl VenueAdapter for DemoAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + let (amount_wei, _valid_until_ms) = Self::decode(&body)?; + Ok(IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: amount_wei.to_be_bytes().to_vec(), + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }) + } + + fn quote(body: Vec) -> Result { + let (amount_wei, valid_until_ms) = Self::decode(&body)?; + let zero = AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }; + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: amount_wei.to_be_bytes().to_vec(), + }, + wants: zero.clone(), + fee: zero, + valid_until_ms: valid_until_ms.unwrap_or(u64::MAX), + }) + } + + fn submit(body: Vec) -> Result { + Self::decode(&body)?; + Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) + } + + fn status(receipt: Vec) -> Result { + if receipt == RECEIPT { + Ok(IntentStatus::Open) + } else { + // 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(), + )) + } + } + + fn cancel(receipt: Vec) -> Result<(), VenueError> { + Self::status(receipt).map(|_| ()) + } +} + +/// The demo venue as a keeper types it. +struct DemoVenue; + +impl Venue for DemoVenue { + const ID: VenueId = VenueId::from_static("demo"); + type Body = QuoteBody; +} + +/// A venue id no adapter answers for, over the same body schema. +struct NowhereVenue; + +impl Venue for NowhereVenue { + const ID: VenueId = VenueId::from_static("nowhere"); + type Body = QuoteBody; +} + +/// In-process transport: routes the demo venue id straight into the +/// adapter, standing in for the host registry the keeper-side seam +/// binds. +struct InProcessClient; + +impl videre_sdk::client::sealed::SealedTransport for InProcessClient {} + +impl VenueTransport for InProcessClient { + async fn quote(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::quote(body).map_err(Into::into) + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::submit(body).map_err(Into::into) + } + + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::status(receipt.to_vec()).map_err(Into::into) + } + + async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::cancel(receipt.to_vec()).map_err(Into::into) + } +} + +fn v2_body() -> QuoteBody { + QuoteBody::V2(QuoteV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + }) +} + +#[test] +fn versioned_body_round_trips_through_the_derive() { + for body in [ + QuoteBody::V1(QuoteV1 { + amount_wei: 42, + memo: "one".to_owned(), + }), + v2_body(), + ] { + let bytes = body.to_bytes().expect("derived payloads encode"); + assert_eq!(QuoteBody::from_bytes(&bytes).unwrap(), body); + } +} + +#[test] +fn wire_tag_is_the_declaration_index() { + let v1 = QuoteBody::V1(QuoteV1 { + amount_wei: 1, + memo: String::new(), + }) + .to_bytes() + .unwrap(); + let v2 = v2_body().to_bytes().unwrap(); + assert_eq!(v1[0], 0); + assert_eq!(v2[0], 1); +} + +#[test] +fn unknown_version_fails_typedly() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 9; + assert_eq!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::UnknownVersion { version: 9 }) + ); +} + +#[test] +fn empty_and_malformed_bodies_fail_typedly() { + assert_eq!(QuoteBody::from_bytes(&[]), Err(BodyError::Empty)); + + // A known tag with a truncated payload. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.truncate(bytes.len() - 1); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); + + // A known tag with trailing bytes: borsh requires full consumption. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.push(0); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); +} + +#[test] +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.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); +} + +#[test] +fn adapter_reports_an_unknown_version_as_invalid_body() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 7; + let err = DemoAdapter::derive_header(bytes).unwrap_err(); + match err { + VenueError::InvalidBody(detail) => assert!(detail.contains("unknown body version 7")), + other => panic!("expected invalid-body, got {other:?}"), + } +} + +#[test] +fn typed_client_round_trips_through_the_transport_seam() { + let client = VenueClient::::with_transport(InProcessClient); + assert_eq!(client.venue(), DemoVenue::ID); + + let outcome = run(client.submit(&v2_body())).unwrap(); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("demo venue always accepts"); + }; + assert_eq!(receipt, RECEIPT.to_vec()); + + assert_eq!(run(client.status(&receipt)).unwrap(), IntentStatus::Open); + run(client.cancel(&receipt)).unwrap(); + + assert!(matches!( + run(client.status(&[0, 1])).unwrap_err(), + ClientError::Venue(VenueFault::Denied(_)) + )); +} + +#[test] +fn quote_typestate_prices_then_submits_the_quoted_body() { + async fn drive( + client: &VenueClient, + ) -> Result { + // The typestate chain under test: a quotation is the only path + // from a priced body to its submission. Static dispatch end to + // end: the transport is native AFIT, nothing boxes. + client.quote(&v2_body()).await?.submit().await + } + + let client = VenueClient::::with_transport(InProcessClient); + + let quoted = run(client.quote(&v2_body())).unwrap(); + assert_eq!( + quoted.quotation().gives.amount, + 1_000_000u64.to_be_bytes().to_vec() + ); + assert_eq!(quoted.quotation().valid_until_ms, 1_700_000_000_000); + + let outcome = run(drive(&client)).unwrap(); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); +} + +#[test] +fn unbound_venue_is_unknown_at_the_client() { + let client = VenueClient::::with_transport(InProcessClient); + assert!(matches!( + run(client.submit(&v2_body())).unwrap_err(), + ClientError::Venue(VenueFault::UnknownVenue) + )); +} diff --git a/crates/nexum-venue-test/Cargo.toml b/crates/videre-test/Cargo.toml similarity index 94% rename from crates/nexum-venue-test/Cargo.toml rename to crates/videre-test/Cargo.toml index 67eb2e3c..02198e5a 100644 --- a/crates/nexum-venue-test/Cargo.toml +++ b/crates/videre-test/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nexum-venue-test" +name = "videre-test" version = "0.1.0" edition.workspace = true license.workspace = true @@ -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/videre-test/goldens/reference-header.json b/crates/videre-test/goldens/reference-header.json new file mode 100644 index 00000000..c56703fd --- /dev/null +++ b/crates/videre-test/goldens/reference-header.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "venue": "videre-test/reference", + "goldens": [ + { + "name": "v1-small", + "body": "00010000000000000002000000676d", + "header": { + "gives": { + "asset": "native", + "amount": "01" + }, + "wants": { + "asset": "native", + "amount": "" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "gives chain-1 native token, minimal big-endian amount" + }, + { + "name": "v2-full", + "body": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", + "header": { + "gives": { + "asset": "native", + "amount": "0f4240" + }, + "wants": { + "asset": { + "erc20": { + "token": "0102030405060708090a0b0c0d0e0f1011121314" + } + }, + "amount": "0f4240" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "v2 adds an erc20 want at the recipient token address" + } + ] +} diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/videre-test/src/codec.rs similarity index 82% rename from crates/nexum-venue-test/src/codec.rs rename to crates/videre-test/src/codec.rs index 3041326e..538db948 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/videre-test/src/codec.rs @@ -2,7 +2,8 @@ //! `IntentBody` wire bytes, and the check that holds a codec to them. //! //! A vector file is the venue's codec contract in portable form: JSON, -//! bytes as lowercase hex, one entry per published body. A non-Rust +//! a leading format version (unknown versions fail closed), bytes as +//! lowercase hex, one entry per published body, never zero. A non-Rust //! adapter author proves byte-exactness by decoding and re-encoding //! each `round-trip` vector in their own language and comparing bytes; //! a Rust author runs [`CodecVectors::assert_conforms`] against the @@ -12,20 +13,24 @@ use std::path::Path; -use nexum_venue_sdk::{BodyError, IntentBody}; use serde::{Deserialize, Serialize}; +use videre_sdk::{BodyError, IntentBody}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of codec vectors for one venue body schema. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CodecVectors { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// Name of the body schema the vectors bind, e.g. /// `acme-dex/order-body`. Informational: the check never reads it. pub schema: String, - /// The vectors, in publication order. + /// The vectors, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub vectors: Vec, } @@ -80,9 +85,11 @@ impl std::fmt::Display for Expectation { } impl CodecVectors { - /// An empty vector set for `schema`. + /// An empty vector set for `schema`. Push at least one vector + /// before publishing: a parsed file is never empty. pub fn new(schema: impl Into) -> Self { Self { + version: FormatVersion, schema: schema.into(), vectors: Vec::new(), } @@ -158,9 +165,16 @@ impl CodecVectors { /// /// A `round-trip` vector must decode and re-encode to the exact /// published bytes; a failure vector must produce the matching - /// [`BodyError`] case (the free-text detail is not compared). + /// [`BodyError`] case (the free-text detail is not compared). An + /// empty set is itself a violation: it would conform vacuously. pub fn check(&self) -> Result<(), ConformanceReport> { let mut violations = Vec::new(); + if self.vectors.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published vector set is empty".to_owned(), + }); + } for vector in &self.vectors { if let Err(detail) = vector.check::() { violations.push(Violation { @@ -230,7 +244,7 @@ impl CodecVector { #[cfg(test)] mod tests { use borsh::{BorshDeserialize, BorshSerialize}; - use nexum_venue_sdk::IntentBody; + use videre_sdk::IntentBody; use super::*; @@ -317,6 +331,7 @@ mod tests { let json = vectors.to_json(); assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"round-trip\"")); assert!(json.contains("\"unknown-version\"")); assert!(json.contains("\"notes\": \"first published body\"")); @@ -343,4 +358,47 @@ mod tests { Err(FixtureError::Read { .. }), )); } + + #[test] + fn unknown_format_version_fails_closed() { + let json = published() + .to_json() + .replace("\"version\": 1", "\"version\": 2"); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("version 2 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 2")); + } + + #[test] + fn missing_format_version_fails() { + let json = published().to_json().replace(" \"version\": 1,\n", ""); + assert!(matches!( + CodecVectors::from_json(&json), + Err(FixtureError::Format(_)), + )); + } + + #[test] + fn empty_vector_set_fails_the_check() { + let report = CodecVectors::new("test/body").check::().unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_rejects_an_empty_set() { + CodecVectors::new("test/body").assert_conforms::(); + } + + #[test] + fn empty_vector_set_fails_to_parse() { + let json = CodecVectors::new("test/body").to_json(); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/videre-test/src/fixture.rs similarity index 59% rename from crates/nexum-venue-test/src/fixture.rs rename to crates/videre-test/src/fixture.rs index 7174573c..6b2b5118 100644 --- a/crates/nexum-venue-test/src/fixture.rs +++ b/crates/videre-test/src/fixture.rs @@ -1,16 +1,60 @@ -//! The shared fixture-file plumbing: JSON on disk, byte fields as -//! lowercase hex, and the typed [`FixtureError`] both file formats -//! load and save through. +//! The shared fixture-file plumbing: JSON on disk, a leading +//! [`FormatVersion`] (unknown versions fail closed), byte fields as +//! lowercase hex, non-empty entry lists, and the typed +//! [`FixtureError`] both file formats load and save through. use std::path::Path; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The one published fixture file-format version. +const FORMAT_VERSION: u32 = 1; + +/// Fixture file-format discriminator: serializes as the current +/// version, refuses any other on parse (fail-closed), so a reader +/// never guesses at a future layout. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FormatVersion; + +impl Serialize for FormatVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u32(FORMAT_VERSION) + } +} + +impl<'de> Deserialize<'de> for FormatVersion { + fn deserialize>(deserializer: D) -> Result { + let version = u32::deserialize(deserializer)?; + if version == FORMAT_VERSION { + Ok(Self) + } else { + Err(D::Error::custom(format!( + "unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}", + ))) + } + } +} + +/// Deserialize a fixture's entry list, refusing an empty one: an empty +/// set would conform vacuously. +pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let entries = Vec::::deserialize(deserializer)?; + if entries.is_empty() { + return Err(D::Error::custom("a published fixture set is never empty")); + } + Ok(entries) +} /// Why a fixture file failed to load or save. The JSON case carries /// serde's rendered detail rather than the error value so the type /// stays independent of `serde_json`'s feature set. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum FixtureError { /// The file could not be read. #[error("failed to read {path}: {source}")] diff --git a/crates/nexum-venue-test/src/header.rs b/crates/videre-test/src/header.rs similarity index 60% rename from crates/nexum-venue-test/src/header.rs rename to crates/videre-test/src/header.rs index bee21583..91063dc4 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/videre-test/src/header.rs @@ -4,31 +4,35 @@ //! //! A golden file pairs wire bodies with the intent header a conforming //! adapter derives from them, spelled in the golden mirror types below -//! (JSON, kebab-case case names matching the WIT, bytes as lowercase -//! hex). The mirrors exist because wit-bindgen types carry no serde; -//! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a -//! macro-built adapter whose bindgen mints its own header type bridges -//! with a field-for-field `From` impl on its crate boundary, the same -//! pattern `nexum-sdk-test` documents for `Fault`. +//! (JSON, a leading format version that fails closed on an unknown tag, +//! kebab-case case names matching the WIT, bytes as lowercase hex, +//! never zero goldens). The mirrors exist because wit-bindgen types +//! carry no serde; [`GoldenHeader`] converts from the venue SDK's +//! `IntentHeader`, which macro-built adapters speak too, so an +//! adapter's `derive_header` feeds the check directly. use std::fmt; use std::path::Path; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; -use nexum_venue_sdk::{AuthScheme, IntentHeader}; use serde::{Deserialize, Serialize}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{AuthScheme, IntentHeader, Settlement}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of header goldens for one venue. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HeaderGoldens { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// The venue the goldens bind. Informational: the check never /// reads it. pub venue: String, - /// The goldens, in publication order. + /// The goldens, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub goldens: Vec, } @@ -53,12 +57,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 +67,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 +94,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 +108,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 +136,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 +145,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,19 +154,18 @@ 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, } } } impl HeaderGoldens { - /// An empty golden set for `venue`. + /// An empty golden set for `venue`. Record at least one golden + /// before publishing: a parsed file is never empty. pub fn new(venue: impl Into) -> Self { Self { + version: FormatVersion, venue: venue.into(), goldens: Vec::new(), } @@ -284,7 +217,8 @@ impl HeaderGoldens { /// collecting all violations rather than stopping at the first. /// /// `derive` is the adapter's derivation; a trait-based adapter - /// passes `MyAdapter::derive_header` directly. + /// passes `MyAdapter::derive_header` directly. An empty set is + /// itself a violation: it would conform vacuously. pub fn check( &self, mut derive: impl FnMut(Vec) -> Result, @@ -294,6 +228,12 @@ impl HeaderGoldens { E: fmt::Debug, { let mut violations = Vec::new(); + if self.goldens.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published golden set is empty".to_owned(), + }); + } for golden in &self.goldens { match derive(golden.body.clone()) { Ok(header) => { @@ -335,48 +275,25 @@ impl HeaderGoldens { #[cfg(test)] mod tests { - use nexum_venue_sdk::VenueError; - use nexum_venue_sdk::value_flow::{OffchainDesc, ServiceDesc}; + use videre_sdk::VenueError; + use videre_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, } } @@ -384,6 +301,7 @@ mod tests { fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { let golden: GoldenHeader = wire_header().into(); let goldens = HeaderGoldens { + version: FormatVersion, venue: "acme".to_owned(), goldens: vec![HeaderGolden { name: "kitchen-sink".to_owned(), @@ -395,12 +313,12 @@ 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("\"version\": 1")); + assert!(json.contains("\"native\"")); + assert!(json.contains("\"erc20\"")); + assert!(json.contains("\"token\"")); + assert!(json.contains("\"chain\"")); + assert!(json.contains("\"eip1271\"")); } #[test] @@ -432,7 +350,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 +372,46 @@ mod tests { goldens .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) .unwrap(); - goldens.assert_conforms(|_| Err::(VenueError::InvalidReceipt)); + goldens.assert_conforms(|_| Err::(VenueError::Timeout)); + } + + #[test] + fn unknown_format_version_fails_closed() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + let json = goldens + .to_json() + .replace("\"version\": 1", "\"version\": 7"); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("version 7 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 7")); + } + + #[test] + fn empty_golden_set_fails_the_check() { + let report = HeaderGoldens::new("acme") + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_rejects_an_empty_set() { + HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header())); + } + + #[test] + fn empty_golden_set_fails_to_parse() { + let json = HeaderGoldens::new("acme").to_json(); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); } } diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/videre-test/src/lib.rs similarity index 81% rename from crates/nexum-venue-test/src/lib.rs rename to crates/videre-test/src/lib.rs index e8c372e3..2485b000 100644 --- a/crates/nexum-venue-test/src/lib.rs +++ b/crates/videre-test/src/lib.rs @@ -1,4 +1,4 @@ -//! # nexum-venue-test +//! # videre-test //! //! Conformance kit for venue adapters: file-published codec vectors, //! header-derivation goldens, and an in-memory transport mock, so an @@ -25,16 +25,16 @@ //! //! ```toml //! [dev-dependencies] -//! nexum-venue-test = { path = "../../crates/nexum-venue-test" } +//! videre-test = { path = "../../crates/videre-test" } //! ``` //! //! Hold the adapter to its published fixtures: //! //! ```rust -//! use nexum_venue_test::reference::{ +//! use videre_test::reference::{ //! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, //! }; -//! use nexum_venue_test::{CodecVectors, HeaderGoldens}; +//! use videre_test::{CodecVectors, HeaderGoldens}; //! //! // In a real adapter test these load the venue's own published //! // files; the kit's reference venue stands in here. @@ -52,11 +52,11 @@ //! //! ## Macro-built adapters //! -//! `#[nexum::venue]` adapters mint their own bindgen header type. The -//! codec check is unaffected (bodies are plain Rust types); for the -//! golden check, bridge with a field-for-field `From for -//! GoldenHeader` impl on the adapter crate's boundary, the same -//! trivial-converter pattern `nexum-sdk-test` documents for `Fault`. +//! `#[videre_sdk::venue]` adapters speak the SDK's own types (the +//! macro remaps the type interfaces onto `videre_sdk::bindings`), so +//! both checks apply directly: pass `MyAdapter::derive_header` to +//! [`HeaderGoldens::assert_conforms`] and the derived enum to +//! [`CodecVectors::assert_conforms`]. No bridge types. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] @@ -69,7 +69,7 @@ pub mod report; pub mod transport; pub use codec::{CodecVector, CodecVectors, Expectation}; -pub use fixture::FixtureError; +pub use fixture::{FixtureError, FormatVersion}; pub use header::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/videre-test/src/reference.rs similarity index 88% rename from crates/nexum-venue-test/src/reference.rs rename to crates/videre-test/src/reference.rs index 74ce00b2..020cd6f9 100644 --- a/crates/nexum-venue-test/src/reference.rs +++ b/crates/videre-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 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"); @@ -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, }) } @@ -122,7 +129,7 @@ mod tests { /// Rebuild the published codec vectors from the reference schema. fn build_codec_vectors() -> CodecVectors { - let mut vectors = CodecVectors::new("nexum-venue-test/reference-body"); + let mut vectors = CodecVectors::new("videre-test/reference-body"); vectors .push_round_trip("v1-small", &v1_small()) @@ -211,7 +218,7 @@ mod tests { /// Rebuild the published header goldens from the reference /// derivation. fn build_header_goldens() -> HeaderGoldens { - let mut goldens = HeaderGoldens::new("nexum-venue-test/reference"); + let mut goldens = HeaderGoldens::new("videre-test/reference"); goldens .record( "v1-small", @@ -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 } @@ -253,7 +259,7 @@ mod tests { } /// Rewrite the published files from the reference schema. Run with - /// `cargo test -p nexum-venue-test -- --ignored regenerate` after a + /// `cargo test -p videre-test -- --ignored regenerate` after a /// deliberate schema change, then commit the diff; the tests above /// compare against the compiled-in copy, so they go green on the /// next build. diff --git a/crates/nexum-venue-test/src/report.rs b/crates/videre-test/src/report.rs similarity index 100% rename from crates/nexum-venue-test/src/report.rs rename to crates/videre-test/src/report.rs diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/videre-test/src/transport.rs similarity index 53% rename from crates/nexum-venue-test/src/transport.rs rename to crates/videre-test/src/transport.rs index 7ec823f3..a7295c97 100644 --- a/crates/nexum-venue-test/src/transport.rs +++ b/crates/videre-test/src/transport.rs @@ -4,17 +4,20 @@ //! [`MockTransport`] composes the three behind the same seams the SDK //! wrappers implement ([`ChainHost`], [`MessagingHost`], [`Fetch`]), so //! adapter logic written against `&impl Seam` runs unchanged in unit -//! tests. Scoping mirrors the host's: [`MockMessaging::scope_topics`] -//! plays the adapter's `messaging_topics` grant and refuses off-scope -//! topics as a typed `denied`, exactly as the host would. +//! tests. Grant play mirrors the host's: [`MockMessaging::scope_topics`] +//! plays the adapter's `messaging_topics` grant with the host's +//! `/`-bounded prefix matching, and [`MockFetch::scope_hosts`] plays the +//! `[capabilities.http].allow` list with the host's exact-or-`*.suffix` +//! matching; both refuse off-grant calls as a typed `denied`, exactly as +//! the host would. use std::cell::RefCell; 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 nexum_sdk_test::{ChainCall, MockChain, MockMessaging, PublishRecord}; +pub use videre_sdk::transport::{Message, MessagingHost}; /// Composed in-memory transport. Each field exposes the per-seam mock /// so tests can program responses and assert on calls. @@ -68,141 +71,6 @@ impl Fetch for MockTransport { } } -// ------------------------------------------------------------ messaging - -/// One recorded [`MessagingHost::publish`] invocation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PublishRecord { - /// Content topic the adapter published to. - pub content_topic: String, - /// Payload bytes, verbatim. - pub payload: Vec, -} - -/// In-memory [`MessagingHost`]. Seeded messages answer queries, -/// publishes are recorded for assertion, and an optional topic scope -/// mirrors the host's `messaging_topics` grant. Seeded history and -/// published records are deliberately separate stores: a query answers -/// from what the test seeded, never from what the adapter published. -#[derive(Default)] -pub struct MockMessaging { - history: RefCell>, - published: RefCell>, - scope: RefCell>>, - faults: RefCell>, -} - -impl MockMessaging { - /// Seed one message into the queryable history. - pub fn seed(&self, message: Message) { - self.history.borrow_mut().push(message); - } - - /// Seed a payload on `content_topic` at `timestamp` (ms since the - /// Unix epoch, UTC), with no sender. - pub fn seed_payload( - &self, - content_topic: impl Into, - payload: impl Into>, - timestamp: u64, - ) { - self.seed(Message { - content_topic: content_topic.into(), - payload: payload.into(), - timestamp, - sender: None, - }); - } - - /// Confine the mock to `topics`, mirroring the adapter's - /// `messaging_topics` grant: any other topic fails as - /// [`Fault::Denied`]. Untouched, every topic is allowed. - pub fn scope_topics(&self, topics: impl IntoIterator>) { - *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); - } - - /// Inject a fault for any operation on a topic starting with - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. - pub fn fail_on(&self, prefix: impl Into, fault: Fault) { - self.faults.borrow_mut().push((prefix.into(), fault)); - } - - /// All publishes received, in arrival order. - pub fn published(&self) -> Vec { - self.published.borrow().clone() - } - - /// Last publish received, if any. - pub fn last_published(&self) -> Option { - self.published.borrow().last().cloned() - } - - /// Total publish count. - pub fn publish_count(&self) -> usize { - self.published.borrow().len() - } - - fn admit(&self, content_topic: &str) -> Result<(), Fault> { - for (prefix, fault) in self.faults.borrow().iter() { - if content_topic.starts_with(prefix.as_str()) { - return Err(fault.clone()); - } - } - if let Some(scope) = self.scope.borrow().as_ref() - && !scope.iter().any(|topic| topic == content_topic) - { - return Err(Fault::Denied(format!( - "MockMessaging: {content_topic} is outside the scoped topics" - ))); - } - Ok(()) - } -} - -impl MessagingHost for MockMessaging { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.admit(content_topic)?; - self.published.borrow_mut().push(PublishRecord { - content_topic: content_topic.to_owned(), - payload: payload.to_vec(), - }); - Ok(()) - } - - /// Answer from the seeded history: exact-topic matches whose - /// timestamp lies within the inclusive `start_time..=end_time` - /// window, in seed order. Seed order is delivery order, so a - /// `limit` keeps the newest matches: the tail. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.admit(content_topic)?; - let mut matches: Vec = self - .history - .borrow() - .iter() - .filter(|message| { - message.content_topic == content_topic - && start_time.is_none_or(|start| message.timestamp >= start) - && end_time.is_none_or(|end| message.timestamp <= end) - }) - .cloned() - .collect(); - if let Some(limit) = limit { - let keep = usize::try_from(limit).unwrap_or(usize::MAX); - if matches.len() > keep { - matches.drain(..matches.len() - keep); - } - } - Ok(matches) - } -} - // ------------------------------------------------------------ http /// One recorded [`Fetch::fetch_with`] invocation. @@ -227,16 +95,29 @@ struct StoredResponse { } /// In-memory [`Fetch`] backed by a `(method, uri)` -> response map. -/// Records every request so tests can assert dispatch shape; an -/// allowlist refusal is programmed as [`FetchError::Denied`] via -/// [`fail_with`](Self::fail_with). +/// Records every request so tests can assert dispatch shape. An +/// optional host scope plays the adapter's `[capabilities.http].allow` +/// grant ([`scope_hosts`](Self::scope_hosts)); one-off refusals can +/// still be programmed via [`fail_with`](Self::fail_with). #[derive(Default)] pub struct MockFetch { responses: RefCell>>, requests: RefCell>, + scope: RefCell>>, } impl MockFetch { + /// Confine the mock to `hosts`, playing the adapter's + /// `[capabilities.http].allow` grant with the host's matching: + /// case-insensitive, an entry is an exact hostname or a `*.suffix` + /// wildcard, and an off-grant request fails as + /// [`FetchError::Denied`]. An empty grant denies every host, the + /// host's posture for an absent allow list; an untouched mock is + /// unscoped. + pub fn scope_hosts(&self, hosts: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect()); + } + /// Program a response for the `(method, uri)` pair. Overwrites any /// prior entry. /// @@ -299,6 +180,14 @@ impl Fetch for MockFetch { body: request.body().clone(), options, }); + if let Some(scope) = self.scope.borrow().as_ref() + && !request + .uri() + .host() + .is_some_and(|host| host_allowed(host, scope)) + { + return Err(FetchError::Denied); + } match self.responses.borrow().get(&(method.clone(), uri.clone())) { Some(Ok(stored)) => Ok(http::Response::builder() .status(stored.status) @@ -312,79 +201,25 @@ impl Fetch for MockFetch { } } +/// The host's `[capabilities.http].allow` matching: host-only and +/// case-insensitive, an entry admits its exact hostname or, as +/// `*.suffix`, any strict subdomain of the suffix. +fn host_allowed(host: &str, allowlist: &[String]) -> bool { + let host = host.to_ascii_lowercase(); + allowlist.iter().any(|pat| { + let pat = pat.to_ascii_lowercase(); + if let Some(suffix) = pat.strip_prefix("*.") { + host.ends_with(&format!(".{suffix}")) + } else { + host == pat + } + }) +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn messaging_records_publishes_and_answers_from_seeds() { - let messaging = MockMessaging::default(); - messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); - messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); - messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); - - messaging.publish("/acme/1/orders/proto", b"out").unwrap(); - assert_eq!(messaging.publish_count(), 1); - assert_eq!( - messaging.last_published().unwrap(), - PublishRecord { - content_topic: "/acme/1/orders/proto".to_owned(), - payload: b"out".to_vec(), - }, - ); - - // Publishes never leak into query results. - let all = messaging - .query("/acme/1/orders/proto", None, None, None) - .unwrap(); - assert_eq!(all.len(), 2); - assert_eq!(all[0].payload, b"one"); - assert_eq!(all[1].payload, b"two"); - } - - #[test] - fn messaging_query_applies_bounds_and_limit() { - let messaging = MockMessaging::default(); - for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { - messaging.seed_payload("/t", payload.to_vec(), ts); - } - - let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); - assert_eq!(window.len(), 2); - assert_eq!(window[0].payload, b"b"); - - // A limit keeps the newest matches: the tail of the window. - let limited = messaging.query("/t", None, None, Some(2)).unwrap(); - assert_eq!(limited.len(), 2); - assert_eq!(limited[0].payload, b"c"); - assert_eq!(limited[1].payload, b"d"); - } - - #[test] - fn messaging_scope_denies_off_grant_topics() { - let messaging = MockMessaging::default(); - messaging.scope_topics(["/acme/1/orders/proto"]); - - messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); - let err = messaging.publish("/other", b"no").unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - let err = messaging.query("/other", None, None, None).unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - // The refused publish was never recorded. - assert_eq!(messaging.publish_count(), 1); - } - - #[test] - fn messaging_fault_injection_fires_by_prefix() { - let messaging = MockMessaging::default(); - messaging.fail_on("/flaky", Fault::Timeout); - assert!(matches!( - messaging.publish("/flaky/topic", b"x").unwrap_err(), - Fault::Timeout, - )); - messaging.publish("/steady", b"x").unwrap(); - } - #[test] fn fetch_returns_programmed_response_and_records_the_request() { let fetch = MockFetch::default(); @@ -437,6 +272,50 @@ mod tests { assert_eq!(fetch.request_count(), 2); } + #[test] + fn fetch_scope_matches_the_host_grant() { + let fetch = MockFetch::default(); + fetch.scope_hosts(["api.acme.example", "*.discord.com"]); + fetch.respond_to(http::Method::GET, "https://api.acme.example/v1", 200, "ok"); + fetch.respond_to(http::Method::GET, "https://API.ACME.EXAMPLE/v1", 200, "ok"); + fetch.respond_to(http::Method::GET, "https://a.b.discord.com/", 200, "ok"); + + // Exact entry, case-insensitively; a wildcard admits strict + // subdomains only. + let get = |uri: &str| { + fetch.fetch( + http::Request::builder() + .uri(uri) + .body(Vec::new()) + .expect("test request builds"), + ) + }; + assert!(get("https://api.acme.example/v1").is_ok()); + assert!(get("https://API.ACME.EXAMPLE/v1").is_ok()); + assert!(get("https://a.b.discord.com/").is_ok()); + assert_eq!( + get("https://evil.api.acme.example/").unwrap_err(), + FetchError::Denied, + ); + assert_eq!(get("https://discord.com/").unwrap_err(), FetchError::Denied); + + // Refused requests are still recorded. + assert_eq!(fetch.request_count(), 5); + + // An empty grant denies every host, the host's posture for an + // absent allow list. + let sealed = MockFetch::default(); + sealed.scope_hosts(Vec::::new()); + sealed.respond_to(http::Method::GET, "https://anywhere.example/", 200, ""); + let denied = sealed.fetch( + http::Request::builder() + .uri("https://anywhere.example/") + .body(Vec::new()) + .expect("test request builds"), + ); + assert_eq!(denied.unwrap_err(), FetchError::Denied); + } + #[test] fn transport_dispatches_through_every_seam() { let transport = MockTransport::new(); diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/videre-test/tests/conformance.rs similarity index 82% rename from crates/nexum-venue-test/tests/conformance.rs rename to crates/videre-test/tests/conformance.rs index 290c2ef4..da0bc538 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/videre-test/tests/conformance.rs @@ -1,16 +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, Settlement}; -use nexum_venue_sdk::{ - AuthScheme, Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, + VenueError, }; -use nexum_venue_test::reference::{ +use videre_test::reference::{ CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, }; -use nexum_venue_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; +use videre_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; /// An adapter under test: the reference venue implemented through the /// SDK trait, transport injected through the seams so the kit's mocks @@ -26,6 +27,19 @@ impl VenueAdapter for ReferenceAdapter { derive_reference_header(body) } + fn quote(body: Vec) -> Result { + let header = derive_reference_header(body)?; + Ok(Quotation { + gives: header.gives, + wants: header.wants, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: u64::MAX, + }) + } + fn submit(body: Vec) -> Result { Ok(SubmitOutcome::Accepted(body)) } @@ -58,9 +72,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 +136,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/nexum-venue-test/vectors/reference-body.json b/crates/videre-test/vectors/reference-body.json similarity index 97% rename from crates/nexum-venue-test/vectors/reference-body.json rename to crates/videre-test/vectors/reference-body.json index 297d32b3..4bc46024 100644 --- a/crates/nexum-venue-test/vectors/reference-body.json +++ b/crates/videre-test/vectors/reference-body.json @@ -1,5 +1,6 @@ { - "schema": "nexum-venue-test/reference-body", + "version": 1, + "schema": "videre-test/reference-body", "vectors": [ { "name": "v1-small", diff --git a/docker-compose.soak.yml b/docker-compose.soak.yml index 0921f186..b48a9027 100644 --- a/docker-compose.soak.yml +++ b/docker-compose.soak.yml @@ -1,7 +1,7 @@ # docker-compose.soak.yml — 7-day grant stability soak. # # Two services: -# engine — the nexum binary; restarts automatically on crash. +# engine — the shepherd binary; restarts automatically on crash. # snapshotter — Alpine loop that scrapes /metrics every hour and # writes evidence files to docs/operations/soak-reports/. # diff --git a/docs/00-overview.md b/docs/00-overview.md index ee99796e..4e5d12ce 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -11,14 +11,12 @@ Two project names look similar but mean different things - keeping them straight | Term | What it is | Where you find it | |---|---|---| | **engine** (`nexum`) | A concrete *implementation* that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. Mobile / browser / embedded engines could exist later - each is a separate engine. | `crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | -| **host** (`nexum:host`) | The WIT *contract* - the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.2.0`, Rust path `nexum::host::*` | +| **host** (`nexum:host`) | The WIT *contract* - the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.1.0`, Rust path `nexum::host::*` | The relationship: an engine *implements* `nexum:host` so that modules *built against* `nexum:host` can run on it. The `nexum:host` package itself does not run anything - it's a specification. When this doc says "the host", it means whichever engine the module currently runs on, as seen through the `nexum:host` contract. The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `crates/nexum-cli`, a thin consumer of it. A Rust embedder skips the binary entirely, constructs an `EngineConfig` in code, and calls `nexum_runtime::bootstrap::run_from_config`. See `crates/nexum-runtime/examples/embed.rs` for a minimal end-to-end example. -> **Upgrading from 0.1?** See the [Migration Guide](migration/0.1-to-0.2.md) for the full rename table (`web3:runtime` → `nexum:host`, `csn` → `chain`, `msg` → `messaging`, `headless-module` → `event-module`, etc.), the per-interface typed error model over the shared `fault` vocabulary, and the manifest-driven capability negotiation introduced in 0.2. - ## Architecture ```mermaid @@ -96,7 +94,7 @@ In addition to the six core primitives, 0.2 introduces one optional capability t Time and secure randomness are WASI concerns rather than Nexum capabilities: `wasi:clocks` and `wasi:random` are linked into every module store ambiently. -0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. See the migration guide for the full WIT. +0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. ## WIT Worlds @@ -121,7 +119,7 @@ graph TB ``` // Universal layer - any platform, any blockchain app -package nexum:host@0.2.0 +package nexum:host@0.1.0 world event-module { import chain - consensus access (JSON-RPC passthrough) @@ -136,7 +134,7 @@ world event-module { } // CoW Protocol extension -package shepherd:cow@0.2.0 +package shepherd:cow@0.1.0 world shepherd { include event-module @@ -158,7 +156,7 @@ The world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked |---------|--------|---------| | Language | Rust | 1.90+ | | WASM runtime | wasmtime (Component Model) | 45.x | -| API contract | WIT (`nexum:host@0.2.0`, `shepherd:cow@0.2.0`) | - | +| API contract | WIT (`nexum:host@0.1.0`, `shepherd:cow@0.1.0`) | - | | Guest bindings | wit-bindgen | 0.57.x | | Async | Tokio | - | | Ethereum RPC | alloy | 1.5.x | @@ -311,7 +309,7 @@ Tower layer stack per chain: timeout -> retry (exponential + jitter) -> rate lim ### Error Model -In 0.2 each interface declares its own typed error and they share one payload-bearing `fault` vocabulary for the cross-domain cases. `fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports); a richer interface embeds `fault` as one case of its own variant and adds the cases only it needs (`chain-error` adds an `rpc` case carrying the node code and decoded revert bytes). Modules match on the typed variant for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping. +In 0.2 each interface declares its own typed error and they share one payload-bearing `fault` vocabulary for the cross-domain cases. `fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports); a richer interface embeds `fault` as one case of its own variant and adds the cases only it needs (`chain-error` adds an `rpc` case carrying the node code and decoded revert bytes). Modules match on the typed variant for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model. ### Observability @@ -344,11 +342,13 @@ The mobile/wallet host story - including the experimental `query-module` world's | # | Milestone | Effort | Key Deliverables | |---|-----------|--------|------------------| | 1 | Core Runtime & Event System | 120h | wasmtime Component Model host, WIT interfaces, event sources, redb local store, CLI | -| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods | +| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods\* | | 3 | SDK & Developer Experience | 60h | `shepherd-sdk` + `shepherd-sdk-test` crates (host-trait seam per ADR-0009), example modules, tutorial, docs | | 4 | Production Hardening | 60h | Resource limits, restart policy, logging, metrics, health checks | | 5 | Multi-Chain & Deployment | 40h | Multi-chain config, Docker image, deployment docs | +\* **M2 divergence.** The "ComposableCoW contract mods" deliverable (enhanced polling interfaces, optimized getters, monitoring events) was intentionally met off-chain: no Solidity was modified. The TWAP module polls `getTradeableOrderWithSignature` via raw `eth_call` with SDK helpers. Contract-side interfaces would fix one concrete TWAP implementation behind the boundary and block competing strategies (ADR-0006). The functional goal stands; the literal deliverable does not. + ## Repository Structure ``` @@ -379,8 +379,7 @@ shepherd/ ├── operations/ Runbooks, E2E reports, load reports, baselines ├── production.md Operator handbook ├── sdk.md Module-author entry point (shipped SDK reference) - ├── tutorial-first-module.md - └── migration/0.1-to-0.2.md + └── tutorial-first-module.md ``` The SDK split is in place: `nexum-sdk` carries the universal surface and `shepherd-sdk` layers the CoW domain on top, with no re-export between them. Shipping a `cargo-nexum` subcommand for module authors remains future direction. diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index 572e2898..8ad5f84f 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -101,12 +101,12 @@ let bindings = EventModule::instantiate_pre(&mut store, &pre)?; Nexum uses a two-layer WIT architecture. The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. The **CoW-specific** package `shepherd:cow` extends it with CoW Protocol interfaces and the `shepherd` world. -### Universal Package: `nexum:host@0.2.0` +### Universal Package: `nexum:host@0.1.0` The `nexum:host` package is the single source of truth for the universal host-guest contract. It defines a custom world with **no WASI imports**: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -301,14 +301,14 @@ world event-module { } ``` -In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The migration guide carries the details. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. +In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW-Specific Package: `shepherd:cow@0.1.0` The `shepherd:cow` package extends the universal world with CoW Protocol interfaces. In 0.2 the two 0.1 interfaces (`cow` + `order`) merge into a single `cow-api` interface to eliminate the `cow::cow::request` triple-stutter: ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index 2a946860..15c1e0c4 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -54,9 +54,9 @@ enable_alerts = true # boolean stays boolean Key design points: -- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). (Was `wasm = ...` in 0.1 - see the migration guide.) +- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). - **`[[subscription]]` blocks are declarative.** The module doesn't set up its own subscriptions imperatively - the runtime reads the manifest and wires up event sources before calling `init`. The 0.1 spelling was `[[subscribe]]` with `type = ...`; 0.2 uses `[[subscription]]` with `kind = ...` because `type` is a reserved word in several binding languages. -- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. See the migration guide for the full schema (including `[capabilities.http]` allowlists). A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `modules/examples/http-probe` for a complete example. +- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `modules/examples/http-probe` for a complete example. - **Chain ids are declared per-subscription**, not in a top-level `[chains]` table - each `[[subscription]]` names its own `chain_id`. If `engine.toml` has no `[chains.]` entry for a chain a subscription names, the engine bails at boot, before any events dispatch (fast, clear error). - **`config`** is opaque to the runtime. 0.2 keeps 0.1's stringly-typed shape (`list>`); the host flattens TOML scalars (numbers, booleans) to their string form on the way through. A typed `config-value` variant is on the 0.3 roadmap, bundled with the manifest-parser work. @@ -277,10 +277,10 @@ The runtime serialises event data via the canonical ABI (handled automatically b The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The architecture uses two packages: `nexum:host` for universal interfaces and `shepherd:cow` for CoW Protocol extensions. -### Universal Package: `nexum:host@0.2.0` +### Universal Package: `nexum:host@0.1.0` ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -409,10 +409,10 @@ world event-module { } ``` -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW-Specific Package: `shepherd:cow@0.1.0` ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; diff --git a/docs/04-state-store.md b/docs/04-state-store.md index 59de103a..96d5434b 100755 --- a/docs/04-state-store.md +++ b/docs/04-state-store.md @@ -55,8 +55,7 @@ interface local-store { /// Set a key-value pair. Overwrites existing value. /// Returns fault.invalid-input or fault.internal on failure. - /// Quota exhaustion surfaces as fault.invalid-input (or a future - /// dedicated case) - see the migration guide. + /// Quota exhaustion surfaces as fault.invalid-input. set: func(key: string, value: list) -> result<_, fault>; /// Delete a key. No-op if key doesn't exist. @@ -67,7 +66,7 @@ interface local-store { } ``` -In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary (see [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) so modules can match on the `fault` case rather than parsing error strings. The interface is the failure domain, so it reports `fault` directly with no subsystem tag. +In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary so modules can match on the `fault` case rather than parsing error strings. The interface is the failure domain, so it reports `fault` directly with no subsystem tag. Keys are UTF-8 strings. Values are opaque bytes - the SDK provides typed wrappers (see doc 05). diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 8b62896f..eff5eed4 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -12,7 +12,8 @@ For the architectural decision behind the host-trait seam that the module-author persona builds on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference (the source of truth once you are writing module code), see [`sdk.md`](sdk.md) and the rustdoc under -`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and `crates/nexum-macros/`. +`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and +`crates/nexum-module-macros/`. ## The two personas @@ -31,14 +32,15 @@ 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 + `#[nexum::venue]` macro, and the `videre-test` conformance kit are all tracked by the SDK-surfaces epic and have no code in the tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) below for the shape of the plan. -Both personas share one proc-macro crate, `nexum-macros`, and the +Each persona has its own proc-macro crate (`nexum-module-macros` for +modules, `videre-macros` for venue adapters) and both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so strategy logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` @@ -52,7 +54,7 @@ toolchain or a running wasmtime instance. nexum-sdk/ ├── Cargo.toml └── src/ - ├── lib.rs # crate docs, `pub use nexum_macros::module` + ├── lib.rs # crate docs, `pub use nexum_module_macros::module` ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters @@ -65,7 +67,7 @@ nexum-sdk/ ├── tracing.rs # guest tracing facade + panic hook over a LogSink seam └── proptests.rs # cfg(test) property tests (not part of the public surface) -nexum-macros/ +nexum-module-macros/ ├── Cargo.toml # proc-macro = true └── src/ └── lib.rs # #[module] attribute macro @@ -153,7 +155,7 @@ layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. ### The `#[nexum::module]` macro -`nexum-macros` ships one attribute macro, re-exported as +`nexum-module-macros` ships one attribute macro, re-exported as `nexum_sdk::module`. Apply it to an inherent `impl` block whose methods are named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - and the macro reads the @@ -176,14 +178,14 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; // ... Ok(()) } fn on_block(block: types::Block) -> Result<(), Fault> { strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) - .map_err(sdk_fault_into_wit) + .map_err(Into::into) } } ``` @@ -257,7 +259,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 @@ -268,13 +270,13 @@ The planned shape: CoW Protocol's intent-body codec and become the eventual home for the CoW helpers `shepherd-sdk::cow` carries today, once the clean break happens. -- **`#[nexum::venue]`** - a second attribute macro in `nexum-macros`, +- **`#[nexum::venue]`** - a second attribute macro, in `videre-macros`, parallel to `#[nexum::module]`: it would emit the per-cdylib export glue for an adapter and a per-component world matching the manifest's declared capabilities (retiring the import-elision dependency for the venue side from day one, rather than as follow-on work). -- **`nexum-venue-test`** - a conformance kit: published codec +- **`videre-test`** - a conformance kit: published codec round-trip vectors (so a non-Rust adapter author can prove byte-exact `IntentBody` encoding without linking Rust), header- derivation golden fixtures, and a `MockTransport` for adapter unit @@ -304,8 +306,6 @@ of it, not a requirement. - [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed error model (`Fault`, `ChainError`, `CowApiError`) the host traits return. -- [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - - what changed in the SDK surface between 0.1 and 0.2. - [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough design and why module authors call `host.request` directly rather than through an injected provider. diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index f7e8f542..25246aac 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -75,7 +75,7 @@ flowchart TD Replace the `blockchain` interface with `chain`: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface chain { use types.{chain-id, fault}; @@ -107,7 +107,7 @@ interface chain { } ``` -Errors are reported via `chain-error`: either a shared `fault` (see doc 00, ADR-0011, and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) or a structured `rpc` case carrying the node code and decoded revert bytes - the 0.1 `json-rpc-error` shape is gone. Modules match on the `fault` case (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) for retry/backoff, and on the `rpc` case to decode a revert without parsing numeric JSON-RPC codes by hand. +Errors are reported via `chain-error`: either a shared `fault` (see doc 00 and ADR-0011) or a structured `rpc` case carrying the node code and decoded revert bytes - the 0.1 `json-rpc-error` shape is gone. Modules match on the `fault` case (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) for retry/backoff, and on the `rpc` case to decode a revert without parsing numeric JSON-RPC codes by hand. The `types` interface now exposes the shared `fault` / `rate-limit`. The `local-store`, `remote-store`, `messaging`, and `logging` interfaces are unchanged in shape (the first three report `fault` directly). @@ -1231,10 +1231,6 @@ The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. s The compile-time guarantee that a module can only call methods in the WIT is traded for a runtime allowlist. Given that the Component Model already provides structural sandboxing (the module can only call `chain::request`, not arbitrary network I/O), and the allowlist is enforced at the host boundary before any RPC call is made, this is a sound trade-off. -## Migration Path - -For modules and embedders moving from 0.1 to 0.2, follow the [Migration Guide](migration/0.1-to-0.2.md). In summary: the early 0.1 `blockchain` sketch was replaced by `csn` later in 0.1 and is now `chain` in 0.2; the SDK's `block_on` is now hidden behind the `#[nexum::module]` macro; and each interface returns its own typed error over the shared `fault` vocabulary rather than a per-protocol error type. - ## Summary | Component | What 0.2 ships | diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index 30c1f8b7..110bf3c0 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -374,7 +374,7 @@ Every platform implements this trivially. On server: `tracing` crate. On mobile: ### Universal World Definition ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -581,7 +581,7 @@ The host loads `index.html` into a WebView and injects the bridge JavaScript tha Domain-specific interfaces extend the universal layer for particular use cases. The pattern: ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; @@ -880,7 +880,7 @@ Any platform that wants to run modules must implement the **Host Adapter** - the ### Required Behaviours -In 0.2 each interface returns its own typed error over the shared `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). The fault case is normative - embedders MUST pick the most specific case for each backend failure. See ADR-0011 and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping table. +Each interface returns its own typed error over the shared `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). The fault case is normative - embedders MUST pick the most specific case for each backend failure. See ADR-0011 for the embedder-side mapping table. **`chain::request` / `chain::request-batch`** (Chain) - MUST forward the JSON-RPC request to a provider for the given chain. @@ -993,17 +993,6 @@ A module author building a generic blockchain automation module depends only on For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary - they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. -## Migration from 0.1 - -For the full 0.1 → 0.2 rename and behaviour change list, see the [Migration Guide](migration/0.1-to-0.2.md). The main themes: - -- WIT package `web3:runtime` → `nexum:host`; interfaces `csn` → `chain` and `msg` → `messaging`; worlds `headless-module` → `event-module` and `shepherd-module` → `shepherd`. -- CoW `cow` + `order` interfaces merged into `cow-api`. -- Each interface returns its own typed error over the shared `fault` vocabulary instead of five per-protocol error types. -- The `event-module` world imports the six primitives the docs always claimed (0.1's WIT was missing `identity` from the world definition). -- Manifest: `wasm = ...` → `component = ...`; `[[subscribe]]` → `[[subscription]]` with `kind` instead of `type`; new `[capabilities]` section drives optional/required imports; `[config]` values are now typed. -- Additive: the `http` capability (serviced by wasi:http, no new `nexum:host` WIT), `chain::request-batch`, and the experimental `query-module` world. - ## Summary ### Primitive Taxonomy diff --git a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md index 2c294279..5dee12b9 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -30,7 +30,7 @@ The engine config carries the path to each module's manifest; the two never coll ## Consequences - A deployment needs both files. A missing `engine.toml` falls back to "no chains, default state_dir" - the example logging module still runs; cow-api / chain backends report `unsupported`. -- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3 per `docs/migration/0.1-to-0.2.md`. +- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3. - Module-bundle redistribution carries `module.toml` with the artifact; engines do not need to ship templates. - Future content-addressed module distribution (0.3) embeds `module.toml` in the bundle hash; `engine.toml` references modules by content address rather than filesystem path. The split survives that migration unchanged. - Implementation impact: `crates/nexum-engine/src/manifest.rs` and `engine_config.rs` need to update the filename lookup from `nexum.toml` to `module.toml`. The 0.1-compat fallback in `manifest::fallback_manifest()` should accept both names during the transition; after 0.3 only `module.toml` is recognised. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index 96f8d02b..a76a9faa 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -39,7 +39,7 @@ An EthFlow module's `on_event(log)` handler decodes the `OrderPlacement` event w ## Consequences -- `shepherd:cow@0.2.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. +- `shepherd:cow@0.1.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. - `KNOWN_CAPABILITIES` in `crates/nexum-engine/src/manifest.rs` does **not** gain `"twap"` or `"ethflow"` entries. Modules declare the universal capabilities they actually use: `chain`, `local-store`, `logging`, `cow-api`. - Modules ship larger (~150 LOC each estimated, up from the ~30 LOC the host-helper design implied), because event decoding, eth_call orchestration, OrderCreation construction, and error-hint interpretation now live in guest code. This is the explicit trade-off: more code per module, less coupling, more freedom for different strategies to coexist. - Different TWAP polling strategies can coexist as different modules. Operators choose which to load via `engine.toml`'s `[[modules]]` array. 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/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index ed627e11..5ed09852 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -213,7 +213,8 @@ event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362b ## Event topic reference These are keccak256 hashes of the event signatures. They are the same on every -chain; only the contract `address` changes for EthFlow. +chain; only the contract `address` changes for EthFlow. Package of record: +`wit/shepherd-cow/cow-events.wit`. | Event | Topic-0 | |-------|---------| diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index a9d6c21a..8e67ba53 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -15,7 +15,7 @@ graph TD ET["engine.toml · module.toml\n(operator config + module manifest)"] SUP["Supervisor::boot"] POOLS["ProviderPool · OrderBookPool · LocalStore"] - HS["HostState (per module)\nnexum:host@0.2.0 + shepherd:cow@0.2.0"] + HS["HostState (per module)\nnexum:host@0.1.0 + shepherd:cow@0.1.0"] EL["EventLoop - futures::stream::select_all\nfan-out block/chain-log streams to subscribers"] MODS["WASM Modules\ntwap.wasm · eth-flow.wasm\n(self-contained protocol logic in guest)"] BC["Blockchain (Sepolia / Mainnet / …)\nComposableCoW · CowEthFlow · RPC Node"] @@ -161,8 +161,8 @@ Two WIT packages: the universal `nexum:host` and the CoW-specific `shepherd:cow` ```mermaid graph TD - NH["nexum:host@0.2.0\n(universal - no CoW knowledge)"] - SC["shepherd:cow@0.2.0\n(CoW Protocol extensions)"] + NH["nexum:host@0.1.0\n(universal - no CoW knowledge)"] + SC["shepherd:cow@0.1.0\n(CoW Protocol extensions)"] NH --> n1["chain ✅ implemented\nrequest(chain-id, method, params)\nrequest-batch(chain-id, requests)\n - \nsubscribe-blocks · subscribe-logs →\n engine-managed via module.toml subscriptions\nregister-address · unregister-address →\n 🕓 deferred to 0.3 (ADR-0008)"] NH --> n2["local-store ✅ implemented\nget(key) · set(key, value)\ndelete(key) · list-keys(prefix)\nnamespacing: 32-byte hash prefix (ADR-0003)"] @@ -182,13 +182,13 @@ graph TD | Interface | What it does | |---|---| -| **nexum:host@0.2.0** | The base WIT package. Any module running in the engine - CoW-aware or not - imports from here. Defines shared types (`chain-id`, `chain-log`, `fault`) used by both packages. | +| **nexum:host@0.1.0** | The base WIT package. Any module running in the engine - CoW-aware or not - imports from here. Defines shared types (`chain-id`, `chain-log`, `fault`) used by both packages. | | **chain** | Reads from the blockchain via JSON-RPC. `request` sends a single call; `request-batch` sends several in one round-trip. **Subscriptions are not callable WIT functions** - they are declared in `module.toml` and opened by the engine at boot. Dynamic `register-address` for factory patterns is deferred to 0.3 (ADR-0008). | | **local-store** | Persistent key-value storage that survives restarts. Operations: `get(key)`, `set(key, value)`, `delete(key)`, `list-keys(prefix)`. The host prefixes every key with a 32-byte deterministic namespace (`keccak256(module_name)` locally, or `ens_namehash(name)` when ENS-loaded) so modules are fully isolated and the namespace cannot be spoofed (ADR-0003). | | **identity · messaging · remote-store** | Capabilities stubbed at 0.2 - they return `Unsupported`. `identity` will provide keystore-backed signing. `messaging` will send Waku messages. `remote-store` will read/write Swarm/IPFS. | | **logging** | Lightweight utility. `logging` emits to the engine's `tracing` subscriber (inherits `RUST_LOG` filters). Time and secure randomness are available ambiently via `wasi:clocks` and `wasi:random`. | | **(outbound HTTP)** | Not a `nexum:host` interface: a module that declares the `http` capability imports the standard `wasi:http/outgoing-handler`, and the host checks every outgoing request against the manifest's `[capabilities.http].allow` list before any connection is made. | -| **shepherd:cow@0.2.0** | The CoW Protocol extension package. Imports `nexum:host/types` for shared types so modules don't re-define `chain-id` or `chain-log`. Only CoW-aware modules need to import this package. Contains exactly **one** interface in 0.2: `cow-api`. | +| **shepherd:cow@0.1.0** | The CoW Protocol extension package. Imports `nexum:host/types` for shared types so modules don't re-define `chain-id` or `chain-log`. Only CoW-aware modules need to import this package. Contains exactly **one** interface in 0.2: `cow-api`. | | **cow-api** | Generic orderbook access. `request` is a raw REST passthrough (returns JSON string). `submit-order` takes raw order bytes and returns a `result` where the string is the order UID. Routes through the engine's `OrderBookPool`. This is the only protocol-level CoW interface in 0.2 - the boundary between "what CoW Protocol *is*" (orderbook submission, order types) and "what's implemented *on top* of CoW" (TWAP polling, EthFlow event handling). | | **(no twap interface)** | Per ADR-0006, no specialised TWAP host interface exists. The TWAP module implements polling, decoding, and submission entirely in guest code, using `chain.request` for `eth_call`, `local-store` for state, `alloy_sol_types` (in-module) for ABI decoding, `cowprotocol` types for `OrderCreation`, and `cow-api.submit-order` for orderbook submission. Multiple TWAP strategies can coexist as separate modules with different polling policies and error tolerances. | | **(no ethflow interface)** | Per ADR-0006, no specialised EthFlow host interface exists. The EthFlow module decodes `OrderPlacement` directly in guest code via `alloy_sol_types`, constructs the `OrderCreation` with the EIP-1271 signing scheme via `cowprotocol` types, and submits via `cow-api`. | diff --git a/docs/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md deleted file mode 100644 index 3b5889ef..00000000 --- a/docs/migration/0.1-to-0.2.md +++ /dev/null @@ -1,526 +0,0 @@ -# Migrating from Nexum 0.1 to 0.2 - -Nexum 0.2 is a single coordinated breaking-change release. It does the renames, the error-model unification, the missing primitives, and the capability-negotiation work in one window so module authors only pay the migration tax once. There will not be another breaking release of comparable scope before 1.0. - -This guide is written for two audiences: - -- **Module authors** - you write WASM components that import the Nexum WIT. -- **Host embedders** - you build the runtime that loads modules (the server daemon, a mobile wallet, a browser host). - -Each section is tagged `[author]`, `[embedder]`, or `[both]`. - ---- - -## TL;DR - what changed [both] - -| Area | 0.1 | 0.2 | -|---|---|---| -| WIT package | `web3:runtime` | `nexum:host` | -| Consensus interface | `csn` | `chain` | -| Messaging interface | `msg` | `messaging` | -| Default world | `headless-module` | `event-module` | -| CoW world | `shepherd:cow/shepherd-module` | `shepherd:cow/shepherd` | -| CoW interfaces | `cow` + `order` | `cow-api` (merged) | -| Feed methods | `feed-get` / `feed-set` | `read-feed` / `write-feed` | -| Event variants | `block-data` / `log-entry` / `message-data` / `timer(u64)` | `block` / `log` / `message` / `tick { fired-at }` | -| Errors | 5 different shapes + bare `string` | per-interface typed errors over a shared `fault` vocabulary | -| Capabilities | All six imports mandatory | Manifest-negotiated, optional imports trap on call | -| Engine crate | `nxm-engine` | `nexum-engine` | -| Manifest file | `nexum.toml` (some docs said `shepherd.toml`) | `nexum.toml` (canonical) | -| Manifest field | `wasm = "sha256:..."` | `component = "sha256:..."` | -| Manifest section | `[[subscribe]]` | `[[subscription]]` | -| Config type | `list>` (stringified) | unchanged in 0.2; typed variant on the 0.3 roadmap | -| New capabilities | - | `http` (wasi:http, allowlisted); time and randomness are ambient via wasi:clocks / wasi:random | -| New RPC method | - | `chain::request-batch` (additive) | -| New world | - | `query-module` (experimental, no host impl shipped) | - -If you only do four things: update your `nexum.toml`, run the sed cheat-sheet at the bottom, replace your error handling with the new `fault` vocabulary, and declare your capabilities explicitly. Everything else is mechanical. - ---- - -## 1. WIT renames [author] - -### Package rename - -```diff -- use web3:runtime/types.{config, event}; -- use web3:runtime/chain.{chain-id}; -+ use nexum:host/types.{config, event}; -+ use nexum:host/chain.{chain-id}; -``` - -Why: `web3:` precommitted the engine to crypto-only branding. The package is now named after the engine; web3-specific capabilities live inside it as interfaces. - -### Interface renames - -| 0.1 | 0.2 | Rationale | -|---|---|---| -| `csn` | `chain` | `csn` was unreadable; `chain.request(chainId, method, params)` reads itself. | -| `msg` | `messaging` | `msg` collided with its own `message` record; ambiguous in non-Rust bindings. | -| `cow` + `order` | `cow-api` (one interface) | `cow::cow::request` triple-stutter eliminated; `order::submit` merged as `cow-api::submit-order`. | - -### World renames - -```diff -- world headless-module { -+ world event-module { - import chain; - import identity; // NOTE: was missing from 0.1 WIT; now present - import local-store; - import remote-store; - import messaging; - import logging; - export init: func(config: config) -> result<_, string>; - export on-event: func(event: event) -> result<_, string>; - } -``` - -```diff -- world shepherd-module { -- include headless-module; -- import cow; -- import order; -+ world shepherd { -+ include event-module; -+ import cow-api; - } -``` - -### Function renames (verb-first, fully spelled) - -```diff - interface remote-store { -- feed-get: func(owner: list, topic: list) -> result>, store-error>; -- feed-set: func(topic: list, data: list) -> result, store-error>; -+ read-feed: func(owner: list, topic: list) -> result>, fault>; -+ write-feed: func(topic: list, data: list) -> result, fault>; - } -``` - -### Type and field renames - -```diff - interface types { -- record block-data { ... } -- record log-entry { ..., tx-hash: list, ... } -- record message-data { ... } -- variant event { -- block(block-data), -- logs(list), -- timer(u64), -- message(message-data), -- } -+ record block { ... } -+ record log { ..., transaction-hash: list, ... } -+ record message { ... } -+ record tick { fired-at: u64 } // milliseconds since Unix epoch, UTC -+ variant event { -+ block(block), -+ logs(list), -+ tick(tick), -+ message(message), -+ } - } -``` - -Two semantic notes: - -- All `u64` timestamps in 0.2 are **milliseconds since Unix epoch, UTC**. The 0.1 WIT did not specify a unit and several sources used seconds. Audit any timestamp arithmetic you do. -- `tick` (formerly `timer`) is now a record, not a bare `u64`. In bindings it reads `event.tick.firedAt` instead of `event.timer === 1700000000`. - -> **Breaking: the chain-event log is now `chain-log`.** The `log` record and the `logs(list)` event arm shown above have been renamed to `chain-log` and `chain-logs(chain-logs)` so the on-chain event vocabulary no longer collides with the diagnostics logging pipeline (`nexum:host/logging`, `[limits.logs]`), which is untouched. Three consequences: a manifest with `kind = "log"` fails load with an unknown-kind error naming the valid set (`block`, `chain-log`, `cron`); a component built against the old world's `log` record or `logs` arm no longer links against the current world (rebuild against the renamed WIT); and guest strategy handlers rename `on_logs` to `on_chain_logs`. The `block`, `tick`, and `message` arms are unchanged. - -> **Breaking: the chain-log record now carries the full RPC log shape.** The record was reshaped to mirror `alloy_rpc_types_eth::Log` field for field so a guest reconstructs the native alloy log without loss: `block-hash`, `block-timestamp`, `transaction-index`, and `removed` are added; `log-index` and `transaction-index` widen to `u64`; and the block-scoped fields become `option<>` (absent on a pending log). The chain id moves off the per-log record onto a new `chain-logs { chain-id, logs }` batch that the `chain-logs` event arm now wraps, since a delivery always shares one chain and the alloy log type carries no chain id of its own. Guests receive `nexum_sdk::events::Log` (alloy's RPC log) directly and decode `sol!` events against `log.inner`; the SDK bind macro emits the WIT-record-to-alloy conversion, so module glue maps a batch straight to `Vec`. - ---- - -## 2. Error model unification [both] - -The five 0.1 error shapes (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) plus bare `string` errors give way to the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases (see [ADR-0011](../adr/0011-per-interface-typed-errors.md)). - -```wit -interface types { - // The shared cross-domain vocabulary. Each payload-bearing case - // carries a human-readable detail; rate-limited carries backoff. - variant fault { - unsupported(string), // capability declared but not provisioned - unavailable(string), // capability exists, backend is down/offline - denied(string), // user or policy rejected - rate-limited(rate-limit), - timeout, - invalid-input(string), - internal(string), // host bug - } - - record rate-limit { - retry-after-ms: option, - } -} -``` - -Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports). A richer interface embeds `fault` as one case of its own variant and adds the cases only it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. - -```wit -interface chain { - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } -} -``` - -### Author migration - -The stringly `domain`/`code` cross-check is gone; dispatch on the typed variant instead. - -```diff -- match chain::request(1, "eth_call", params) { -- Ok(s) => parse(s), -- Err(JsonRpcError { code, message, .. }) if code == -32000 => retry(), -- Err(e) => bail!("rpc failed: {}", e.message), -- } -+ use nexum_sdk::host::{ChainError, Fault}; -+ match host.request(1, "eth_call", params) { -+ Ok(s) => parse(s), -+ Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), -+ Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), -+ Err(ChainError::Fault(Fault::Denied(_))) => abort("denied"), -+ Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // node code + revert bytes -+ Err(e) => bail!("{e}"), -+ } -``` - -`local-store` errors are no longer bare `string`s: they are a plain `fault`. The interface is the failure domain, so the fault omits any subsystem tag; the case tells you whether you hit a quota (`invalid-input`), the backend is down (`unavailable`), etc. - -Module export signatures also change: - -```diff -- export init: func(config: config) -> result<_, string>; -- export on-event: func(event: event) -> result<_, string>; -+ export init: func(config: config) -> result<_, fault>; -+ export on-event: func(event: event) -> result<_, fault>; -``` - -Module identity is the supervisor's business, so module errors are plain `fault` cases; you no longer restate your module name or prefix your messages. A strategy that aggregates store and chain calls into one `fault` relies on the SDK's `From for Fault` fold for `?`. - -### Embedder migration - -Hosts implementing capability traits now return the interface's typed error. Chain calls return `chain-error` (use the `rpc` case for a structured JSON-RPC error; otherwise a `fault`); the rest return `fault` directly. Map each backend failure to the right case: - -| Backend signal | `fault` case | -|---|---| -| Connection refused / DNS fail / offline | `unavailable` | -| Provider HTTP 4xx (other than 401/403/429) | `invalid-input` | -| Provider HTTP 401/403 | `denied` | -| Provider HTTP 429 | `rate-limited` | -| Provider HTTP 5xx / timeout | `unavailable` or `timeout` (prefer the more specific) | -| Structured JSON-RPC error (node `code`, revert `data`) | `chain-error.rpc` (not a fault) | -| User rejected signing in wallet UI | `denied` | -| Module asked for a capability the host doesn't provide | `unsupported` | -| Bug / panic / internal invariant violated | `internal` | - ---- - -## 3. Manifest changes [both] - -### File rename - -If any code, docs, or scripts reference `shepherd.toml`, change to `nexum.toml`. This was a doc/code inconsistency in 0.1; canonical is `nexum.toml`. - -### Field and section renames - -```diff - [module] - name = "twap-monitor" - version = "0.3.0" -- wasm = "sha256:9f86d081..." -+ component = "sha256:9f86d081..." - -- [[subscribe]] -- type = "block" -- chain_id = 42161 -+ [[subscription]] -+ kind = "block" -+ chain_id = 42161 -``` - -`type` → `kind` because `type` is reserved in several binding languages. - -`[module.resources]` (per-module resource caps) and a top-level `[chains]` table were dropped from this example rather than renamed: neither exists in 0.2's manifest schema - resource limits are global engine defaults today (`docs/02-modules-events-packaging.md`'s "Future direction" note), and chain ids are declared per-`[[subscription]]` (`chain_id`) rather than in a manifest-wide table. - -### Capability declaration (new, required) - -In 0.1 the world declared which interfaces a module imported, and instantiation failed if any were unsatisfied. In 0.2, imports declared `optional` in the manifest install a trap stub on the host side - calling them returns `fault.unsupported` rather than failing instantiation. - -```toml -[capabilities] -required = ["chain", "local-store", "logging"] -optional = ["messaging", "remote-store"] # module continues if host doesn't provide - -[capabilities.http] -allow = ["api.coingecko.com", "discord.com"] -``` - -If you omit `[capabilities]` entirely, 0.2 falls back to "all imports required" - same as 0.1 behaviour - and prints a deprecation warning at load. Add the section in your next module update; the implicit-all fallback will be removed in 0.3. - -### Config: unchanged in 0.2 - -`[config]` values continue to flow through to the guest as `list>` - the host flattens TOML scalars (numbers, booleans) to their string form on the way through, same as 0.1. If you currently parse `"50"` into `u64`, that code continues to work unchanged: - -```rust -let bps: u64 = config.iter() - .find(|(k, _)| k == "slippage_bps") - .map(|(_, v)| v.parse()) - .transpose()? - .unwrap_or(50); -``` - -**Deferred to 0.3.** A typed `config-value` variant (string / integer / boolean / list) and a `#[derive(NexumConfig)]` helper are on the 0.3 roadmap, bundled with the manifest-parser work (see §3) so the typing story lands as one coherent feature. - ---- - -## 4. New capabilities (additive) [author] - -These didn't exist in 0.1 and don't break anything. Adopt them to remove workarounds. - -> **Breaking if you tracked the 0.2 drafts.** Earlier 0.2 drafts published `nexum:host/clock` and `nexum:host/http` WIT interfaces. Both are gone from the package: a component importing either no longer links against the 0.2 world, and a manifest declaring `clock` under `[capabilities]` fails load with an unknown-capability error. Time is ambient `wasi:clocks` with no declaration, and outbound HTTP is a `wasi:http` import (the SDK's `http::fetch` wraps it) plus the `http` capability declaration with a `[capabilities.http].allow` list, as described below. Components built against the final 0.1 surface are unaffected beyond the renames in this guide. - -### Time (ambient wasi:clocks) - -Wall-clock and monotonic time are WASI concerns, not `nexum:host` interfaces: the host links `wasi:clocks` into every module store, so a module reads time through any wasi:clocks binding with no capability declaration. - -Replaces the 0.1 workaround of "only know the time inside `on_block` via `block.timestamp`." - -### Randomness (ambient wasi:random) - -Secure randomness is a WASI concern, not a `nexum:host` interface: the host links `wasi:random` into every module store, so a module draws CSPRNG bytes through any wasi:random binding with no capability declaration. - -Replaces the 0.1 workaround of "you can't, period." - -### `http` (allowlisted) - -Outbound HTTP is the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The host links `wasi:http/{outgoing-handler, types}` into every module store; a module imports it with any wasi:http client binding and declares the `http` capability in its manifest. - -Requires a domain allowlist in `nexum.toml`: - -```toml -[capabilities] -optional = ["http"] - -[capabilities.http] -allow = ["api.coingecko.com", "*.discord.com"] -``` - -Hosts MUST enforce the allowlist on every outgoing request (exact host match or `*.domain` suffix, case-insensitive, ports ignored); off-list hosts fail with the wasi:http `HTTP-request-denied` error code. The host does not follow redirects, so each hop is a fresh request checked against the same list. The operator sees the union of granted domains at module load. This replaces the 0.1 anti-pattern of tunnelling alerts through Waku. - -The host also bounds every request: guest-set request-options timeouts are clamped to the engine's `[limits.http]` maxima (unset ones inherit them), the whole exchange runs under a total deadline, and a response body beyond the configured cap fails with `HTTP-response-body-size`. The knobs and defaults live in `engine.example.toml`. - -### `chain::request-batch` - -```wit -interface chain { - use types.{chain-id, fault}; - - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } - - /// A single JSON-RPC request to be executed as part of a batch. - record rpc-request { - method: string, - params: string, - } - - /// Result of a single request inside a batch. Each entry is independent; - /// one failing call does not abort the others. - variant rpc-result { - ok(string), - err(chain-error), - } - - request: func(chain-id: chain-id, method: string, params: string) - -> result; - - /// Hosts that cannot batch natively MUST fall back to sequential - /// `request` calls; the returned list is the same length as `requests` - /// and in the same order. - request-batch: func(chain-id: chain-id, requests: list) - -> result, chain-error>; -} -``` - -Additive. The alloy-backed `HostTransport` now routes `RequestPacket::Batch` through `request-batch` - your existing `provider.multicall(...).await` actually batches on the wire in 0.2 (it didn't in 0.1, despite the docs). - ---- - -## 5. New world: `query-module` (experimental) [author] - -A request/response world for modules that aren't event-driven (wallet rule evaluators, signature validators, pricing oracles). - -```wit -world query-module { - import local-store; - import logging; - // chain, identity, http, etc. are optional via manifest - - export init: func(config: config) -> result<_, fault>; - export evaluate: func(input: list) -> result, fault>; -} -``` - -**Status: WIT is published, no host implementation ships in 0.2.** The 0.2 server runtime only supports `event-module` and `shepherd`. The world is published so module authors can target it experimentally and so embedders building mobile/wallet hosts have a stable contract to implement against. Production support lands in 0.3. - -If you're writing a module that fits this shape, target it now and stub the host with `MockHost` for testing. - ---- - -## 6. Engine crate rename [embedder] - -```diff - [dependencies] -- nxm-engine = "0.1" -+ nexum-engine = "0.2" -``` - -The 0.1 release renamed `nexum-host` → `nxm-engine`. 0.2 reverses that to `nexum-engine` for consistency with `shepherd-sdk` / `shepherd-sdk-test` (and the future `nexum-sdk` / `cargo-nexum` direction described in doc 05). - -```diff -- use nxm_engine::{Engine, Module}; -+ use nexum_engine::{Engine, Module}; -``` - -The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` embedder facade (for non-Rust hosts) are explicitly **deferred to a later release** pending mobile validation; do not assume they exist in 0.2. - ---- - -## 7. SDK changes [author] - -### Rust SDK - -0.1 had no Rust SDK crate: modules called the wit-bindgen-generated -imports directly and hand-wrote the per-cdylib `Guest` / `export!` -glue. 0.2 ships `nexum-sdk` (host-neutral helpers) with -`shepherd-sdk` layering the CoW Protocol surface on top. What that -means for a module you are porting: - -- **Host traits.** Strategy code is written against the - `ChainHost` / `LocalStoreHost` / `LoggingHost` traits (plus - `CowApiHost` in `shepherd-sdk`) instead of the raw wit-bindgen - import shims; the `bind_host_via_wit_bindgen!` / - `bind_cow_host_via_wit_bindgen!` macros generate the adapter at - the cdylib boundary, and the `nexum-sdk-test` / - `shepherd-sdk-test` mocks slot in for native unit tests. -- **Typed errors.** Handlers and host traits use the `Fault` / - `ChainError` / `CowApiError` vocabulary from §2 in place of 0.1's - bare strings. -- **One attribute macro.** `#[nexum_sdk::module]` (a re-export of - `nexum_macros::module`) replaces the hand-written glue: applied to - an inherent `impl` block of named handlers (`init`, `on_block`, - `on_chain_logs`, `on_tick`, `on_message`), it emits the - `wit_bindgen::generate!` call, the host adapter, the `Guest` impl - dispatching to the handlers present, and `export!`. Handlers are - synchronous `fn`s; there is no `async` support, no `block_on`, and - no separate `#[shepherd::module]`. - -Earlier drafts of this section described a richer 0.2 surface -(`provider()`, `Signer`, `Messaging`, `RemoteStore`, a hidden -`HostTransport`); none of that shipped. See -[doc 05](../05-sdk-design.md) for the SDK that exists. - -### Non-Rust SDKs - -The WIT renames propagate mechanically through `wit-bindgen`. Regenerate your bindings against the 0.2 WIT and your existing call sites - adjusted for the renames in §1 - will type-check. - ---- - -## 8. Mechanical rename cheat sheet [both] - -For mechanical search/replace in your codebase. Apply in order; some replacements depend on earlier ones. - -```bash -# WIT package -rg -l 'web3:runtime' | xargs sed -i 's/web3:runtime/nexum:host/g' - -# Interface names (do these before function names - some functions reference the old interface in paths) -rg -l '\bcsn\b' | xargs sed -i 's/\bcsn\b/chain/g' -rg -l '\bmsg\b' | xargs sed -i 's/\bmsg\b/messaging/g' - -# Worlds -rg -l 'headless-module' | xargs sed -i 's/headless-module/event-module/g' -rg -l 'headless_module' | xargs sed -i 's/headless_module/event_module/g' - -# CoW interface stutter -rg -l '\bcow::cow::' | xargs sed -i 's/\bcow::cow::/cow_api::/g' -# (manual: merge `order` imports into `cow-api`; rename `order::submit` to `cow-api::submit-order`) - -# Feed methods -rg -l '\bfeed-get\b' | xargs sed -i 's/\bfeed-get\b/read-feed/g' -rg -l '\bfeed-set\b' | xargs sed -i 's/\bfeed-set\b/write-feed/g' -rg -l '\bfeed_get\b' | xargs sed -i 's/\bfeed_get\b/read_feed/g' -rg -l '\bfeed_set\b' | xargs sed -i 's/\bfeed_set\b/write_feed/g' - -# Type renames -rg -l '\bblock-data\b' | xargs sed -i 's/\bblock-data\b/block/g' -rg -l '\blog-entry\b' | xargs sed -i 's/\blog-entry\b/log/g' -rg -l '\bmessage-data\b' | xargs sed -i 's/\bmessage-data\b/message/g' -rg -l '\btx-hash\b' | xargs sed -i 's/\btx-hash\b/transaction-hash/g' -rg -l '\btx_hash\b' | xargs sed -i 's/\btx_hash\b/transaction_hash/g' - -# Crate rename (Cargo.toml + use statements) -rg -l '\bnxm-engine\b' | xargs sed -i 's/\bnxm-engine\b/nexum-engine/g' -rg -l '\bnxm_engine\b' | xargs sed -i 's/\bnxm_engine\b/nexum_engine/g' - -# Manifest section -rg -l '\[\[subscribe\]\]' | xargs sed -i 's/\[\[subscribe\]\]/[[subscription]]/g' - -# Manifest field -rg -l '^wasm = ' | xargs sed -i 's/^wasm = /component = /' -``` - -Things that **cannot** be sedded - do these by hand: - -- `timer(u64)` → `tick(tick)` with the new `tick { fired-at: u64 }` record. Call sites that pattern-match `Event::Timer(ts)` become `Event::Tick(tick) => tick.fired_at`. -- Error handling. The five old error types are gone; you can't mechanically rewrite a `match` against `JsonRpcError { code, .. }` into the new typed variants (`ChainError::Rpc`, the `fault` cases). Do these per-call-site. -- Splitting `cow` + `order` into a single `cow-api`. Rewrite the imports and adjust function paths. -- Adding `[capabilities]` to `nexum.toml`. Declare what your module actually uses; this is a meaningful audit. - ---- - -## 9. Verification checklist [both] - -After running the renames: - -- [ ] `cargo check --workspace --all-targets` is clean (Rust + bindings). -- [ ] `cargo check --target wasm32-wasip2 -p ` is clean. -- [ ] `cargo test --workspace --no-fail-fast` passes. -- [ ] Your bindgen invocations point at the package's own WIT dir (`wit/nexum-host/`) - or, when consuming both `nexum:host` and a domain-extension package, list both paths explicitly. The 0.1 vendored `deps/` pattern is no longer used in the reference repo. -- [ ] `nexum.toml` has a `[capabilities]` section listing what the module uses. -- [ ] `nexum.toml` references `component = "sha256:..."` not `wasm = ...`. -- [ ] All `[[subscribe]]` sections renamed to `[[subscription]]` with `kind` (not `type`). -- [ ] No remaining references to `web3:runtime`, `csn`, `msg`, `headless-module`, `nxm-engine`, `shepherd.toml`, `feed-get`/`feed-set`, `block-data`/`log-entry`/`message-data`, `tx-hash`. -- [ ] All `Result<_, String>` from module exports replaced with `Result<_, fault>`. -- [ ] Error matching code dispatches on the typed variant (`fault` cases, `ChainError::Rpc`), not protocol-specific error codes. -- [ ] If you used `chrono`/timestamp arithmetic, audited for the seconds-vs-ms change (0.2 is always ms UTC). -- [ ] If you used `provider.multicall(...).await`, confirmed it now actually batches on the wire (`chain::request-batch` shows in tracing). - -> **No `cargo nexum` toolchain in 0.2.** A `cargo-nexum` cargo subcommand (with `new`, `check`, `package`, `run --mock`, `migrate`) is on the 0.3 roadmap. Until then, use `cargo` directly and the `just` recipes in the reference repo. - ---- - -## 10. Deprecation policy going forward [both] - -0.2 is the breaking-change window. The contracts below are stable starting at 0.2.0: - -- WIT package name `nexum:host` and interface names within it. -- The per-interface typed errors over the shared `fault` vocabulary. -- The `nexum.toml` manifest schema. -- The `#[nexum::module]` macro surface. - -Additive changes (new interfaces, new manifest fields, new SDK helpers) may land in any 0.2.x release. Existing identifiers will not be removed or repurposed before 1.0 without a deprecation cycle of at least one minor release. - -The mobile/wallet host story (`query-module` production support, C ABI, `nexum-host` embedder crate) is on the 0.3 roadmap, conditional on a named design partner. The 0.2 `query-module` WIT is an experimental option, not a stable contract; expect changes to its error variants and request/response payload conventions before the 0.3 host ships. - ---- - -## 11. Getting help - -- Open an issue at the repo with the `migration-0.2` label. -- The full 0.2 WIT lives in `wit/nexum-host/` (formerly `wit/web3-runtime/`). -- The §8 cheat sheet has the mechanical sed commands; a `cargo nexum migrate --from 0.1` codemod that wraps them safely is planned for 0.3 alongside the rest of the `cargo-nexum` toolchain. diff --git a/docs/operations/m3-edge-case-validation.md b/docs/operations/m3-edge-case-validation.md index c31cda38..62b681ab 100644 --- a/docs/operations/m3-edge-case-validation.md +++ b/docs/operations/m3-edge-case-validation.md @@ -79,7 +79,7 @@ Error: load module target/wasm32-wasip2/release/stop_loss.wasm Caused by: 0: capability violation in target/wasm32-wasip2/release/stop_loss.wasm - 1: component imports `cow-api` (shepherd:cow/cow-api@0.2.0) but it + 1: component imports `cow-api` (shepherd:cow/cow-api@0.1.0) but it is not listed in [capabilities].required or [capabilities].optional ``` diff --git a/docs/sdk.md b/docs/sdk.md index 14d21da7..c4f07f2f 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -13,13 +13,13 @@ site under `target/doc/nexum_sdk/` and `target/doc/shepherd_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-macros --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-module-macros --no-deps --open ``` ## Authoring a module Modules are authored with the `#[nexum_sdk::module]` attribute -(re-exported from `nexum-macros`). Apply it to an inherent `impl` +(re-exported from `nexum-module-macros`). Apply it to an inherent `impl` block whose associated functions are the named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each taking its wit-bindgen payload and returning `Result<(), Fault>`. @@ -28,7 +28,7 @@ The macro generates the rest of the per-cdylib glue: the adapter, a `Guest` impl whose `on_event` dispatches to whichever handlers are present (absent handlers no-op), and `export!`. See [doc 05](05-sdk-design.md#the-nexummodule-macro) for a worked -example and the `nexum-macros` rustdoc for the fine print. +example and the `nexum-module-macros` rustdoc for the fine print. ## Supported host capabilities @@ -42,11 +42,11 @@ macros generate that adapter). The traits in | Trait | Mirrors | What it does | |---|---|---| -| `ChainHost` | `nexum:host/chain@0.2.0` | JSON-RPC dispatch (`eth_call`, `eth_getLogs`, …) | -| `LocalStoreHost` | `nexum:host/local-store@0.2.0` | Per-module key-value store | -| `LoggingHost` | `nexum:host/logging@0.2.0` | Structured log lines tagged by module | +| `ChainHost` | `nexum:host/chain@0.1.0` | JSON-RPC dispatch (`eth_call`, `eth_getLogs`, …) | +| `LocalStoreHost` | `nexum:host/local-store@0.1.0` | Per-module key-value store | +| `LoggingHost` | `nexum:host/logging@0.1.0` | Structured log lines tagged by module | | `Host` | supertrait | Bundles the three core traits; blanket impl | -| `CowApiHost` (shepherd-sdk) | `shepherd:cow/cow-api@0.2.0` | Orderbook submission (`POST /api/v1/orders`) | +| `CowApiHost` (shepherd-sdk) | `shepherd:cow/cow-api@0.1.0` | Orderbook submission (`POST /api/v1/orders`) | | `CowHost` (shepherd-sdk) | supertrait | `Host` + `CowApiHost` for orderbook strategies | A module declaring `[capabilities].required = ["chain", "local-store", diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 9c4c5604..6dc7d2ae 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -304,8 +304,8 @@ use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `convert_fault`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source +// `WitBindgenHost`, the fault and level `From` impls, `HostLogSink`, +// and `install_tracing` are generated below. Single source // of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -316,7 +316,7 @@ struct StopLoss; impl Guest for StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -333,7 +333,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } @@ -344,7 +344,7 @@ export!(StopLoss); The macro generates `WitBindgenHost`, the `ChainHost` / `LocalStoreHost` / `LoggingHost` / `CowApiHost` impls, the -`Fault` conversions (`convert_fault`, `sdk_fault_into_wit`), and +`Fault` `From` impls (both directions), and `install_tracing`, which installs the guest `tracing` facade so the `tracing::info!`, `warn!`, and `error!` macros reach the host log call with no `Host` value to thread through. Call it once at diff --git a/engine.docker.toml b/engine.docker.toml index 72141a15..e2e697a3 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -77,3 +77,16 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# ---- adapters ---- +# +# The bundled cow venue adapter: the pool router resolves the `cow` +# venue id through it. The operator grant scopes its outbound HTTP to +# the production orderbook host. The Sepolia manifest matches +# twap-monitor's Sepolia subscriptions; a mainnet run swaps in +# /opt/shepherd/manifests/cow-venue.toml. + +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.e2e.toml b/engine.e2e.toml index 330774b7..31960778 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -65,3 +65,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index a3f883bd..be346456 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -100,3 +100,18 @@ rpc_url = "${ARBITRUM_RPC_URL}" [chains.8453] # Base rpc_url = "${BASE_RPC_URL}" + +# ---- adapters ---- +# +# Venue-adapter components the pool router resolves venue ids through. +# The operator, not the adapter author, grants the transport scope: +# `http_allow` is the outbound wasi:http host allowlist. The bundled +# cow adapter builds with `just build-cow-venue`; its venue id is the +# manifest name (`cow`). One manifest per chain: `module.toml` is +# mainnet, `module.sepolia.toml` Sepolia - wire the one matching the +# chain the submitting modules index. + +# [[adapters]] +# path = "target/wasm32-wasip2/release/cow_venue.wasm" +# manifest = "crates/cow-venue/module.toml" +# http_allow = ["api.cow.fi"] diff --git a/engine.load.toml b/engine.load.toml index 7999669d..44924b49 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -41,6 +41,15 @@ rpc_url = "ws://localhost:8545" path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Load-variant manifest: Sepolia chain id with the +# orderbook re-pointed at tools/orderbook-mock, matching +# [extensions.cow.orderbook_urls] above. +[[adapters]] +path = "./target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "./crates/cow-venue/module.load.toml" +http_allow = ["localhost"] + [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "./modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index cce435c3..11f29389 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -33,3 +33,11 @@ manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "modules/ethflow-watcher/module.toml" + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 3f29ea10..7efd05cc 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -46,3 +46,12 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# --- adapters ----------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through. Sepolia +# manifest: the adapter's orderbook must match the chain twap indexes. +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index 8f6cabf8..ba3e78d7 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -9,6 +9,8 @@ # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ # -p balance-tracker -p stop-loss +# cargo build --target wasm32-wasip2 --release \ +# -p cow-venue --features cow-venue/adapter # ./target/release/nexum --engine-config engine.soak.toml # # Swap rpc_url below for your paid endpoint before starting, or set it @@ -66,3 +68,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/extensions.toml b/extensions.toml new file mode 100644 index 00000000..6a280f02 --- /dev/null +++ b/extensions.toml @@ -0,0 +1,13 @@ +# Extension capability registry for this composition root: the +# per-namespace rows the module world synthesis emits beyond the core +# nexum:host table. Each row names the WIT import a `[capabilities]` +# declaration turns into and the package directories its resolve path +# needs, in dependency order. + +[extensions.client] +import = "videre:venue/client@0.1.0" +packages = ["videre-value-flow", "videre-types", "videre-venue"] + +[extensions.cow-api] +import = "shepherd:cow/cow-api@0.1.0" +packages = ["shepherd-cow"] diff --git a/justfile b/justfile index a89e8061..1d22c5c2 100644 --- a/justfile +++ b/justfile @@ -1,16 +1,22 @@ -# Build the host engine +# Build the engine binaries: the bare `nexum` engine and the cow +# composition root `shepherd`. build-engine: - cargo build -p nexum-cli + cargo build -p nexum-cli -p shepherd # Build the example WASM module 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 +# Build the bundled cow venue adapter component. Install via the +# engine.toml [[adapters]] stanza; the venue id is its manifest name. +build-cow-venue: + cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter + # Build everything build: build-engine build-module @@ -37,8 +43,8 @@ build-m2: # (Sepolia, both M2 modules). See `docs/operations/m2-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m2: build-m2 build-engine - cargo run -p nexum-cli -- --engine-config engine.m2.toml --pretty-logs +run-m2: build-m2 build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs # Build the M3 example modules (price-alert + balance-tracker + stop-loss) # for wasm32-wasip2. @@ -52,7 +58,7 @@ build-m3: # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. run-m3: build-m3 build-engine - cargo run -p nexum-cli -- --engine-config engine.m3.toml --pretty-logs + cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs # Build the http-probe example module (wasi:http fetch + allowlist # denial demo) for wasm32-wasip2. @@ -68,14 +74,25 @@ build-e2e: build-m2 build-m3 # 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. -run-e2e: build-e2e build-engine - cargo run -p nexum-cli -- --engine-config engine.e2e.toml +run-e2e: build-e2e build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.e2e.toml + +# Zero-leak gate: host-layer crate graphs, runtime charter-symbol and +# router-field scans, and the nexum:host WIT leaf and foreign-namespace +# scans. Blocking in CI. +check-venue-agnostic: + ./scripts/check-venue-agnostic.sh + +# Orderbook-only gate: the CoW venue crate carries no composable +# symbol. Blocking in CI. +check-cow-orderbook-only: + ./scripts/check-cow-orderbook-only.sh # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example cargo check -p nexum-runtime - cargo check -p nexum-cli + cargo check -p nexum-cli -p shepherd # Run the full CI series locally before pushing. Mirrors # .github/workflows/ci.yml one-to-one: rustfmt, clippy, rustdoc, the @@ -92,6 +109,6 @@ ci: cargo build --release --target wasm32-wasip2 \ -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 fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host + -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ + -p memory-bomb -p panic-bomb -p slow-host cargo test --workspace --all-features --no-fail-fast diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 3beb4710..eff3f0ba 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -14,7 +14,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -22,5 +23,4 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 45b6519a..ae9cb5f0 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -1,6 +1,6 @@ -# ethflow-watcher: see `CoWSwapEthFlow.OrderPlacement`, lift the embedded -# `GPv2OrderData` into an `OrderCreation`, and submit it via the CoW -# Protocol orderbook with the EIP-1271 signing scheme. +# ethflow-watcher: decode `CoWSwapEthFlow.OrderPlacement` logs, compute +# the orderbook UID, and put it under the pool's status watch via the +# cow venue adapter. Observe-only: the module never submits. [module] name = "ethflow-watcher" @@ -10,23 +10,22 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# Least-privilege: the module exercises logging, local-store and -# cow-api today; `chain` is listed as optional so a follow-up (e.g. -# adding an eth_call to read the EthFlow refund pointer) -# can use it without manifest churn, without widening the required -# grant for a capability the module does not call yet. -required = ["logging", "local-store", "cow-api"] -optional = ["chain"] +# Least-privilege: `client` for the pool observe path, `logging` for +# structured runtime logs, `local-store` for the `observed:` journal. +required = ["client", "logging", "local-store"] +optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api`; no direct `http` calls. +# All venue I/O rides the pool router; no direct `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ # CoWSwapEthFlow.OrderPlacement on Sepolia. topic-0 = keccak256( # "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, -# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"). +# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# strategy.rs. # `address` is the Sepolia ETH_FLOW_PRODUCTION deployment from # `cowprotocol/ethflowcontract/networks.prod.json`. Unlike # ComposableCoW's CREATE2 address, EthFlow has had multiple per-network @@ -37,3 +36,9 @@ kind = "chain-log" chain_id = 11155111 address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +# Status transitions the registry polls for watched receipts at the cow +# venue; the module journals `observed:{uid}` on the first one. +[[subscription]] +kind = "intent-status" +venue = "cow" diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 0c04791b..67c92f9e 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -1,23 +1,21 @@ //! # ethflow-watcher (Shepherd module) //! //! Subscribes to `CoWSwapOnchainOrders.OrderPlacement` logs from the -//! canonical CoWSwap EthFlow contracts and verifies the orderbook's -//! native indexer caught each placement via `GET /api/v1/orders/{uid}`. -//! See `strategy.rs` for the design rationale: the orderbook -//! backend indexes EthFlow `OrderPlacement` events server-side with -//! its own dual-validTo bookkeeping, so `POST /api/v1/orders` is -//! structurally the wrong endpoint for on-chain EthFlow orders. The -//! module observes and verifies, it does not submit. +//! canonical CoWSwap EthFlow contracts, computes each placement's +//! orderbook UID, and puts it under the pool's status watch through the +//! typed cow venue client. The registry polls the cow adapter and fans +//! transitions back as `intent-status` events; the module journals +//! `observed:{uid}` on the first one. Observe-only: it never submits +//! (see `strategy.rs`). //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates the `chain-logs` event variant to `strategy::on_chain_logs`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` and `videre_sdk` client seams. It does not know +//! `wit-bindgen` exists. +//! - `lib.rs` (this file) is the per-cdylib glue: the +//! `#[videre_sdk::keeper]` handler impl that binds the world derived +//! from `module.toml` and delegates each event to `strategy`. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -25,62 +23,48 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -// The wit-bindgen-generated import shims only resolve against the -// engine's wasm component host - they have no native-target -// equivalent. Cfg-gate the entire glue layer so the `rlib` artefact -// (consumed by `shepherd-backtest`) carries just the -// strategy code without dangling `extern "C"` imports. The -// `use wit_bindgen as _` line below silences the unused-crate -// lint on native targets where the macro never expands. +// The keeper glue only resolves against the engine's wasm component +// host. Cfg-gate it so the `rlib` artefact (consumed by +// `shepherd-backtest`) carries just the strategy code without dangling +// `extern "C"` imports; the `use wit_bindgen as _` line silences the +// unused-crate lint on native targets where the macro never expands. #[cfg(not(target_arch = "wasm32"))] 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", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - pub mod strategy; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -// Gated on `wasm32` so the strategy can be reused in native targets -// (e.g. the backtest replay harness in `crates/shepherd-backtest`). #[cfg(target_arch = "wasm32")] -use nexum::host::types; +mod glue { + use cow_venue::client::CowClient; -#[cfg(target_arch = "wasm32")] -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); + use crate::strategy; -#[cfg(target_arch = "wasm32")] -struct EthFlowWatcher; + struct EthFlowWatcher; -#[cfg(target_arch = "wasm32")] -impl Guest for EthFlowWatcher { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - tracing::info!("ethflow-watcher init"); - Ok(()) - } + #[videre_sdk::keeper] + impl EthFlowWatcher { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + tracing::info!("ethflow-watcher init"); + Ok(()) + } - fn on_event(event: types::Event) -> Result<(), Fault> { - if let types::Event::ChainLogs(batch) = event { + async fn on_chain_logs(batch: nexum::host::types::ChainLogs) -> Result<(), Fault> { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs) - .map_err(sdk_fault_into_wit)?; + strategy::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) + .await?; + Ok(()) + } + + fn on_intent_status(update: nexum::host::types::IntentStatusUpdate) -> Result<(), Fault> { + strategy::on_intent_status( + &WitBindgenHost, + &update.venue, + &update.receipt, + &update.status, + )?; + Ok(()) } - // Block / Tick / Message are not used by this module. - Ok(()) } } - -#[cfg(target_arch = "wasm32")] -export!(EthFlowWatcher); diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 99e25ba1..002a9984 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -1,11 +1,11 @@ //! Pure strategy logic for the ethflow-watcher module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`]; tests under `#[cfg(test)]` -//! drive the same function with `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through two seams: the +//! `nexum_sdk::host` traits for the `observed:` journal and the typed +//! [`CowClient`] over [`VenueTransport`] for the pool. The `lib.rs` +//! glue binds both to the module's own imports; tests drive the same +//! functions with `nexum_sdk_test::MockHost` and an in-memory spy +//! transport. //! //! ## Design (redesign) //! @@ -13,36 +13,35 @@ //! to `/api/v1/orders` with the EthFlow contract as the EIP-1271 owner. //! Empirical evidence (2026-06-22 Sepolia soak) showed that path cannot //! succeed: the orderbook backend indexes EthFlow `OrderPlacement` -//! events natively and writes server-only fields (`onchainUser`, -//! `onchainOrderData`, `ethflowData.userValidTo`) the public POST body -//! does not carry. Submissions through `/api/v1/orders` are rejected -//! with `ExcessiveValidTo` even though the same UID is `fulfilled` on -//! the orderbook by the time we look. +//! events natively and writes server-only fields the public POST body +//! does not carry. //! -//! This strategy therefore **observes + verifies** instead of -//! submitting: +//! This strategy therefore **observes + verifies** through the pool: //! //! 1. Decode the `OrderPlacement` log against the canonical EthFlow //! contract addresses. -//! 2. Compute the orderbook UID from the on-chain order shape -//! (`OrderData::uid(domain, contract)`). -//! 3. GET `/api/v1/orders/{uid}` to confirm the orderbook indexer -//! picked up the placement. On 200, record `observed:{uid}` in the -//! keeper idempotency journal so log re-delivery is a no-op. On -//! 404, log at Info - typical indexer lag, do not write the marker -//! so the next re-delivery rechecks. Any other error is logged at -//! Warn for operator follow-up. +//! 2. Compute the orderbook UID from the on-chain order shape: the +//! externally-obtained receipt at the cow venue. +//! 3. `observe` the receipt through the pool router, which polls the +//! cow adapter's `status` and fans transitions back as +//! `intent-status` events. On the first one, record `observed:{uid}` +//! in the keeper idempotency journal so log re-delivery is a no-op. +//! A refused observe is logged and left unjournalled, so the next +//! re-delivery retries. use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolEvent; +use cow_venue::assembly; +use cow_venue::client::{CowClient, CowVenue}; use cowprotocol::{ Chain, CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, OnchainSignature, OrderUid, }; use nexum_sdk::events::Log; -use nexum_sdk::host::Fault; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; +use nexum_sdk::status_body::StatusBody; +use videre_sdk::client::{Venue, VenueTransport}; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -70,16 +69,51 @@ pub(crate) struct DecodedPlacement { } /// Entry point: decode every `OrderPlacement` chain-log in a dispatch batch -/// and feed each decoded placement to the observe path. -pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Result<(), Fault> { +/// and put each decoded placement's UID under the pool's status watch. +pub async fn on_chain_logs( + host: &H, + venue: &CowClient, + chain_id: u64, + logs: &[Log], +) -> Result<(), Fault> { for log in logs { if let Some(placement) = decode_order_placement(log) { - observe_placement(host, chain_id, &placement)?; + observe_placement(host, venue, chain_id, &placement).await?; } } Ok(()) } +/// A registry status transition for a watched receipt. Foreign venues +/// are ignored; the first transition for a cow receipt records the +/// `observed:{uid}` marker (the orderbook indexed the placement), and +/// every transition is logged. +pub fn on_intent_status( + host: &H, + venue: &str, + receipt: &[u8], + status: &[u8], +) -> Result<(), Fault> { + if venue != CowVenue::ID.as_str() { + return Ok(()); + } + let Ok(uid) = OrderUid::try_from(receipt) else { + tracing::warn!( + "ethflow status update with a non-uid receipt ({} bytes)", + receipt.len(), + ); + return Ok(()); + }; + let body = StatusBody::decode(status).map_err(|e| Fault::InvalidInput(e.to_string()))?; + let uid_hex = format!("{uid}"); + let journal = Journal::observed(host); + if !journal.contains(&uid_hex)? { + journal.record(&uid_hex)?; + } + tracing::info!("ethflow observed {uid_hex}: {:?}", body.status); + Ok(()) +} + // ---- decode ---- /// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. @@ -89,13 +123,16 @@ pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Resul /// `ETH_FLOW_STAGING` (defensive - the host's `[[subscription]]` /// filter already pins the address, but a misconfigured engine could /// still leak through); -/// - topic0 does not match the event signature; or +/// - topic-0 does not match the `shepherd:cow/cow-events` pin; or /// - the ABI body fails to decode. pub(crate) fn decode_order_placement(log: &Log) -> Option { let contract = log.address(); if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { return None; } + if log.topics().first() != Some(&OrderPlacement::SIGNATURE_HASH) { + return None; + } let decoded = OrderPlacement::decode_log(&log.inner).ok()?; Some(DecodedPlacement { contract, @@ -106,57 +143,43 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { }) } -// ---- observe + verify (redesign) ---- +// ---- observe + verify (pool) ---- -/// Compute the orderbook UID for the placement and confirm the -/// orderbook's native EthFlow indexer picked it up. -fn observe_placement( +/// Compute the orderbook UID for the placement and put it under the +/// pool's status watch. A refused observe (venue down, watch set full) +/// is logged and left unjournalled, so re-delivery retries. +async fn observe_placement( host: &H, + venue: &CowClient, chain_id: u64, placement: &DecodedPlacement, ) -> Result<(), Fault> { - let uid_hex = match compute_uid(chain_id, placement) { - Some(uid) => format!("{uid}"), - None => { - tracing::warn!( - "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", - placement.sender, - ); - return Ok(()); - } + let Some(uid) = compute_uid(chain_id, placement) else { + tracing::warn!( + "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", + placement.sender, + ); + return Ok(()); }; + let uid_hex = format!("{uid}"); - // Idempotency: once verified, do not re-check on log re-delivery + // Idempotency: once observed, do not re-watch on log re-delivery // (engine restart, reorg replay, supervisor restart). let journal = Journal::observed(host); if journal.contains(&uid_hex)? { return Ok(()); } - let path = format!("/api/v1/orders/{uid_hex}"); - match host.cow_api_request(chain_id, "GET", &path, None) { - Ok(_) => { - journal.record(&uid_hex)?; - tracing::info!( - "ethflow observed {uid_hex} (orderbook indexed, sender={:#x})", - placement.sender, - ); - } - Err(CowApiError::Http(http)) if http.status == 404 => { - // Indexer lag is expected immediately after the block lands - - // shepherd's WebSocket can deliver the log a few hundred - // milliseconds before the orderbook's own indexer commits. - // Do NOT write the marker so a later re-delivery (or a future - // block-tick poll) can recheck. Info keeps the soak dashboard - // quiet on normal lag. + match venue.observe(uid.as_slice()).await { + Ok(()) => { tracing::info!( - "ethflow not yet indexed {uid_hex} (sender={:#x}); will recheck on re-delivery", + "ethflow watching {uid_hex} (sender={:#x})", placement.sender, ); } Err(err) => { tracing::warn!( - "ethflow indexer check failed {uid_hex}: {err} (sender={:#x})", + "ethflow watch failed {uid_hex}: {err} (sender={:#x})", placement.sender, ); } @@ -165,30 +188,140 @@ fn observe_placement( } /// Compute the canonical 56-byte orderbook UID for the placement. -/// `OrderData::uid` packs `digest || owner || valid_to`; the owner -/// input is the EthFlow contract (which signs via EIP-1271), not the -/// native-token sender. +/// The UID packs `digest || owner || valid_to`; the owner input is the +/// EthFlow contract (which signs via EIP-1271), not the native-token +/// sender. fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option { let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(&placement.order)?; - Some(order_data.uid(&domain, placement.contract)) + let order = assembly::gpv2_to_order_data(&placement.order)?; + Some(assembly::order_uid(chain, &order, placement.contract)) } #[cfg(test)] mod tests { - use super::*; - use alloy_primitives::{U256, address, b256, hex}; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; - use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::HttpFailure; - use shepherd_sdk_test::MockHost; + use nexum_sdk::host::LocalStoreHost as _; + use nexum_sdk::status_body::IntentStatus as Lifecycle; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::VenueId; + use videre_sdk::rt::complete; + use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// One recorded transport call: which verb, and for `observe` the + /// routed venue and receipt. + #[derive(Clone, Debug, Eq, PartialEq)] + enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, + } + + /// Records every call; `observe` pops a scripted response and + /// defaults to accepted once the script drains. The other verbs + /// refuse: the observe-only strategy must never reach them. + /// Cloneable over shared state so the test keeps a handle after + /// one moves into the client. + #[derive(Clone, Default)] + struct SpyVenues { + calls: Rc>>, + observe_script: Rc>>>, + } + + impl SpyVenues { + fn script_observe(&self, result: Result<(), VenueFault>) { + self.observe_script.borrow_mut().push_back(result); + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + fn observe_count(&self) -> usize { + self.calls + .borrow() + .iter() + .filter(|c| matches!(c, Call::Observe(..))) + .count() + } + } + + impl videre_sdk::client::sealed::SealedTransport for SpyVenues {} + + impl VenueTransport for SpyVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit( + &self, + _venue: &VenueId, + _body: Vec, + ) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + self.observe_script + .borrow_mut() + .pop_front() + .unwrap_or(Ok(())) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } + } + + /// Drive the async strategy on the synchronous test boundary. + fn run_logs( + host: &MockHost, + spy: &SpyVenues, + chain_id: u64, + logs: &[Log], + ) -> Result<(), Fault> { + let client = CowClient::with_transport(spy.clone()); + complete(on_chain_logs(host, &client, chain_id, logs)) + .expect("guest futures complete in one poll") + } + + fn open_status() -> Vec { + StatusBody { + status: Lifecycle::Open, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes") + } + fn sample_order() -> GPv2OrderData { GPv2OrderData { sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), @@ -243,11 +376,14 @@ mod tests { .into() } - fn computed_uid(placement: &DecodedPlacement) -> String { - format!( - "{}", - compute_uid(SEPOLIA, placement).expect("sepolia + canonical markers") - ) + fn sample_log() -> Log { + let (topics, data) = encode_log(&sample_event()); + make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data) + } + + fn sample_uid() -> OrderUid { + let placement = decode_order_placement(&sample_log()).expect("decode succeeds"); + compute_uid(SEPOLIA, &placement).expect("sepolia + canonical markers") } // ---- decode (invariants preserved) ---- @@ -255,9 +391,7 @@ mod tests { #[test] fn decodes_well_formed_placement() { let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).expect("decode succeeds"); + let decoded = decode_order_placement(&sample_log()).expect("decode succeeds"); assert_eq!(decoded.contract, ETH_FLOW_PRODUCTION); assert_eq!(decoded.sender, event.sender); assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); @@ -291,11 +425,7 @@ mod tests { #[test] fn compute_uid_pins_owner_to_ethflow_contract_and_validto() { let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); - - let uid = compute_uid(SEPOLIA, &decoded).expect("sepolia + canonical markers"); + let uid = sample_uid(); let bytes: [u8; 56] = uid.into(); // owner suffix (bytes 32..52) = EthFlow contract address. assert_eq!(&bytes[32..52], ETH_FLOW_PRODUCTION.as_slice()); @@ -308,273 +438,202 @@ mod tests { #[test] fn compute_uid_returns_none_on_unsupported_chain() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); + let decoded = decode_order_placement(&sample_log()).unwrap(); assert!(compute_uid(9999, &decoded).is_none()); } - // ---- observe + verify dispatch (Host-trait integration) ---- + // ---- observe via the pool (transport integration) ---- - /// 200 from `GET /api/v1/orders/{uid}` → `observed:{uid}` written - /// + Info log + zero submit attempts. + /// A placement registers exactly one status watch at the cow venue + /// with the computed UID as the receipt, and journals nothing yet: + /// the marker waits for the first status transition. #[test] - fn placement_log_marks_observed_on_orderbook_200() { + fn placement_log_registers_the_uid_watch() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - // Minimal stub of the orderbook's GET response - strategy only - // checks for 200 vs 404 vs other, the body is opaque to it. - host.cow_api.respond_to_request_for( - "GET", - format!("/api/v1/orders/{uid}"), - Ok(r#"{"status":"fulfilled"}"#.to_string()), - ); + let spy = SpyVenues::default(); + let uid = sample_uid(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 response must write observed:{{uid}} marker" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "exactly one orderbook GET per log" - ); assert_eq!( - host.cow_api.call_count(), - 0, - "observe path must never call submit_order" + spy.calls(), + vec![Call::Observe("cow".to_owned(), uid.as_slice().to_vec())], + "exactly one observe, nothing else", ); - } - - /// 404 from `GET /api/v1/orders/{uid}` → no marker written + Info - /// log + the next re-delivery rechecks (no early dedup). - #[test] - fn placement_log_does_not_mark_observed_on_orderbook_404() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - }))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "404 must NOT write observed: so re-delivery can recheck" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - let ev = logs.expect_one(|e| e.message.contains("not yet indexed")); - assert_eq!( - ev.level, - Level::INFO, - "indexer lag is expected; Info keeps soak dashboards quiet" + host.store.snapshot().is_empty(), + "observed:{{uid}} waits for the first status transition", ); } - /// Non-404 error from the orderbook check → Warn log + no marker. + /// A refused observe warns, journals nothing, and the next + /// re-delivery retries the watch. #[test] - fn placement_log_warns_on_orderbook_other_error() { + fn watch_refusal_warns_and_redelivery_retries() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); + let spy = SpyVenues::default(); + spy.script_observe(Err(VenueFault::Unavailable("venue down".to_owned()))); - host.cow_api - .respond_to_request(Err(CowApiError::Fault(Fault::Unavailable( - "bad gateway".into(), - )))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, SEPOLIA, &[sample_log()])); result.unwrap(); - assert!( - host.store.snapshot().is_empty(), - "non-404 error must not write any marker" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("watch failed")); + + // Unjournalled, so the re-delivered log observes again. + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + assert_eq!(spy.observe_count(), 2); } /// Idempotency: a placement that already has `observed:{uid}` in - /// local store does NOT trigger a fresh GET on re-delivery. + /// local store does NOT touch the pool on re-delivery. #[test] fn previously_observed_placement_is_skipped_on_redelivery() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let spy = SpyVenues::default(); + let uid = sample_uid(); host.store .set(&format!("observed:{uid}"), b"") .expect("seed observed marker"); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "observed:{{uid}} must short-circuit before the orderbook GET" - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "and certainly no submit_order" + assert!( + spy.calls().is_empty(), + "observed:{{uid}} must short-circuit before the pool", ); } /// Defensive: unsupported chain id surfaces a Warn but does not - /// panic and does not touch the orderbook. + /// panic and does not touch the pool. #[test] - fn unsupported_chain_logs_warn_without_orderbook_call() { + fn unsupported_chain_logs_warn_without_venue_call() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); + let spy = SpyVenues::default(); // 9999 is not in cowprotocol::Chain. - let (result, logs) = capture_tracing(|| on_chain_logs(&host, 9999, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, 9999, &[sample_log()])); result.unwrap(); - assert_eq!(host.cow_api.request_calls().len(), 0); - assert_eq!(host.cow_api.call_count(), 0); + assert!(spy.calls().is_empty()); assert!(host.store.snapshot().is_empty()); logs.expect_one(|e| { e.level == Level::WARN && e.message.contains("ethflow uid build skipped") }); } - /// Strategy must never call `submit_order` - the trait still - /// exposes it for other modules (twap-monitor legitimately - /// submits), but ethflow-watcher's observe design never does. - /// Belt-and-suspenders regression guard. + /// The strategy is observer-only: no call path reaches quote, + /// submit, status, or cancel. Belt-and-suspenders regression guard. #[test] - fn strategy_never_calls_submit_order() { + fn strategy_never_submits() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - host.cow_api.respond_to_request(Ok("{}".to_string())); + let spy = SpyVenues::default(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.call_count(), - 0, - "submit_order count must stay at zero - ethflow-watcher is observer-only" + assert!( + spy.calls().iter().all(|c| matches!(c, Call::Observe(..))), + "observe is the only verb the strategy may use", ); } - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `OrderPlacement` signature. - /// A typo or ABI drift would silently miss every EthFlow event. - #[test] - fn topic0_matches_order_placement_canonical_signature() { - assert_eq!( - OrderPlacement::SIGNATURE_HASH, - b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", - ); - } + // ---- intent-status transitions ---- - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals `OrderPlacement::SIGNATURE_HASH` - catches a manifest/code - /// drift the ABI-hash assertion cannot see. (Ported from #164.) + /// The first cow transition journals `observed:{uid}` and logs it. #[test] - fn manifest_topic0_matches_order_placement_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", OrderPlacement::SIGNATURE_HASH); + fn status_update_journals_the_observed_marker() { + let host = MockHost::new(); + let uid = sample_uid(); + + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", uid.as_slice(), &open_status())); + result.unwrap(); + assert!( - manifest.contains(&expected), - "module.toml event_signature must equal OrderPlacement::SIGNATURE_HASH ({expected})", + host.store + .snapshot() + .contains_key(&format!("observed:{uid}")), + "the first transition must write observed:{{uid}}", ); + let ev = logs.expect_one(|e| e.message.contains("ethflow observed")); + assert_eq!(ev.level, Level::INFO); } - /// 429 (rate-limit) from the orderbook check → Warn log + no marker. - /// Verifies the strategy does not conflate 429 with 404 (which would - /// suppress the warning) and does not panic or return an error. + /// Later transitions keep the single marker and stay Ok. #[test] - fn placement_log_warns_on_429_rate_limit() { + fn repeated_transitions_keep_one_marker() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let uid = sample_uid(); - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 429, - body: Some("Too Many Requests".to_string()), - }))); + on_intent_status(&host, "cow", uid.as_slice(), &open_status()).unwrap(); + let fulfilled = StatusBody { + status: Lifecycle::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes"); + on_intent_status(&host, "cow", uid.as_slice(), &fulfilled).unwrap(); - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); + assert_eq!(host.store.snapshot().len(), 1); + } - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "429 must NOT write observed: marker" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + /// A transition from a foreign venue is not ethflow's: ignored. + #[test] + fn foreign_venue_status_update_is_ignored() { + let host = MockHost::new(); + on_intent_status(&host, "echo-venue", sample_uid().as_slice(), &open_status()).unwrap(); + assert!(host.store.snapshot().is_empty()); } - /// HTTP 200 with a malformed (non-JSON) body → `observed:{uid}` still - /// written. The strategy only inspects Ok vs Err, never parses the body, - /// so any successful response confirms indexer pickup regardless of body - /// content. + /// A cow receipt that is not a 56-byte UID warns without a marker. #[test] - fn placement_log_marks_observed_on_malformed_response_body() { + fn non_uid_receipt_warns_without_marker() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", b"abc", &open_status())); + result.unwrap(); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("non-uid receipt")); + } - host.cow_api - .respond_to_request(Ok("not-valid-json{{{{".to_string())); + /// A status body the SDK cannot decode is a typed fault, never a + /// silent marker. + #[test] + fn malformed_status_body_is_a_typed_fault() { + let host = MockHost::new(); + let err = on_intent_status(&host, "cow", sample_uid().as_slice(), &[0xFF, 0x00]) + .expect_err("undecodable status body"); + assert!(matches!(err, Fault::InvalidInput(_))); + assert!(host.store.snapshot().is_empty()); + } - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + // ---- package-of-record parity ---- + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every EthFlow event. + #[test] + fn topic0_matches_the_cow_events_package_of_record() { + let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 with malformed body must still write observed: — strategy does not parse the response", + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", + ); + } + + /// Read the shipped `module.toml` and assert its pinned + /// `event_signature` equals the decoder topic-0 - catches a + /// manifest/code drift the wit assertion cannot see. + #[test] + fn manifest_topic0_matches_order_placement_signature_hash() { + let manifest = include_str!("../module.toml"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); + assert!( + manifest.contains(&expected), + "module.toml event_signature must equal the decoder topic-0 ({expected})", ); } } diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 5c7445b0..da1ddd26 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -68,11 +68,14 @@ impl ExampleModule { } fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, &format!( - "intent status update from venue {} ({} receipt bytes)", + "intent status update from venue {}: {:?} ({} receipt bytes)", update.venue, + body.status, update.receipt.len(), ), ); diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 263a7bcd..ee3ae410 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -9,7 +9,7 @@ //! ## Module layout //! //! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` +//! the `nexum_sdk::host` trait seam. It does not know `wit-bindgen` //! exists. //! - `lib.rs` (this file) declares the handlers and defers the //! per-cdylib glue to `#[nexum_sdk::module]`. @@ -35,7 +35,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the // wit-bindgen call and the `Guest`/`export!` glue. struct BalanceTracker; @@ -44,7 +44,7 @@ struct BalanceTracker; impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "balance-tracker init: {} addresses, threshold={} wei", cfg.addresses.len(), @@ -58,6 +58,6 @@ impl BalanceTracker { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) } } diff --git a/modules/examples/balance-tracker/src/strategy.rs b/modules/examples/balance-tracker/src/strategy.rs index 91e25193..f1f5bf73 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/modules/examples/balance-tracker/src/strategy.rs @@ -1,11 +1,13 @@ //! Pure strategy logic for the balance-tracker module. //! -//! Every interaction with the world flows through the [`Host`] trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. +//! Every interaction with the world flows through the host trait +//! seam exposed by `nexum-sdk`, bounded to exactly the interfaces the +//! module declares ([`ChainHost`] + [`LocalStoreHost`]) - no direct +//! calls to wit-bindgen-generated free functions live here. The +//! `lib.rs` glue wraps a `WitBindgenHost` adapter around the module's +//! per-cdylib wit-bindgen imports and hands it to [`on_block`]; tests +//! under `#[cfg(test)]` hand the same function a +//! `nexum_sdk_test::MockHost`. //! //! Aligns balance-tracker with the M3 "host trait + adapter" recipe //! the other four modules already follow (PR #55 review). Previously @@ -15,7 +17,7 @@ use nexum_sdk::address::parse_address_list; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; use nexum_sdk::prelude::{Address, U256}; /// Resolved settings parsed from `[config]` at `init` and read on @@ -35,7 +37,11 @@ pub struct Settings { /// Each address is independent; a single flaky `eth_getBalance` does /// not abort the loop - the failure is logged and the next address is /// still polled. -pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result<(), Fault> { +pub fn on_block( + host: &H, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> { for addr in &settings.addresses { if let Err(err) = check_one(host, chain_id, *addr, settings.change_threshold) { tracing::warn!("balance-tracker {addr:#x}: {err}"); @@ -47,7 +53,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result /// Poll one address: fetch latest balance, diff against the last /// stored value, emit a log if the delta crosses `threshold`, then /// persist the new value under `balance:{addr}`. -fn check_one( +fn check_one( host: &H, chain_id: u64, addr: Address, @@ -74,7 +80,7 @@ fn check_one( } /// `chain::request("eth_getBalance", [addr, "latest"])` -> `U256`. -fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { +fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { let params = format!("[\"{addr:#x}\",\"latest\"]"); let result_json = host.request(chain_id, "eth_getBalance", ¶ms)?; parse_balance_hex(&result_json).ok_or_else(|| { @@ -149,7 +155,7 @@ fn config_err(e: ConfigError) -> Fault { mod tests { use super::*; use nexum_sdk::Level; - use nexum_sdk::host::{ChainError, Fault, LocalStoreHost as _}; + use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::prelude::address; use nexum_sdk_test::{MockHost, capture_tracing}; 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..9dee6673 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,10 +23,16 @@ 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" [config] name = "echo-client" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 08eb05ee..185e22da 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 quotes and +//! 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,21 @@ 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::quote(ECHO_VENUE, &body) { + Ok(quotation) => logging::log( + logging::Level::Info, + &format!( + "quoted {} bytes at {ECHO_VENUE}: gives {} amount bytes", + body.len(), + quotation.gives.amount.len(), + ), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("quote at {ECHO_VENUE} was refused"), + ), + } + match client::submit(ECHO_VENUE, &body) { Ok(SubmitOutcome::Accepted(receipt)) => logging::log( logging::Level::Info, &format!( @@ -55,11 +69,14 @@ impl EchoClient { } fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, &format!( - "intent status from venue {} ({} receipt bytes)", + "intent status from venue {}: {:?} ({} receipt bytes)", update.venue, + body.status, update.receipt.len(), ), ); diff --git a/modules/examples/echo-keeper/Cargo.toml b/modules/examples/echo-keeper/Cargo.toml new file mode 100644 index 00000000..ff843e85 --- /dev/null +++ b/modules/examples/echo-keeper/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "echo-keeper" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shepherd example keeper paired with the echo-venue adapter: drives it through the typed VenueClient emitted by #[videre_sdk::keeper] - quote, submit, status, cancel - and logs the intent-status transitions the registry fans back." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../crates/nexum-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-keeper/module.toml b/modules/examples/echo-keeper/module.toml new file mode 100644 index 00000000..0620286c --- /dev/null +++ b/modules/examples/echo-keeper/module.toml @@ -0,0 +1,40 @@ +# echo-keeper module manifest - the blessed keeper half of the echo +# pair. It drives the echo-venue adapter through the typed client, 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-keeper" +version = "0.1.0" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# `client` grants the videre:venue/client import (required by +# #[videre_sdk::keeper]); `logging` the log sink. +required = ["client", "logging"] +optional = [] + +[capabilities.http] +allow = [] + +# Drive the venue on every chain-1 block. +[[subscription]] +kind = "block" +chain_id = 1 + +# Observe the status transitions the registry polls from the echo-venue +# adapter. +[[subscription]] +kind = "intent-status" +venue = "echo-venue" + +[config] +name = "echo-keeper" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/modules/examples/echo-keeper/src/lib.rs b/modules/examples/echo-keeper/src/lib.rs new file mode 100644 index 00000000..05e41619 --- /dev/null +++ b/modules/examples/echo-keeper/src/lib.rs @@ -0,0 +1,109 @@ +//! # echo-keeper (reference videre keeper module) +//! +//! The blessed keeper half of the echo pair: on every chain-1 block it +//! drives the echo-venue adapter through the typed +//! `VenueClient` - quote, submit, status, cancel, all with a +//! typed body - and logs each `intent-status` transition the registry +//! fans back. Where echo-client hand-writes byte marshalling over the +//! raw `videre:venue/client` import, this module is +//! `#[videre_sdk::keeper]`: the macro wires the world and the client +//! import, and the author never sees wire bytes. +//! +//! 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. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::{logging, types}; +use videre_sdk::{SubmitOutcome, Venue, VenueClient, VenueId}; + +/// The echo venue as this keeper types it: the id the paired adapter +/// answers for and the body schema below. +struct EchoVenue; + +impl Venue for EchoVenue { + const ID: VenueId = VenueId::from_static("echo-venue"); + type Body = EchoBody; +} + +/// The keeper's published body schema. The echo venue accepts any +/// bytes, so v1 is just the block number: enough to exercise the typed +/// codec end to end. +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), +} + +struct EchoKeeper; + +#[videre_sdk::keeper] +impl EchoKeeper { + async fn on_block(block: types::Block) -> Result<(), Fault> { + let venue = VenueClient::::new(); + let body = EchoBody::V1(block.number); + + // Quote-then-submit through the typestate: the venue prices + // exactly the bytes it is later handed. ClientError folds into + // the wire fault, so `?` applies throughout. + let quoted = venue.quote(&body).await?; + logging::log( + logging::Level::Info, + &format!( + "quoted at {}: gives {} amount bytes", + EchoVenue::ID, + quoted.quotation().gives.amount.len(), + ), + ); + let receipt = match quoted.submit().await? { + SubmitOutcome::Accepted(receipt) => receipt, + SubmitOutcome::RequiresSigning(_) => { + logging::log( + logging::Level::Warn, + &format!("{} unexpectedly asked for a signature", EchoVenue::ID), + ); + return Ok(()); + } + }; + logging::log( + logging::Level::Info, + &format!( + "submitted to {}: receipt {} bytes", + EchoVenue::ID, + receipt.len(), + ), + ); + + let status = venue.status(&receipt).await?; + logging::log( + logging::Level::Info, + &format!("status at {}: {status:?}", EchoVenue::ID), + ); + + venue.cancel(&receipt).await?; + logging::log( + logging::Level::Info, + &format!("cancelled at {}", EchoVenue::ID), + ); + Ok(()) + } + + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + logging::log( + logging::Level::Info, + &format!( + "intent status from venue {}: {:?} ({} receipt bytes)", + update.venue, + body.status, + update.receipt.len(), + ), + ); + Ok(()) + } +} diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml index f900f97b..a1d818f1 100644 --- a/modules/examples/echo-venue/Cargo.toml +++ b/modules/examples/echo-venue/Cargo.toml @@ -12,11 +12,11 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } -wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] # The conformance kit: holds this adapter's header derivation to the kit's # golden mirror types, so echo-venue is both the tutorial artefact and the # kit's worked test target. -nexum-venue-test = { path = "../../../crates/nexum-venue-test" } +videre-test = { path = "../../../crates/videre-test" } diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml index f57607ca..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. @@ -22,3 +22,8 @@ allow = [] [config] name = "echo-venue" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index ef80f731..2a3d8d50 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -2,12 +2,12 @@ //! //! 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 -//! 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 -//! the adapter face - and as the `nexum-venue-test` conformance target (see -//! the tests below). +//! `fulfilled`). It carries no real venue protocol, so it doubles as the +//! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the +//! attribute takes the `impl VenueAdapter` block and supplies the +//! per-cdylib wit-bindgen for a world derived from `module.toml` plus the +//! export glue - and as the `videre-test` conformance target (see the +//! tests below). //! //! It declares one capability (`chain`), so the built component imports //! `nexum:host/chain` and nothing else: the per-component world matches @@ -18,31 +18,56 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] +// `Config` and `Fault` come from the macro's world bindgen at the crate +// root: aliases of the SDK types, so the trait impl lines up. use nexum::host::chain; -use nexum::intent::types::{IntentHeader, IntentStatus, SubmitOutcome, VenueError}; -use nexum::value_flow::types::{Asset, AssetAmount, Settlement}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, + VenueError, +}; struct EchoVenue; -#[nexum_venue_sdk::venue] -impl EchoVenue { +#[videre_sdk::venue] +impl VenueAdapter for EchoVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(()) } + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + 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: zero_native(), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, + }) + } + + fn quote(body: Vec) -> Result { + // Echo pricing mirrors the header: gives the body length, wants + // nothing, charges no fee, and the quote never expires. + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, }) } @@ -55,155 +80,102 @@ 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(()) + } +} + +/// A zero native amount: the venue's spelling of "nothing". +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), } } -/// 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 -/// `IntentHeader`, so the check bridges it to [`GoldenHeader`] field for -/// field - the pattern the kit documents for macro-built adapters - rather -/// than reusing the SDK's `From`. +/// 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 `videre-test` conformance target: the adapter's +/// pure header derivation is held to a hand-written golden. The macro +/// remaps the type interfaces onto the SDK bindings, so the derivation +/// feeds the kit directly through its `From` mirror. #[cfg(test)] mod conformance { use super::*; - use nexum::intent::types::AuthScheme; - use nexum_venue_test::{ - GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, - HeaderGolden, HeaderGoldens, + use videre_test::{ + FormatVersion, 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, - }, - } - } - - fn amount_to_golden(amount: AssetAmount) -> GoldenAssetAmount { + fn zero_native() -> GoldenAssetAmount { GoldenAssetAmount { - asset: asset_to_golden(amount.asset), - amount: amount.amount, + asset: GoldenAsset::Native, + amount: Vec::new(), } } - 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, - } - } - - 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), - authorisation: auth_to_golden(header.authorisation), - } - } - - /// The adapter derivation the kit checks, bridged to the golden mirror. - fn derive_golden(body: Vec) -> Result { - EchoVenue::derive_header(body).map(header_to_golden) - } - #[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 { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![golden], }; - goldens.assert_conforms(derive_golden); + goldens.assert_conforms(EchoVenue::derive_header); } #[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 { + version: FormatVersion, 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, }], }; - assert!(goldens.check(derive_golden).is_err()); + assert!(goldens.check(EchoVenue::derive_header).is_err()); } } diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index c48377b1..672f0001 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -43,7 +43,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `sdk_fault_into_wit` and `install_tracing` (used by the handlers) are +// The fault `From` impls and `install_tracing` (used by the handlers) are // generated by the attribute alongside the wit-bindgen call and the // `Guest`/`export!` glue. struct HttpProbe; @@ -52,7 +52,7 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "http-probe init: probe_url={} denied_url={} every_n_blocks={}", cfg.probe_url, @@ -67,7 +67,6 @@ impl HttpProbe { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/strategy.rs index 09eb6980..3d3d80b7 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -86,7 +86,9 @@ fn fetch_err(url: &str, error: &FetchError) -> Fault { FetchError::Denied => Fault::Denied(detail), FetchError::InvalidRequest(_) => Fault::InvalidInput(detail), FetchError::Timeout(_) => Fault::Timeout, - FetchError::Transport(_) => Fault::Unavailable(detail), + // `FetchError` is `#[non_exhaustive]`: a future case folds to + // retryable `unavailable` with its detail. + _ => Fault::Unavailable(detail), } } diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index 94285268..f8e3a825 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -50,7 +50,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the // wit-bindgen call and the `Guest`/`export!` glue. struct PriceAlert; @@ -59,7 +59,7 @@ struct PriceAlert; impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", cfg.oracle_address, @@ -75,7 +75,6 @@ impl PriceAlert { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) - .map_err(sdk_fault_into_wit) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index be6ef1b7..5e203753 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", ], @@ -39,7 +37,7 @@ use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated +// `WitBindgenHost` and the fault and level `From` impls are generated // below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -50,7 +48,7 @@ struct StopLoss; impl Guest for StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -67,7 +65,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } 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/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml new file mode 100644 index 00000000..4987897e --- /dev/null +++ b/modules/fixtures/flaky-venue/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flaky-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Evil-by-design venue adapter fixture: submit panics while the chain head reads as the poison sentinel and accepts once it stops. Drives the supervisor's provider trap-to-recovery and poison sweeps." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/flaky-venue/module.toml b/modules/fixtures/flaky-venue/module.toml new file mode 100644 index 00000000..c7667b9d --- /dev/null +++ b/modules/fixtures/flaky-venue/module.toml @@ -0,0 +1,19 @@ +# flaky-venue adapter manifest - the trap-to-recovery test fixture. +# Declares the chain capability its poison-sentinel read requires. + +[module] +name = "flaky-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "flaky-venue" diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs new file mode 100644 index 00000000..10716274 --- /dev/null +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -0,0 +1,79 @@ +//! # flaky-venue (test fixture) +//! +//! A venue adapter whose `submit` panics (traps the store) while the chain +//! head reads as the poison sentinel `0xdead`, and accepts once the head +//! moves on. The controlling test programs the mock chain backend, so the +//! trap is deterministic and the recovery is host-driven: the supervisor's +//! sweep must reinstantiate the adapter before a submit succeeds again. +//! +//! Not a production adapter. Lives under `modules/fixtures/` so it is +//! obviously test-only. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +// `Config` and `Fault` come from the macro's world bindgen at the crate +// root: aliases of the SDK types, so the trait impl lines up. +use nexum::host::chain; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, + VenueError, +}; + +/// The chain-head response that detonates `submit`. +const POISON_HEAD: &str = "0xdead"; + +struct FlakyVenue; + +#[videre_sdk::venue] +impl VenueAdapter for FlakyVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(_body: Vec) -> Result { + Ok(IntentHeader { + gives: zero_native(), + wants: zero_native(), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, + }) + } + + fn quote(_body: Vec) -> Result { + Ok(Quotation { + gives: zero_native(), + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + + fn submit(body: Vec) -> Result { + let head = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + // The sentinel detonates the fixture: a guest panic traps the + // store, which is what the sweep under test must recover from. + assert!(!head.contains(POISON_HEAD), "flaky-venue poison head"); + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +/// A zero native amount. +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} 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/Cargo.toml b/modules/twap-monitor/Cargo.toml index 91a37a90..acacdadb 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,8 +9,11 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] +composable-cow = { path = "../../crates/composable-cow" } +cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -18,6 +21,6 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index e49b8dec..d7f45ca2 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -1,5 +1,5 @@ # twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones via the CoW Protocol orderbook. +# submit ready ones to the CoW venue through the pool. [module] name = "twap-monitor" @@ -14,21 +14,22 @@ component = "sha256:000000000000000000000000000000000000000000000000000000000000 # - local-store -> watch: / next_block: / next_epoch: / submitted: / # backoff: / dropped: persistence # - chain -> eth_call into ComposableCoW.getTradeableOrderWithSignature -# - cow-api -> POST /api/v1/orders submission path -required = ["logging", "local-store", "chain", "cow-api"] +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "local-store", "chain", "client"] optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api` (which routes through the -# host's pinned orderbook URL); no direct `http` calls. +# All outbound HTTP is the cow adapter's; the keeper makes no direct +# `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ # ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = -# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"). -# Both `address` and `event_signature` are pinned so the supervisor -# does not deliver unrelated logs to the module. +# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# strategy.rs. Both `address` and `event_signature` are pinned so the +# supervisor does not deliver unrelated logs to the module. [[subscription]] kind = "chain-log" chain_id = 11155111 @@ -39,3 +40,13 @@ event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2c [[subscription]] kind = "block" chain_id = 11155111 + +# Status transitions the registry polls back for submitted orders. +[[subscription]] +kind = "intent-status" +venue = "cow" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed venue adapter decodes it. +[venue] +body_version = 1 diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index c23eb237..562a86d8 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,21 +1,19 @@ -//! # twap-monitor (Shepherd module) +//! # twap-monitor (Shepherd keeper module) //! //! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to -//! the CoW orderbook as they go live. +//! watched conditional order on every block, submitting tranches to the +//! CoW venue through the pool as they go live. //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates each event variant to `strategy`. -//! -//! Same recipe as `modules/examples/price-alert` and -//! `modules/examples/stop-loss`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -23,54 +21,45 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; +use cow_venue::CowClient; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - struct TwapMonitor; -impl Guest for TwapMonitor { +#[videre_sdk::keeper] +impl TwapMonitor { fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); tracing::info!("twap-monitor init"); Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match event { - types::Event::ChainLogs(batch) => { - let logs: Vec = - batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs).map_err(sdk_fault_into_wit)?; - } - types::Event::Block(block) => { - let info = strategy::BlockInfo { - chain_id: block.chain_id, - number: block.number, - timestamp: block.timestamp, - }; - strategy::on_block(&WitBindgenHost, info).map_err(sdk_fault_into_wit)?; - } - // Tick / Message are not used by this module. - _ => {} - } + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); + strategy::on_chain_logs(&WitBindgenHost, &logs)?; + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let info = strategy::BlockInfo { + chain_id: block.chain_id, + number: block.number, + timestamp: block.timestamp, + }; + strategy::on_block(&WitBindgenHost, &CowClient::new(), info)?; Ok(()) } -} -export!(TwapMonitor); + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + tracing::info!( + "cow intent status {:?} ({} receipt bytes)", + body.status, + update.receipt.len(), + ); + Ok(()) + } +} diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index dd6da48c..15affe60 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -1,29 +1,34 @@ //! Pure strategy logic for the twap-monitor module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under -//! `#[cfg(test)]` hand the same functions a -//! `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through a trait seam: the +//! `nexum_sdk::host` traits for chain and store access and the videre +//! [`VenueTransport`] under the typed [`CowClient`] for submission - +//! no direct calls to wit-bindgen-generated free functions live here. +//! The `lib.rs` glue hands [`on_chain_logs`] / [`on_block`] the +//! `WitBindgenHost` adapter and the client over the module's own +//! `videre:venue/client` import; tests under `#[cfg(test)]` hand the +//! same functions a `nexum_sdk_test::MockHost` and a scripted +//! transport. //! //! The module owns decode and evaluate only: log decoding into the //! keeper watch set, and the `getTradeableOrderWithSignature` poll //! behind [`ConditionalSource`]. Gate discipline, the `submitted:` -//! journal, submission, and retry dispatch live in the shared -//! composition (`shepherd_sdk::cow::run`). +//! journal, submission through the pool, and retry dispatch live in +//! the shared composition (`shepherd_sdk::cow::run`). use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use composable_cow::{LegacyRevertAdapter, Verdict}; +use cow_venue::CowClient; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; -use nexum_sdk::host::{ChainError, Fault}; +use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, LegacyRevertAdapter, Verdict, run}; +use shepherd_sdk::cow::{events, run}; +use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -60,7 +65,7 @@ mod abi { /// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` /// chain-log in a dispatch batch and persist its watch. -pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { +pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { persist_watch(host, owner, ¶ms)?; @@ -70,20 +75,30 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { } /// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition. The block timestamp arrives in milliseconds; the -/// tick carries Unix seconds. -pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { +/// shared composition, submitting through the typed client onto the +/// pool. The block timestamp arrives in milliseconds; the tick carries +/// Unix seconds. +pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> +where + H: ChainHost + LocalStoreHost, + T: VenueTransport, +{ let tick = Tick { chain_id: block.chain_id, block: block.number, epoch_s: block.timestamp / 1000, }; - run(host, &TwapSource, &tick) + run(host, venue, &TwapSource, &tick) } // ---- indexing path ---- +/// Topic-0 resolves from the `shepherd:cow/cow-events` package of +/// record before the ABI decode. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { + if log.topics().first() != Some(&events::CONDITIONAL_ORDER_CREATED.topic0) { + return None; + } let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; Some((decoded.data.owner, decoded.data.params)) } @@ -91,7 +106,7 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr /// The watch set overwrites in place, so re-indexing the same log /// (re-org replay, overlapping subscription windows) produces no /// observable side effect. -fn persist_watch( +fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, @@ -110,7 +125,7 @@ fn persist_watch( /// down the sweep. struct TwapSource; -impl ConditionalSource for TwapSource { +impl ConditionalSource for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { @@ -135,7 +150,7 @@ impl ConditionalSource for TwapSource { } } -fn poll_one( +fn poll_one( host: &H, chain_id: u64, owner: &Address, @@ -217,7 +232,7 @@ fn outcome_label(o: &Verdict) -> &'static str { // ---- test-only seam mirrors ---- // -// Thin views over the keeper / SDK canon so the dispatch tests can +// Thin views over the keeper / venue canon so the dispatch tests can // seed and inspect the store in the exact shapes production writes. #[cfg(test)] @@ -230,23 +245,108 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) +fn signed_intent_body( + order: &GPv2OrderData, + signature: &Bytes, + owner: Address, +) -> Option { + use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; + use cow_venue::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = gpv2_to_order_data(order)?; + Some(CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + }))) +} + +#[cfg(test)] +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + cow_venue::intent_id(&signed_intent_body(order, signature, owner)?).ok() } #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use alloy_primitives::{B256, U256, address, b256, hex}; + use cow_venue::CowVenue; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::{CowApiError, OrderRejection}; - use shepherd_sdk_test::MockHost; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + }; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted [`VenueTransport`]: one submit outcome per queued entry, + /// every submit recorded. Quote, status, and cancel are off the + /// module's poll path. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Dispatch one block through `on_block` with the typed client over + /// the scripted transport. + fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { + on_block(host, &CowClient::with_transport(venue), block) + } + /// `validTo` a given number of seconds from now. The constructor's /// client-side max-horizon policy reads the wall clock (not the /// block clock), so test orders must expire relative to it. @@ -369,7 +469,7 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - // ---- MockHost dispatch tests ---- + // ---- MockHost + MockVenue dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed /// `ConditionalOrderCreated`, assembled through the same WIT-edge @@ -463,6 +563,7 @@ mod tests { #[test] fn poll_skips_when_next_block_gate_is_in_future() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); let key = seed_watch(&host, owner, ¶ms); @@ -476,19 +577,20 @@ mod tests { ) .unwrap(); - on_block(&host, sample_block(100)).unwrap(); + dispatch(&host, &venue, sample_block(100)).unwrap(); assert_eq!( host.chain.call_count(), 0, "gated watch must not issue eth_call" ); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); } #[test] - fn poll_ready_submits_order_and_persists_submitted_uid() { + fn poll_ready_submits_the_intent_body_through_the_pool() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -501,45 +603,52 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_body = signed_intent_body(&ready_order, &signature, owner) + .expect("canonical markers") + .to_bytes() + .expect("body encodes"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!( + submits[0].0, + CowVenue::ID.as_str(), + "routed to the cow venue" + ); + assert_eq!( + submits[0].1, expected_body, + "the wire carries the intent body" + ); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "expected submitted:{{client_uid}} marker" + .contains_key(&format!("submitted:{expected_id}")), + "expected submitted:{{intent_id}} marker" ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID" + "marker must key on the pre-submit intent-id, not the venue receipt" ); - // The MockHost orderbook stub returns `0xfeedface` instead of - // the canonical UID; the strategy logs a Warn about the - // divergence (real orderbooks would not diverge). - let ev = logs - .expect_one(|e| e.level == Level::WARN && e.message.contains("twap UID divergence")); - assert!(ev.message.contains(&format!("client={expected_uid}"))); - assert!(ev.message.contains("server=0xfeedface")); } /// Regression guard: when `getTradeableOrderWithSignature` /// returns the same Ready tuple in consecutive poll-ticks (the /// on-chain conditional order does not know shepherd already - /// POSTed it), the second tick must NOT call `submit_order` - /// again. Without the guard the orderbook responds with - /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{uid}` - /// short-circuit at the top of `submit_ready`. + /// posted it), the second tick must NOT submit again. Without the + /// guard the venue refuses the duplicate and a Warn fires for what + /// is in fact correct, finished work. The guard is the + /// `submitted:{intent_id}` short-circuit at the top of + /// `submit_ready`. #[test] - fn poll_ready_skips_submit_when_submitted_uid_already_in_store() { + fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -555,14 +664,14 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the - // orderbook submit must not be attempted. - let already_submitted_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + // venue submit must not be attempted. + let already_submitted = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store - .set(&format!("submitted:{already_submitted_uid}"), b"") + .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), @@ -570,28 +679,20 @@ mod tests { "poll still consults the chain to see Ready", ); assert_eq!( - host.cow_api.call_count(), + venue.submit_count(), 0, - "submit_order must NOT be called when submitted:{{uid}} already exists", - ); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "the REST passthrough must NOT be touched - the guard short-circuits early", + "the pool must NOT be touched when submitted:{{intent_id}} already exists", ); } - /// A Ready order with a non-empty `appData` digest submits the - /// digest verbatim as the hash-only wire shape: `appData` carries - /// the `0x`-hex digest, `appDataHash` is absent, and no orderbook - /// GET runs first - watch-tower parity. The absence of - /// `appDataHash` is load-bearing: with both fields present the - /// orderbook reads the body as the full-document shape and rejects - /// it for a digest mismatch. + /// A Ready order with a non-empty `appData` digest rides the intent + /// body verbatim: assembly into the orderbook wire shape is the + /// adapter's, so the keeper ships exactly the digest the chain + /// returned - watch-tower parity. #[test] - fn poll_ready_submits_non_empty_app_data_hash_only() { - use alloy_primitives::keccak256; + fn poll_ready_carries_a_non_empty_app_data_digest_in_the_body() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -607,42 +708,40 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), 1, "exactly one eth_call to poll Ready" ); - assert_eq!(host.cow_api.call_count(), 1, "exactly one orderbook submit"); - assert!( - host.cow_api.request_calls().is_empty(), - "no appData GET before submit - the digest goes out verbatim", - ); - let body = host.cow_api.last_body_as_json().expect("body is JSON"); + let submits = venue.submits(); + assert_eq!(submits.len(), 1, "exactly one pool submit"); + let cow_venue::CowIntentBody::V1(cow_venue::CowIntent::Signed(signed)) = + cow_venue::CowIntentBody::from_bytes(&submits[0].1).expect("body decodes") + else { + panic!("expected a signed V1 intent"); + }; assert_eq!( - body["appData"], - format!("0x{}", alloy_primitives::hex::encode(app_data_hash)), - ); - assert!( - body.get("appDataHash").is_none(), - "hash-only body must omit appDataHash, got: {body}" + signed.order.app_data, app_data_hash.0, + "the digest goes out verbatim in the body", ); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful submit" + .contains_key(&format!("submitted:{expected_id}")), + "submitted:{{intent_id}} marker must be written after a successful submit" ); } #[test] - fn submit_transient_error_leaves_state_unchanged_for_next_block() { + fn submit_transient_fault_leaves_state_unchanged_for_next_block() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -656,17 +755,13 @@ mod tests { Ok(quoted_hex(&wire)), ); - // InsufficientFee classifies as TryNextBlock per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + // The adapter projects a retriable rejection onto `unavailable`, + // which the retry table folds to TryNextBlock. + venue.enqueue_submit(Err(VenueFault::Unavailable( + "InsufficientFee: fee too low".into(), + ))); + + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); // Watch still present, no gate written, no submitted marker. @@ -690,9 +785,13 @@ mod tests { }); } + /// The venue's throttle hint survives the pool seam: a rate-limited + /// refusal backs the watch off on the epoch clock instead of + /// hot-looping the submit every block. #[test] - fn submit_permanent_error_drops_watch() { + fn submit_rate_limited_backs_off_on_the_epoch_gate() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -705,22 +804,55 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); - // InvalidSignature classifies as Drop. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); - on_block(&host, sample_block(1_000)).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&watch_key_str), + "backoff must keep the watch" + ); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert_eq!( + snapshot + .get(&format!("next_epoch:{owner_hex}:{hash_hex}")) + .unwrap(), + &(1_700_000_000_u64 + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + } + + #[test] + fn submit_denied_drops_watch() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // The adapter projects a permanent rejection onto `denied`, + // which the retry table folds to Drop. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); let store = host.store.snapshot(); assert!( !store.contains_key(&watch_key_str), - "permanent error must drop the watch" + "permanent refusal must drop the watch" ); let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); @@ -735,10 +867,11 @@ mod tests { // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes. use alloy_sol_types::SolError; + use composable_cow::IConditionalOrder; use nexum_sdk::host::RpcError; - use shepherd_sdk::cow::IConditionalOrder; let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -764,7 +897,7 @@ mod tests { })), ); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); @@ -774,11 +907,7 @@ mod tests { .snapshot() .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), ); - assert_eq!( - host.cow_api.call_count(), - 0, - "revert-to-drop path never submits" - ); + assert_eq!(venue.submit_count(), 0, "revert-to-drop path never submits"); // The destructive drop carries its cause: the revert selector // and the node's message ride the Warn, and the keeper logs // the removal itself. @@ -799,28 +928,29 @@ mod tests { }); } - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `ConditionalOrderCreated` signature. - /// A typo or ABI drift would silently miss every registration event. + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every registration event. #[test] fn topic0_matches_conditional_order_created_canonical_signature() { assert_eq!( ConditionalOrderCreated::SIGNATURE_HASH, - b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::CONDITIONAL_ORDER_CREATED.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// Stronger guard than the constant check above: read the shipped /// `module.toml` and assert its pinned `event_signature` actually - /// equals `ConditionalOrderCreated::SIGNATURE_HASH`. (Ported from #164.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_conditional_order_created_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", ConditionalOrderCreated::SIGNATURE_HASH); + let expected = format!("{:#x}", events::CONDITIONAL_ORDER_CREATED.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal ConditionalOrderCreated::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } } diff --git a/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh new file mode 100755 index 00000000..074d85bf --- /dev/null +++ b/scripts/check-cow-orderbook-only.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Orderbook-only check for the CoW venue crate: crates/cow-venue carries +# no composable symbol (Composable*, getTradeableOrder*, the +# IConditionalOrder revert selectors, LegacyRevertAdapter) and no +# dependency edge to the composable-cow keeper crate - the Cargo.toml +# scan covers the edge, since the dep line names the crate. Blocking in +# CI; run locally via `just check-cow-orderbook-only`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[cow PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[cow FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +symbols='composable|getTradeableOrder|IConditionalOrder|LegacyRevertAdapter|OrderNotValid|PollTryNextBlock|PollTryAtBlock|PollTryAtEpoch|PollNever' +rg -in --no-heading -e "$symbols" crates/cow-venue +case $? in + 0) fail "composable symbols leak into crates/cow-venue" ;; + 1) pass "cow-venue symbol scan empty" ;; + *) fail "symbol scan errored (crates/cow-venue missing?)" ;; +esac + +exit "$status" diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh new file mode 100755 index 00000000..407006c9 --- /dev/null +++ b/scripts/check-venue-agnostic.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Zero-leak check for the host layer, scoped precisely: no host-layer +# crate graph (runtime, launcher, bare engine) reaches a +# videre/intent/venue/cow crate; the runtime Rust sources carry no +# charter symbol +# (nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter) +# and no privileged router field; and nexum:host names no foreign WIT +# package and resolves as a leaf. The opaque-status envelope +# (intent-status-update, its venue id string) is ratified host surface, +# not a leak. Blocking in CI; run locally via `just check-venue-agnostic`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[l1 PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[l1 FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +# 1. Crate graph: nothing venue-shaped reachable from the host-layer +# crates - the runtime, the generic launcher, and the bare engine +# binary (normal + build edges; dev-deps stay local to the crate). +for crate in nexum-runtime nexum-launch nexum-cli; do + if tree="$(cargo tree -p "$crate" -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "$crate crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "$crate crate graph clean" + fi + else + fail "cargo tree failed for $crate" + fi +done + +# 2. Symbol scan: the charter set (forbidden WIT namespaces, the old +# router and adapter names). Section 1 guards dependency edges; this +# scan stays curated so opaque extension payloads never false-flag. +charter='nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter' +rg -n --no-heading -e "$charter" crates/nexum-runtime/src +case $? in + 0) fail "charter symbols leak into nexum-runtime" ;; + 1) pass "symbol scan empty" ;; + *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; +esac + +# 3. Privileged-field scan: the venue registry rides the extension +# service map; no router field may return to the runtime. +rg -n --no-heading -e 'venue_registry|pool_router' crates/nexum-runtime/src +case $? in + 0) fail "a privileged router field returned to nexum-runtime" ;; + 1) pass "no privileged router field" ;; + *) fail "field scan errored (crates/nexum-runtime/src missing?)" ;; +esac + +# 4. WIT surface: nexum:host is a leaf. No foreign package named +# anywhere in its sources, no cross-package use/import, and the +# package resolves standalone. The opaque-status envelope stays. +wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' +rg -n --no-heading -e "$wit_charter" wit/nexum-host +case $? in + 0) fail "a foreign WIT namespace leaks into wit/nexum-host" ;; + 1) pass "no foreign WIT namespace named" ;; + *) fail "WIT namespace scan errored (wit/nexum-host missing?)" ;; +esac +rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host +case $? in + 0) fail "nexum:host references another WIT package" ;; + 1) pass "nexum:host has no cross-package reference" ;; + *) fail "WIT scan errored (wit/nexum-host missing?)" ;; +esac +if command -v wasm-tools >/dev/null; then + if wasm-tools component wit wit/nexum-host >/dev/null; then + pass "nexum:host resolves standalone" + else + fail "nexum:host does not resolve standalone" + fi +else + printf '\033[1;33m[l1 WARN]\033[0m wasm-tools not found; WIT resolve skipped\n' >&2 +fi + +exit "$status" diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index ce24b343..8ec2f4e1 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -7,7 +7,7 @@ # gitignored. # 3. Cleans data/e2e for a fresh local-store. # 4. Builds all 5 modules + the engine. -# 5. Launches nexum via nohup, redirecting stdout/stderr to +# 5. Launches shepherd via nohup, redirecting stdout/stderr to # docs/operations/e2e-reports/engine-.log. JSON logs # (no --pretty-logs) so e2e-report-gen.sh can mine them with jq. # 6. Waits up to 60 s for the `supervisor ready modules=5 chains=1` @@ -52,7 +52,7 @@ log "building 5 modules + engine (this can take a minute on first run)" cargo build -p price-alert --target wasm32-wasip2 --release >/dev/null cargo build -p balance-tracker --target wasm32-wasip2 --release >/dev/null cargo build -p stop-loss --target wasm32-wasip2 --release >/dev/null - cargo build -p nexum-cli --release >/dev/null + cargo build -p shepherd --release >/dev/null ) ts="$(date -u +%Y%m%dT%H%M%SZ)" @@ -63,7 +63,7 @@ start_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" log "launching engine — log: $log_file" ( cd "$REPO_ROOT" - nohup "$REPO_ROOT/target/release/nexum" \ + nohup "$REPO_ROOT/target/release/shepherd" \ --engine-config "$REPO_ROOT/engine.e2e.local.toml" \ >"$log_file" 2>&1 & echo $! > "$STATE_FILE.pid.tmp" diff --git a/scripts/load-run.sh b/scripts/load-run.sh index 91cef9b0..af636aef 100755 --- a/scripts/load-run.sh +++ b/scripts/load-run.sh @@ -80,10 +80,10 @@ log "building modules + engine + load-gen (release)" ( cd "$REPO_ROOT" && cargo build --release --quiet \ --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher ) -( cd "$REPO_ROOT" && cargo build --release --quiet -p nexum-cli -p load-gen ) +( cd "$REPO_ROOT" && cargo build --release --quiet -p shepherd -p load-gen ) -log "starting nexum (engine.load.toml)" -( cd "$REPO_ROOT" && ./target/release/nexum --engine-config engine.load.toml ) \ +log "starting shepherd (engine.load.toml)" +( cd "$REPO_ROOT" && ./target/release/shepherd --engine-config engine.load.toml ) \ >"$LOG_DIR/engine.log" 2>&1 & ENGINE_PID=$! echo "ENGINE_PID=$ENGINE_PID" >>"$PID_FILE" diff --git a/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml index 9652bdc8..4c62cd8a 100644 --- a/tools/load-gen/Cargo.toml +++ b/tools/load-gen/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true alloy-primitives.workspace = true -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws"] } alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true alloy-transport-ws.workspace = true 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-host/chain.wit b/wit/nexum-host/chain.wit index 97055e5a..1d812dc6 100644 --- a/wit/nexum-host/chain.wit +++ b/wit/nexum-host/chain.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface chain { use types.{chain-id, fault}; diff --git a/wit/nexum-host/event-module.wit b/wit/nexum-host/event-module.wit index db8d7f5a..277134a9 100644 --- a/wit/nexum-host/event-module.wit +++ b/wit/nexum-host/event-module.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Event-driven module — automation, background processing. /// No UI capabilities. Runs on any conforming host. diff --git a/wit/nexum-host/identity.wit b/wit/nexum-host/identity.wit index 09dac970..2e82c9fe 100644 --- a/wit/nexum-host/identity.wit +++ b/wit/nexum-host/identity.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Identity / signing capability. /// diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index 6c5a22b3..8521b37a 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface local-store { use types.{fault}; @@ -15,4 +15,15 @@ interface local-store { /// List all keys matching a prefix. Empty prefix returns all keys. list-keys: func(prefix: string) -> result, fault>; + + /// Whether the key exists, without transferring the value. + contains: func(key: string) -> result; + + /// Value byte length, none if the key is absent, without + /// transferring the value. On some backends this may be a scan. + len: func(key: string) -> result, fault>; + + /// Number of keys matching a prefix, without materialising the key + /// list. On some backends this may be a scan. + count: func(prefix: string) -> result; } diff --git a/wit/nexum-host/logging.wit b/wit/nexum-host/logging.wit index 37e9193f..8cd98140 100644 --- a/wit/nexum-host/logging.wit +++ b/wit/nexum-host/logging.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface logging { enum level { diff --git a/wit/nexum-host/messaging.wit b/wit/nexum-host/messaging.wit index 7f8e4bf6..30777ca7 100644 --- a/wit/nexum-host/messaging.wit +++ b/wit/nexum-host/messaging.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface messaging { use types.{fault, message}; diff --git a/wit/nexum-host/query-module.wit b/wit/nexum-host/query-module.wit index 7dd52600..ffb29b87 100644 --- a/wit/nexum-host/query-module.wit +++ b/wit/nexum-host/query-module.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Query module — synchronous, side-effect-free evaluation. /// diff --git a/wit/nexum-host/remote-store.wit b/wit/nexum-host/remote-store.wit index 731ae370..39b36bae 100644 --- a/wit/nexum-host/remote-store.wit +++ b/wit/nexum-host/remote-store.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface remote-store { use types.{fault}; diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 6c48dc29..8e834263 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -1,12 +1,10 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Common types shared across all runtime interfaces. /// /// All `u64` timestamps in this package are milliseconds since the Unix /// epoch, UTC. interface types { - use nexum:intent/types@0.1.0.{receipt, intent-status}; - type chain-id = u64; record block { @@ -61,16 +59,19 @@ interface types { } /// A host-observed status transition for a previously submitted - /// intent, expressed in the venue-neutral `nexum:intent` vocabulary. - /// Transport-blind: the subscriber sees only where the intent is in - /// its life, never how the host learnt it. + /// intent. Transport-blind: the subscriber sees only where the + /// intent is in its life, never how the host learnt it. record intent-status-update { /// Venue id the receipt was issued by. venue: string, - /// The venue-scoped intent identifier. - receipt: receipt, - /// Where the intent now is in its life at the venue. - status: intent-status, + /// The venue-scoped intent identifier, opaque to the host. + receipt: list, + /// Opaque versioned status body: a leading u8 version tag, + /// then that version's borsh payload (v1: lifecycle status, + /// optional settlement proof, optional failure reason). An + /// unknown tag is a decode error and the body is never empty. + /// The host never inspects it. + status: list, } variant event { 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/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit index 5c7e379d..0dbaa940 100644 --- a/wit/shepherd-cow/cow-api.wit +++ b/wit/shepherd-cow/cow-api.wit @@ -1,7 +1,9 @@ -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; +/// Legacy host-extension surface: retiring. Submission moves to the +/// generic videre pool seam; deleted at the fork-gated poll wire-swap. interface cow-api { - use nexum:host/types@0.2.0.{chain-id, fault}; + use nexum:host/types@0.1.0.{chain-id, fault}; /// A non-2xx reply from the orderbook that carries no typed /// rejection envelope. `body` is the raw response text, foreign diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit new file mode 100644 index 00000000..b5f54bc1 --- /dev/null +++ b/wit/shepherd-cow/cow-events.wit @@ -0,0 +1,20 @@ +package shepherd:cow@0.1.0; + +/// CoW on-chain event surfaces the keepers decode. Package of record +/// for the canonical event signatures and their keccak256 topic-0 +/// hashes; guest constants and module manifests are parity-tested +/// against this file. +interface cow-events { + /// A decoded CoW on-chain event surface. Each variant doc pins the + /// canonical Solidity signature and its topic-0. + enum cow-event { + /// ComposableCoW registration. + /// signature: ConditionalOrderCreated(address,(address,bytes32,bytes)) + /// topic0: 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361 + conditional-order-created, + /// CoWSwapOnchainOrders (EthFlow) placement. + /// signature: OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes) + /// topic0: 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9 + order-placement, + } +} diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit index ed71da3b..2a81b5f7 100644 --- a/wit/shepherd-cow/cow-ext.wit +++ b/wit/shepherd-cow/cow-ext.wit @@ -1,8 +1,9 @@ -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; /// Extension world: the cow-api interface alone, wired into a module /// linker by the cow extension. Kept separate from `shepherd` so the /// extension contributes only its own import, never the core interfaces. +/// Retiring with `cow-api`. world cow-ext { import cow-api; } diff --git a/wit/shepherd-cow/shepherd.wit b/wit/shepherd-cow/shepherd.wit index 88aff143..729bcd0f 100644 --- a/wit/shepherd-cow/shepherd.wit +++ b/wit/shepherd-cow/shepherd.wit @@ -1,7 +1,7 @@ -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; /// Shepherd module — event-driven Nexum module with CoW Protocol extensions. world shepherd { - include nexum:host/event-module@0.2.0; + include nexum:host/event-module@0.1.0; import cow-api; } 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..8a9685f2 --- /dev/null +++ b/wit/videre-venue/venue.wit @@ -0,0 +1,49 @@ +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.{quotation, receipt, intent-status, submit-outcome, venue-error}; + + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + /// Put an externally-obtained receipt (e.g. an on-chain placement) + /// under the host's status watch; an accepted submit is watched + /// implicitly. Idempotent. + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; + 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, quotation, receipt, intent-status, submit-outcome, venue-error}; + + /// Body-schema versions this adapter decodes. Must equal the + /// manifest `[venue] body_versions` set; install asserts it. + body-versions: func() -> list; + + /// Pure: derive the guard-facing header from a body. No I/O. + derive-header: func(body: list) -> result; + quote: 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.1.0.{config, fault}; + + import nexum:host/chain@0.1.0; + import nexum:host/messaging@0.1.0; + + /// Configure the adapter from its `[config]` before any submission. + export init: func(config: config) -> result<_, fault>; + export adapter; +}