From 80044a4e5eb15ea1df7e40122758dff5f2596714 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 18 Jun 2026 09:14:32 -0300 Subject: [PATCH 1/2] fix(event_loop): do not bail boot when block / log stream Vec is empty Surfaced wiring up `engine.m3.toml` for the M3 testnet runbook: all 3 M3 example modules (price-alert, balance-tracker, stop-loss) only declare `[[subscription]] kind = "block"`, leaving `log_streams` empty. `select_all` over an empty Vec yields `None` immediately, the `tokio::select!` arm fired, and the loop hit the "log stream ended - shutting down for restart" bail before any block flowed. The engine bailed within ~50 ms of `supervisor ready`. Fix: replace each empty side with `futures::stream::pending()` so the corresponding select arm is never selected. The bail-on-None semantic still fires when a *non-empty* stream actually closes (real WebSocket drop), which is the original intent. The bug was symmetric (log-only configs would also bail) but only the block-only path is exercised by an existing module config. M2 was unaffected because both modules subscribe to at least one log. Regression test in `supervisor::tests:: run_does_not_bail_when_both_stream_kinds_are_empty`: invokes `run` with two empty `Vec`s plus a 50 ms shutdown timer; asserts `run` blocks the full 50 ms instead of returning at 0 ms. The pre-fix binary returns in <5 ms. Verified locally: cargo test -p nexum-engine -> 47 passed (was 46) just run-m3 -> 3 modules boot; first block dispatch fires all 3 strategy paths against live Sepolia (oracle read, balance polls, cow-api submit + retry classification) --- crates/nexum-engine/src/runtime/event_loop.rs | 22 ++++++-- crates/nexum-engine/src/supervisor/tests.rs | 50 +++++++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/nexum-engine/src/runtime/event_loop.rs b/crates/nexum-engine/src/runtime/event_loop.rs index 94c74335..ff399fa2 100644 --- a/crates/nexum-engine/src/runtime/event_loop.rs +++ b/crates/nexum-engine/src/runtime/event_loop.rs @@ -2,7 +2,7 @@ //! supervisor until a shutdown signal arrives. use futures::StreamExt; -use futures::stream::{FuturesUnordered, select_all}; +use futures::stream::{BoxStream, FuturesUnordered, select_all}; use tracing::{info, warn}; use crate::bindings::nexum; @@ -93,8 +93,24 @@ pub async fn run( log_streams: Vec, shutdown: impl std::future::Future + Send, ) { - let mut blocks = select_all(block_streams); - let mut logs = select_all(log_streams); + // `select_all` over an empty Vec yields `None` immediately, which + // would trip the "stream ended -> shut down" arm below before the + // first block / log ever flows. Engine configs that subscribe to + // only one event kind (e.g. all modules use `[[subscription]] kind + // = "block"`) are valid and must not be punished. Replace each + // empty side with `stream::pending()` so the corresponding select + // arm is never selected; the bail-on-None semantic still fires + // when a *non-empty* stream actually closes. + let mut blocks: BoxStream<'_, _> = if block_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(block_streams).boxed() + }; + let mut logs: BoxStream<'_, _> = if log_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(log_streams).boxed() + }; let mut shutdown = Box::pin(shutdown); loop { tokio::select! { diff --git a/crates/nexum-engine/src/supervisor/tests.rs b/crates/nexum-engine/src/supervisor/tests.rs index 224e0a03..d0c6405d 100644 --- a/crates/nexum-engine/src/supervisor/tests.rs +++ b/crates/nexum-engine/src/supervisor/tests.rs @@ -12,6 +12,41 @@ fn empty_supervisor_returns_no_subscriptions() { assert_eq!(sup.module_count(), 0); } +/// Regression guard: engines whose modules only declare +/// `[[subscription]] kind = "block"` (or only `kind = "log"`) must not +/// bail at boot. Previously `select_all` on an empty `Vec` yielded +/// `None` immediately and the "stream ended -> shut down" arm fired +/// before any event flowed. The fix in `runtime/event_loop.rs` +/// substitutes `stream::pending()` when the Vec is empty so the +/// corresponding select arm is never selected. +/// +/// Surfaced when wiring up `engine.m3.toml` for the M3 testnet runbook: +/// the 3 M3 example modules (price-alert, balance-tracker, stop-loss) +/// all subscribe to blocks only, no logs. The engine bailed within +/// ~50 ms of `supervisor ready` until this fix landed. +#[tokio::test] +async fn run_does_not_bail_when_both_stream_kinds_are_empty() { + use std::time::{Duration, Instant}; + + let mut supervisor = Supervisor { + modules: Vec::new(), + }; + let started = Instant::now(); + let shutdown = tokio::time::sleep(Duration::from_millis(50)); + + crate::runtime::event_loop::run(&mut supervisor, Vec::new(), Vec::new(), shutdown).await; + + // If the bug were present, `run` returns ~0 ms (the empty `logs` + // stream's first `.next()` yields `None` and the loop bails on + // the bail-on-None arm). With the fix, `run` blocks on `shutdown` + // for the full 50 ms. + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(40), + "run returned in {elapsed:?}, expected >= ~50ms (shutdown timer)", + ); +} + // ── E2E helpers ─────────────────────────────────────────────────────── /// Path to the pre-built example WASM component. Tests that need it @@ -255,8 +290,7 @@ async fn e2e_twap_monitor_block_dispatch() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); - let mut supervisor = - boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; + let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; assert_eq!(supervisor.module_count(), 1); assert_eq!(supervisor.alive_count(), 1); @@ -280,8 +314,7 @@ async fn e2e_ethflow_watcher_log_dispatch() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); - let mut supervisor = - boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; + 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 @@ -309,8 +342,7 @@ async fn e2e_price_alert_block_dispatch() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); - let mut supervisor = - boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; + 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); @@ -326,8 +358,7 @@ async fn e2e_balance_tracker_block_dispatch() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); - let mut supervisor = - boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; + 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); @@ -343,8 +374,7 @@ async fn e2e_stop_loss_block_dispatch() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); - let mut supervisor = - boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; + 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); From 8acd9e267e2d1f536c6747526f590c5600491593 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 18 Jun 2026 09:14:58 -0300 Subject: [PATCH 2/2] docs(m3): testnet runbook + engine.m3.toml + `just run-m3` (validated 3-module E2E) Sister doc to `docs/operations/m2-testnet-runbook.md`. Same shape, different modules. Closes the gap "M3 is unit + integration tested but has never been exercised against a real chain", same as the M2 runbook closed for M2. ## New files - `engine.m3.toml` - workspace-root engine config that boots the 3 M3 example modules (price-alert + balance-tracker + stop-loss) against Sepolia public WS. Separate `state_dir = "./data/m3"` so it never collides with M1 / M2 runbook state. - `docs/operations/m3-testnet-runbook.md` - operator runbook mirroring the M2 one: prerequisites, smoke+active run (M3 is active by default since the example modules trigger on every block), optional pre-signature setup for real stop-loss settlement, state inspection, scope boundaries, troubleshooting, references. - `justfile` recipes: `build-m3` + `run-m3`. ## Validated locally A single Sepolia block dispatch (~10 s wall clock) drove all 3 M3 strategy paths through the live testnet: - **price-alert**: `chain::request eth_call` -> Chainlink AggregatorV3Interface -> ABI decode -> `TRIGGERED answer= 174553978080 threshold=250000000000 (Below)` (Sepolia ETH/USD feed reports $1745.54, below the $2500 default threshold). - **balance-tracker**: 2 `chain::request eth_getBalance` calls (one per configured address) - SDK chain helper + multi-key local-store path. - **stop-loss**: `eth_call` oracle -> `from_signed_order_data` `OrderCreation` with `Signature::PreSign` -> `cow-api::submit- order` bytes=561 -> orderbook returns typed `TransferSimulationFailed` -> `classify_api_error` tags as retriable -> `retry on next block`. Full submit path confirmed; the orderbook rejection is the typed-retry contract working as designed (the default config's `owner = 0x70997970...` does not hold the sell token on Sepolia, so simulation correctly fails). This validates everything the SDK BLEU-840 / BLEU-841 / BLEU-851 / -852 / -854 / -855 PR series builds: Host trait surface, chain helpers, cow helpers, MockHost recipe, strategy/lib split. The same code paths that pass 145 unit tests + 6 doctests + 5 supervisor integration tests now also work against live Sepolia. ## What this validates that the M2 runbook does not M2 only exercises the orderbook submit path indirectly (through the EthFlow watcher reacting to swap.cow.fi traffic, and only when app_data is empty - documented limitation). M3 stop-loss submits proactively on every poll, so the orderbook always sees a real `OrderCreation` body even if it rejects. The typed-retry SDK contract (`classify_api_error` mapping `TransferSimulationFailed` -> `RetryAction::TryNextBlock`) is exercised end-to-end with a real orderbook response, not a fixture. ## Stacks on - `fix(event_loop)` commit immediately preceding this one - the bug surfaced wiring up `engine.m3.toml` (block-only subscriptions bailed the engine pre-fix). - PR #31 (M2 runbook) - same operator-doc shape, same conventions. --- docs/operations/m3-testnet-runbook.md | 208 ++++++++++++++++++++++++++ engine.m3.toml | 36 +++++ justfile | 12 ++ 3 files changed, 256 insertions(+) create mode 100644 docs/operations/m3-testnet-runbook.md create mode 100644 engine.m3.toml diff --git a/docs/operations/m3-testnet-runbook.md b/docs/operations/m3-testnet-runbook.md new file mode 100644 index 00000000..3f98727e --- /dev/null +++ b/docs/operations/m3-testnet-runbook.md @@ -0,0 +1,208 @@ +# M3 testnet runbook (Sepolia) + +How to exercise the M3 example modules - price-alert, balance-tracker, +stop-loss - on Sepolia. Same shape as the M2 runbook but the modules +are different: + +- **price-alert** validates SDK `chain` helpers + Chainlink ABI decode. + Read-only; no on-chain or orderbook action. +- **balance-tracker** validates SDK `chain::request` (raw RPC) + + `local-store` per-key diff persistence. Read-only. +- **stop-loss** validates the full M3 surface: `chain::request` + + `local-store` dedup + `cow-api::submit-order` with + `Signature::PreSign`. Will attempt to submit a real CoW order to the + Sepolia orderbook when the oracle price crosses the trigger. + +In other words: M3 exercises the *strategy*-side SDK surface that M2 +modules eventually consume. The runbook below validates everything in +~8 seconds of wall clock against the real Sepolia ETH/USD Chainlink +feed. + +--- + +## 0. Prerequisites + +- Same as the M2 runbook (Rust nightly + `wasm32-wasip2`, `just` + optional, Sepolia RPC). +- For stop-loss to actually settle an order (not just submit and get + rejected) you also need: + - An EOA matching `[config] owner = ...` in + `modules/examples/stop-loss/module.toml` that has called + `setPreSignature(orderUid, true)` on the GPv2Settlement Sepolia + contract for the computed UID. + - That EOA holds + has approved enough of `sell_token` to settle. + + Without those, stop-loss will hit `TransferSimulationFailed` (or + `InvalidSignature` / `InsufficientAllowance`) and log it as a + retriable error or drop. **That outcome alone validates the + orderbook round-trip** - same shape as the M2 EthFlow validation. + +--- + +## 1. Smoke + active run + +The M3 modules all subscribe to blocks only and start working +immediately - there is no `[[subscription]] kind = "log"` to wait for. +A single Sepolia block (~12 s) drives all three through their full +strategy. + +```bash +just run-m3 +``` + +Equivalent long form: + +```bash +cargo build -p price-alert --target wasm32-wasip2 --release +cargo build -p balance-tracker --target wasm32-wasip2 --release +cargo build -p stop-loss --target wasm32-wasip2 --release +cargo run -p nexum-engine -- --engine-config engine.m3.toml +``` + +### What you should see in the first ~10 seconds (observed) + +``` +INFO nexum-engine starting +INFO opening chain RPC provider chain_id=11155111 url="wss://..." +INFO loading module manifest manifest=modules/examples/price-alert/module.toml +[manifest] required capabilities: logging, chain +INFO compiling component component=...price_alert.wasm +INFO price-alert init: oracle=0x694aa1769357215de4fac081bf1f309adc325306 + threshold=250000000000 direction=Below every_n_blocks=1 +INFO init succeeded module=price-alert +INFO loading module manifest manifest=modules/examples/balance-tracker/module.toml +[manifest] required capabilities: logging, chain, local-store +INFO compiling component component=...balance_tracker.wasm +INFO balance-tracker init: 2 addresses, threshold=100000000000000000 wei +INFO init succeeded module=balance-tracker +INFO loading module manifest manifest=modules/examples/stop-loss/module.toml +[manifest] required capabilities: logging, chain, local-store, cow-api +INFO compiling component component=...stop_loss.wasm +INFO stop-loss init: owner=0x70997970c51812dc3a010c7d01b50e0d17dc79c8 + trigger=250000000000 sell=0x6810e776880c02933d47db1b9fc05908e5386b96 + buy=0xfff9976782d46cc05630d1f6ebab18b2324d6b14 +INFO init succeeded module=stop-loss +INFO supervisor up count=3 +INFO supervisor ready modules=3 chains=1 +INFO block subscription open chain_id=11155111 +``` + +Then on the FIRST Sepolia block dispatch (~5-15s after boot): + +``` +DEBUG chain::request chain_id=11155111 method=eth_call # price-alert reads oracle +WARN price-alert: TRIGGERED answer=174553978080 threshold=250000000000 (Below) +DEBUG chain::request chain_id=11155111 method=eth_getBalance # balance-tracker addr 1 +DEBUG chain::request chain_id=11155111 method=eth_getBalance # balance-tracker addr 2 +DEBUG chain::request chain_id=11155111 method=eth_call # stop-loss reads oracle +DEBUG cow-api::submit-order chain_id=11155111 bytes=561 +WARN stop-loss retry on next block (0): orderbook error (TransferSimulationFailed): + sell token cannot be transferred +``` + +That single block proves the entire M3 strategy surface end-to-end: +oracle read + ABI decode + multi-key local-store + cow-api submit + +typed retry classification, all routed through real wit-bindgen + +WitBindgenHost + supervisor dispatch on a live testnet. + +### Why TRIGGERED fires immediately + +The default `threshold = "2500.00"` in `module.toml::[config]` is +above the Sepolia Chainlink ETH/USD feed (which tracks a stale or +mocked value, often around $1745). Direction is `below`, so the very +first poll trips the alert. Tune `threshold` if you want to test the +"silent" path. + +### Why stop-loss logs TransferSimulationFailed + +The default `owner = 0x70997970...` in stop-loss's config is the +canonical hardhat test EOA (`anvil` account index 1). It does not own +or approve the `sell_token` on Sepolia, so the orderbook simulates +the would-be settle and rejects with +`TransferSimulationFailed`. **This is the orderbook returning a typed +error - the full submit path worked.** The module's +`classify_api_error` SDK helper correctly tagged it as retriable +(`TryNextBlock`), so the watch is left in place for the next block. + +For the silent ("idle until trigger") run path, set `owner` to a real +EOA with the right allowances + pre-signature - see section 2 below. + +--- + +## 2. Active validation (optional) + +To see stop-loss actually submit + persist `submitted:{uid}` you need +to set up a real signed order: + +1. Pick a Sepolia EOA you control. +2. In `modules/examples/stop-loss/module.toml`, set `owner = "0x..."` + to that EOA. +3. Choose a `sell_token` / `buy_token` pair the EOA holds. +4. Compute the OrderUid the module will submit (the `build_creation` + helper in `strategy.rs` shows the construction; you can also boot + the engine once with a high trigger so it stays idle, then + simulate-decode the would-be submit by reading the supervisor's + debug log). +5. Call `GPv2Settlement.setPreSignature(uid, true)` from that EOA on + Sepolia. +6. Approve `sell_token` to the GPv2VaultRelayer for the sell amount. +7. Lower the `trigger_price` in `module.toml` so the next poll fires. + +On the next block: + +``` +INFO stop-loss TRIGGERED price=... trigger=... +DEBUG cow-api::submit-order ... +INFO stop-loss submitted submitted:0x +``` + +This is the M3 equivalent of the M2 EthFlow validation: same +end-to-end surface, different module. + +--- + +## 3. State inspection + +`./data/m3/ls.redb` accumulates the `last:{addr}` keys +(balance-tracker), `submitted:{uid}` / `dropped:{uid}` (stop-loss). +Same caveat as M2 - no `ls-dump` CLI today; reboot the engine on the +same `state_dir` and the supervisor logs every key it loads. + +`rm -rf ./data/m3` between runs for a fresh slate. + +--- + +## 4. What this does NOT prove + +Same boundary as M2's section 4: + +- Throughput / 7-day soak -> COW-1031. +- Cross-module isolation under load -> COW-1064 (4-6 h e2e). +- Adversarial resource exhaustion -> COW-1036. +- Security review -> COW-1065. +- `app_data` resolution for stop-loss orders with non-empty metadata + -> M5 (typed `Cow` client with `raw_request`). + +--- + +## 5. Troubleshooting + +Most of the M2 runbook's section 5 applies verbatim. M3-specific: + +| Symptom | Likely cause | Fix | +|---|---|---| +| `module stop-loss trapped: TransferSimulationFailed` | Trap vs warn confusion | The "sell token cannot be transferred" line is a Warn, not a trap. Module stays alive. Read again carefully. | +| Engine bails immediately with `log stream ended (WebSocket dropped?)` | Pre-fix M1 bug | Should not happen on this commit. The fix lands in `runtime/event_loop.rs`: `select_all` over empty `Vec` is replaced with `stream::pending()`. Regression test at `supervisor::tests::run_does_not_bail_when_both_stream_kinds_are_empty`. | +| `price-alert: TRIGGERED` does not fire | Oracle returned shape we cannot decode, or Sepolia public node throttled the `eth_call` | Check for `eth_call failed` warnings; switch to Alchemy. | +| `balance-tracker` only logs 1 of 2 addresses | RPC dropped a request mid-block | Same RPC throttle path; switch RPC. | + +--- + +## 6. References + +- M3 modules: `modules/examples/{price-alert,balance-tracker,stop-loss}/` +- SDK helpers exercised: `crates/shepherd-sdk/src/{chain,cow}/` +- ADR-0009 (host trait surface): `docs/adr/0009-host-trait-surface.md` +- M3 PRs in `bleu/nullis-shepherd`: #12-#26 (SDK + examples + tutorial + QA cleanup) +- M3 fix tail PRs: #27-#31 (CI matrix, rustdoc gate, doctests, supervisor integration, M2 runbook) +- M2 runbook (sister doc, same shape): `docs/operations/m2-testnet-runbook.md` diff --git a/engine.m3.toml b/engine.m3.toml new file mode 100644 index 00000000..979f7923 --- /dev/null +++ b/engine.m3.toml @@ -0,0 +1,36 @@ +# M3 smoke / validation config for nexum-engine. +# +# Boots the 3 M3 example modules (price-alert + balance-tracker + +# stop-loss) against Sepolia. The 3 modules exercise the full SDK +# helper surface (chain::request via Chainlink read, local-store +# diffing, cow-api submit with PreSign). +# +# Usage: +# just run-m3 +# # or: +# cargo build -p price-alert --target wasm32-wasip2 --release +# cargo build -p balance-tracker --target wasm32-wasip2 --release +# cargo build -p stop-loss --target wasm32-wasip2 --release +# cargo run -p nexum-engine -- --engine-config engine.m3.toml + +[engine] +# Separate from data/m2 and the M1 example state. +state_dir = "./data/m3" +log_level = "info,nexum_engine=debug" + +# Sepolia. Override with an Alchemy / Infura WS for sustained runs; +# the public node throttles eth_subscribe under load. +[chains.11155111] +rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" + +[[modules]] +path = "target/wasm32-wasip2/release/price_alert.wasm" +manifest = "modules/examples/price-alert/module.toml" + +[[modules]] +path = "target/wasm32-wasip2/release/balance_tracker.wasm" +manifest = "modules/examples/balance-tracker/module.toml" + +[[modules]] +path = "target/wasm32-wasip2/release/stop_loss.wasm" +manifest = "modules/examples/stop-loss/module.toml" diff --git a/justfile b/justfile index 1f58883d..f8a59d63 100644 --- a/justfile +++ b/justfile @@ -33,6 +33,18 @@ build-m2: run-m2: build-m2 build-engine cargo run -p nexum-engine -- --engine-config engine.m2.toml +# Build the M3 example modules (price-alert + balance-tracker + stop-loss) +# for wasm32-wasip2. +build-m3: + cargo build -p price-alert --target wasm32-wasip2 --release + cargo build -p balance-tracker --target wasm32-wasip2 --release + cargo build -p stop-loss --target wasm32-wasip2 --release + +# Run nexum-engine wired for the M3 smoke / validation scenario +# (Sepolia, 3 example modules). See `docs/operations/m3-testnet-runbook.md`. +run-m3: build-m3 build-engine + cargo run -p nexum-engine -- --engine-config engine.m3.toml + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example