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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions crates/nexum-engine/src/runtime/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,8 +93,24 @@ pub async fn run(
log_streams: Vec<TaggedLogStream>,
shutdown: impl std::future::Future<Output = ()> + 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! {
Expand Down
50 changes: 40 additions & 10 deletions crates/nexum-engine/src/supervisor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
208 changes: 208 additions & 0 deletions docs/operations/m3-testnet-runbook.md
Original file line number Diff line number Diff line change
@@ -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<orderUid>
```

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`
36 changes: 36 additions & 0 deletions engine.m3.toml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down