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); 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