diff --git a/Cargo.toml b/Cargo.toml index 615ec47..955c9b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "modules/example", "modules/examples/balance-tracker", "modules/examples/price-alert", + "modules/examples/stop-loss", "modules/twap-monitor", ] resolver = "2" diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 777180c..4525696 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -4,22 +4,13 @@ This is the cold-start guide for an external developer. Target completion time: **under four hours** from "I cloned the repo" to "I see my module's first event in the engine log". -Scenario: a **stop-loss** module that watches a Chainlink price -oracle on every block and submits a CoW Protocol order when the -price drops below a configured trigger. It combines every -load-bearing pattern in the SDK: - -| Pattern | Where this tutorial uses it | Already shown in | -|---|---|---| -| Block subscription | "react every block" | [`price-alert`](../modules/examples/price-alert) | -| `chain::request` + ABI decode | read the oracle | [`price-alert`](../modules/examples/price-alert) | -| `local-store` | dedup submitted orders | [`balance-tracker`](../modules/examples/balance-tracker) | -| `cow_api::submit_order` | submit the order | [`twap-monitor`](../modules/twap-monitor) | -| Host-free tests via `MockHost` | unit tests | [`shepherd-sdk-test`](../crates/shepherd-sdk-test) | - -If you would rather read working code than a walkthrough, those -four crates are the worked examples. The rest of this guide -sequences the build so the patterns are introduced one at a time. +The walked-through example is **stop-loss**: a module that watches a +Chainlink price oracle on every block and submits a pre-signed CoW +order when the price drops below a configured trigger. The fully +working source lives at [`modules/examples/stop-loss/`]( +../modules/examples/stop-loss). The rest of this guide reads that +source top-to-bottom and explains *why* each piece is shaped the +way it is. Open the files alongside the guide as you read. ## 0. Prerequisites (15 minutes) @@ -45,472 +36,344 @@ You should see two log lines from the example module - one in the build fails or those log lines do not appear; the rest of the tutorial assumes a working local engine. -## 1. Scaffold the workspace member (15 minutes) - -Create a new crate under `modules/examples/`: +Now build the stop-loss module: ```sh -mkdir -p modules/examples/stop-loss/src +cargo build --target wasm32-wasip2 --release -p stop-loss +ls -lh target/wasm32-wasip2/release/stop_loss.wasm ``` -The `Cargo.toml` follows the same template as `price-alert`: +Expected size: ~300 KB. -```toml -# modules/examples/stop-loss/Cargo.toml -[package] -name = "stop-loss" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -shepherd-sdk = { path = "../../../crates/shepherd-sdk" } -cowprotocol = { version = "1.0.0-alpha.3", default-features = false } -alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } -alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } +## 1. Anatomy of a module (10 minutes) + +A Shepherd module is a Cargo crate with `crate-type = ["cdylib"]` +compiled to `wasm32-wasip2`. The minimum layout: + +``` +modules/examples/stop-loss/ +├── Cargo.toml declares deps (shepherd-sdk, cowprotocol, alloy, ...) +├── module.toml declares capabilities + subscriptions + config +└── src/ + ├── lib.rs wit-bindgen glue + Guest impl + adapter + └── strategy.rs pure logic against `shepherd_sdk::host::Host` ``` -Note the four key features: +The split into `lib.rs` (impure / wit-bindgen) and `strategy.rs` +(pure / `&impl Host`) is the recipe that lets you test the strategy +end-to-end against `shepherd-sdk-test::MockHost` without ever +running the wasm toolchain. + +Open [`Cargo.toml`](../modules/examples/stop-loss/Cargo.toml) and +note the four key features: - **`crate-type = ["cdylib"]`** - produces a WASM Component when built for `wasm32-wasip2`. -- **`shepherd-sdk` path dep** - brings in the helpers (`cow::`, - `chain::`, `host::`, `prelude`). -- **`shepherd-sdk-test` as a dev-dep** - `MockHost` + assertion - helpers, only linked under `cargo test`. -- **No direct `nexum-engine` dep** - modules never link the engine; - they communicate via wit-bindgen-generated shims. - -Add the new crate to the workspace `members` list in `Cargo.toml` -at the repo root: - -```toml -[workspace] -members = [ - # ... existing members - "modules/examples/stop-loss", -] -``` +- **`shepherd-sdk` path dep** - the helpers (`cow::`, `chain::`, + `host::`, `prelude`) live here. +- **`shepherd-sdk-test` as a dev-dep** - `MockHost` is only linked + under `cargo test`, never in the wasm bundle. +- **No `nexum-engine` dep** - modules never link the engine; they + communicate exclusively through wit-bindgen-generated shims. -`cargo check --target wasm32-wasip2 -p stop-loss` should fail with -"no library targets found" - expected, you have not written any -source yet. +The workspace `Cargo.toml` at the repo root has the crate listed +under `[workspace] members`. -## 2. Author the manifest (10 minutes) +## 2. The manifest: capabilities and config (10 minutes) -`module.toml` declares the capabilities, subscriptions, and -operator-supplied config. Drop this next to `Cargo.toml`: +Open [`module.toml`](../modules/examples/stop-loss/module.toml). +Two things matter: ```toml -# modules/examples/stop-loss/module.toml -[module] -name = "stop-loss" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - [capabilities] required = ["logging", "chain", "local-store", "cow-api"] -optional = [] +``` + +The engine enforces this list against the WIT imports the +compiled component declares. Declaring a capability you do not +use is fine; *missing* one you do use is a hard error at +instantiation. Stop-loss touches all four: -[capabilities.http] -allow = [] +| Capability | Used for | +|---|---| +| `logging` | every Info / Warn line | +| `chain` | the `eth_call` to read the oracle | +| `local-store` | the `submitted:{uid}` and `dropped:{uid}` dedup flags | +| `cow-api` | submitting the `OrderCreation` body | +```toml [[subscription]] kind = "block" chain_id = 11155111 # Sepolia +``` +Stop-loss reacts to every new block on Sepolia. WebSocket RPC is +required because `block` rides `eth_subscribe`; see +[`docs/deployment.md`](./deployment.md) for the operator-side +chain config. + +```toml [config] -# Chainlink AggregatorV3Interface address (ETH/USD on Sepolia). -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +oracle_address = "0x694AA1..." decimals = "8" -# Trigger price in the oracle's native decimal units. Below this, -# we sell. trigger_price = "2500.00" -# CoW order parameters (signed by the owner off-chain ahead of -# time, then the module submits the pre-signed body on trigger). -owner = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" -sell_token = "0x6810e776880C02933D47DB1b9fc05908e5386b96" # GNO on Sepolia -buy_token = "0xfff9976782d46cc05630d1f6ebab18b2324d6b14" # WETH on Sepolia -sell_amount_wei = "1000000000000000000" # 1 GNO -buy_amount_wei = "300000000000000000" # 0.3 ETH -valid_to_seconds = "4294967295" # u32::MAX (no expiry) +owner = "0x70997970..." +sell_token = "..." +buy_token = "..." +sell_amount_wei = "..." +buy_amount_wei = "..." +valid_to_seconds = "..." ``` -Two patterns worth noting: +`[config]` is operator-supplied. The values are strings; the +module parses them once at `init`. We will look at the parsing +code in §4. -- **`required` matches the WIT imports the module uses.** The - engine enforces this at instantiation - declaring a capability - the module does not use is fine; missing a capability the module - does use is a hard error. -- **`[config]` values are stringly-typed in 0.2.** Your `init` - parses them; the M3 SDK's `OnceLock` pattern (see - `price-alert`) is the recommended idiom. +## 3. The wit-bindgen adapter in `lib.rs` (15 minutes) -## 3. Write the strategy (60 minutes) +Open [`src/lib.rs`](../modules/examples/stop-loss/src/lib.rs). Top +of the file: -The strategy logic splits into two layers: +```rust +wit_bindgen::generate!({ + path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], + world: "shepherd:cow/shepherd", + generate_all, +}); -- A pure function that takes `&impl Host` and runs the decision - tree. This is what your tests exercise - no `wit-bindgen`, no - `wasmtime`, fast iteration. -- A thin `Guest` impl in `lib.rs` that adapts the wit-bindgen- - generated host imports into a struct implementing - `shepherd_sdk::host::Host`. +mod strategy; +``` + +The `generate!` macro emits the per-cdylib `Guest` trait, +`HostError` struct, and host import shims (`nexum::host::chain:: +request`, `local_store::set`, etc.) into this crate's scope. +`generate_all` is required because the `shepherd:cow/shepherd` +world cross-references types from `nexum:host/types` - see +[`docs/sdk.md`](./sdk.md) for the gotcha. -### 3a. The pure strategy (30 minutes) +Below the macro, three blocks deserve attention: -Sketch in `src/strategy.rs`: +### 3a. `WitBindgenHost` (~80 lines) ```rust -use alloy_primitives::{Address, I256}; -use alloy_sol_types::{SolCall, sol}; -use shepherd_sdk::chain::{eth_call_params, parse_eth_call_result}; -use shepherd_sdk::host::{Host, HostError, LogLevel}; -use shepherd_sdk::prelude::*; - -sol! { - interface AggregatorV3 { - function latestRoundData() external view returns ( - uint80, int256 answer, uint256, uint256, uint80 - ); +struct WitBindgenHost; + +impl ChainHost for WitBindgenHost { + fn request(&self, chain_id: u64, method: &str, params: &str) + -> Result + { + chain::request(chain_id, method, params).map_err(convert_err) } } +// ... LocalStoreHost / CowApiHost / LoggingHost ... +``` -pub struct Settings { - pub oracle_address: Address, - pub trigger_price_scaled: I256, - pub owner: Address, - pub sell_token: Address, - pub buy_token: Address, - pub sell_amount: U256, - pub buy_amount: U256, - pub valid_to: u32, -} +This is the bridge between wit-bindgen's free functions and the +`shepherd_sdk::host::Host` trait the strategy works against. The +shape is mechanical and identical across modules - copy it as-is +into your own module, and a future declarative macro in +`shepherd-sdk` will eventually elide it. -pub fn on_block( - host: &H, - chain_id: u64, - settings: &Settings, -) -> Result<(), HostError> { - // 1. Read the oracle. - let call = AggregatorV3::latestRoundDataCall {}; - let params = eth_call_params(&settings.oracle_address, &call.abi_encode()); - let result_json = host.request(chain_id, "eth_call", ¶ms)?; - let Some(bytes) = parse_eth_call_result(&result_json) else { - host.log(LogLevel::Warn, "stop-loss: cannot decode oracle result"); - return Ok(()); - }; - let decoded = AggregatorV3::latestRoundDataCall::abi_decode_returns(&bytes) - .map_err(|e| HostError { - domain: "stop-loss".into(), - kind: shepherd_sdk::host::HostErrorKind::InvalidInput, - code: 0, - message: format!("oracle decode: {e}"), - data: None, - })?; - let price = decoded.answer; - - // 2. Are we above trigger? Stay idle. - if price > settings.trigger_price_scaled { - host.log(LogLevel::Info, &format!("stop-loss idle (price={price})")); - return Ok(()); - } +### 3b. `convert_err` / `sdk_err_into_wit` / `convert_level` - // 3. Dedup: did we already submit? - let dedup_key = format!("submitted:{:#x}", settings.owner); - if host.get(&dedup_key)?.is_some() { - host.log(LogLevel::Info, "stop-loss: already submitted, skipping"); - return Ok(()); - } +`wit_bindgen::generate!` emits a `HostError` struct into the +module's own crate. `shepherd_sdk::host::HostError` is a *separate* +type with the same fields. The three converters are 7-arm enum +maps - mechanical, but necessary so the trait surface can stay +world-neutral. - // 4. Build the OrderCreation. (See `twap-monitor` for the full - // helper; for tutorial brevity we elide the JSON encoding.) - let body = build_order_body(settings)?; - let uid = host.submit_order(chain_id, &body)?; +### 3c. `Guest for StopLoss` - // 5. Persist + log. - host.set(&dedup_key, uid.as_bytes())?; - host.log(LogLevel::Warn, &format!("stop-loss triggered, uid={uid}")); - Ok(()) -} +```rust +impl Guest for StopLoss { + fn init(config: Vec<(String, String)>) -> Result<(), HostError> { + let cfg = strategy::parse_config(&config).map_err(sdk_err_into_wit)?; + // ... log + cache in OnceLock ... + } -fn build_order_body(_s: &Settings) -> Result, HostError> { - // Cross-reference: `modules/twap-monitor/src/lib.rs::build_order_creation` - // shows the full assembly path using cowprotocol::OrderCreation:: - // from_signed_order_data + serde_json::to_vec. - todo!("see modules/twap-monitor for the canonical assembly") + fn on_event(event: types::Event) -> Result<(), HostError> { + let Some(cfg) = SETTINGS.get() else { return Ok(()); }; + if let types::Event::Block(block) = event { + strategy::on_block(&WitBindgenHost, block.chain_id, cfg) + .map_err(sdk_err_into_wit)?; + } + Ok(()) + } } ``` -The shape to internalise: +`init` parses + caches; `on_event` hands a `WitBindgenHost` to the +strategy and translates the resulting `SdkHostError` back into the +wit-bindgen one for the supervisor. + +`SETTINGS: OnceLock` is the recommended +single-init pattern. wasm32 modules are single-threaded so +`OnceLock` is overkill on synchronisation but cheap and explicit +about lifetime. + +## 4. The strategy in `strategy.rs` (45 minutes) + +Open [`src/strategy.rs`](../modules/examples/stop-loss/src/strategy.rs). +This file is the heart of the module - the only one you would +diff against if you rebased on a newer SDK. -- **Every interaction with the world goes through `host`.** No - global wit-bindgen functions in the strategy; everything is a - method on `&impl Host`. -- **The function is pure-ish:** the only effects are through the - host trait. Tests in §3c run this function against `MockHost` - and assert on the side effects (calls + log lines + state writes). -- **Errors propagate but the loop should not abort on transient - failure.** Wrap upstream calls so a single bad event does not - poison the supervisor - see `price-alert`'s warn-and-return - pattern. +### 4a. `Settings` + `parse_config` -### 3b. The Guest adapter (15 minutes) +The parser walks `Vec<(String, String)>` and produces a typed +`Settings`. It returns `Result` so the upstream `Guest::init` can lift the failure +straight into the wit-bindgen `HostError` envelope with no extra +plumbing. `scale_signed` is a hand-rolled decimal-to-I256 scaler +because alloy ships no `Decimal::parse_units` equivalent (yet). -`src/lib.rs` adapts wit-bindgen's free functions into a struct that -implements `Host`. This is mechanical and almost identical across -modules: +### 4b. `read_oracle` ```rust -#![allow(clippy::too_many_arguments)] +fn read_oracle(host: &H, chain_id: u64, oracle: Address) + -> Option +{ + let call_data = AggregatorV3::latestRoundDataCall {}.abi_encode(); + let params = eth_call_params(&oracle, &call_data); + let result_json = host.request(chain_id, "eth_call", ¶ms).ok()?; + let bytes = parse_eth_call_result(&result_json)?; + AggregatorV3::latestRoundDataCall::abi_decode_returns(&bytes) + .ok() + .map(|r| r.answer) +} +``` -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); +Three SDK helpers in three lines: `chain::eth_call_params` builds +the `[{to, data}, "latest"]` JSON, `chain::parse_eth_call_result` +unpacks the `"0x..."` hex response. The `sol! interface AggregatorV3` +declared at the top of the file gives us a typed call + return +decoder; the same pattern works for any read-only EVM contract. -mod strategy; +Returning `Option` (with a Warn log on the error path inside +the function) is intentional: the next block re-polls, and a +single flaky RPC reply should not propagate into the supervisor. -use std::sync::OnceLock; -use shepherd_sdk::host::{ - ChainHost, CowApiHost, HostError as SdkHostError, HostErrorKind as SdkHostErrorKind, - LocalStoreHost, LogLevel as SdkLogLevel, LoggingHost, -}; +### 4c. `build_creation` -static SETTINGS: OnceLock = OnceLock::new(); +The most interesting piece. Constructs a `cowprotocol:: +OrderCreation` body the orderbook accepts: -struct WitBindgenHost; +```rust +let chain = Chain::try_from(chain_id)?; +let domain = chain.settlement_domain(); +let gpv2 = GPv2OrderData { ... }; +let order_data = gpv2_to_order_data(&gpv2)?; // shepherd-sdk helper +let uid = order_data.uid(&domain, settings.owner); +let creation = OrderCreation::from_signed_order_data( + &order_data, + Signature::PreSign, // owner has called setPreSignature on-chain + settings.owner, + EMPTY_APP_DATA_JSON.to_string(), + None, +)?; +``` -impl ChainHost for WitBindgenHost { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - nexum::host::chain::request(chain_id, method, params).map_err(convert_err) - } -} +Three load-bearing decisions: -impl LocalStoreHost for WitBindgenHost { - fn get(&self, key: &str) -> Result>, SdkHostError> { - nexum::host::local_store::get(key).map_err(convert_err) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), SdkHostError> { - nexum::host::local_store::set(key, value).map_err(convert_err) - } - fn delete(&self, key: &str) -> Result<(), SdkHostError> { - nexum::host::local_store::delete(key).map_err(convert_err) - } - fn list_keys(&self, prefix: &str) -> Result, SdkHostError> { - nexum::host::local_store::list_keys(prefix).map_err(convert_err) - } -} +- **`Signature::PreSign`**: the module ships no ECDSA. The order + owner is expected to have called `GPv2Signing.setPreSignature` + on-chain ahead of the trigger. The body shipped to the orderbook + carries the owner address and an empty signature; the orderbook + validates by checking the on-chain pre-signature record at + settlement. +- **`gpv2_to_order_data`**: the `shepherd-sdk` helper that maps the + on-chain `bytes32` markers (`kind`, balance sources) onto + cowprotocol's typed enums. Same code-path twap-monitor and + ethflow-watcher take after the BLEU-843 refactor. +- **`order_data.uid(&domain, settings.owner)`**: computes the + canonical 56-byte UID locally. The orderbook's `POST /api/v1/ + orders` returns the same UID; the module uses the local version + to dedup *before* paying for the network round-trip. -impl CowApiHost for WitBindgenHost { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - shepherd::cow::cow_api::submit_order(chain_id, body).map_err(convert_err) - } -} +### 4d. `on_block` -impl LoggingHost for WitBindgenHost { - fn log(&self, level: SdkLogLevel, message: &str) { - nexum::host::logging::log(convert_level(level), message); - } -} +The dispatch loop: -fn convert_err(e: HostError) -> SdkHostError { - SdkHostError { - domain: e.domain, - kind: match e.kind { - HostErrorKind::Unsupported => SdkHostErrorKind::Unsupported, - HostErrorKind::Unavailable => SdkHostErrorKind::Unavailable, - HostErrorKind::Denied => SdkHostErrorKind::Denied, - HostErrorKind::RateLimited => SdkHostErrorKind::RateLimited, - HostErrorKind::Timeout => SdkHostErrorKind::Timeout, - HostErrorKind::InvalidInput => SdkHostErrorKind::InvalidInput, - HostErrorKind::Internal => SdkHostErrorKind::Internal, - }, - code: e.code, - message: e.message, - data: e.data, - } -} +```rust +pub fn on_block(host: &H, chain_id: u64, settings: &Settings) + -> Result<(), HostError> +{ + let price = read_oracle(host, chain_id, settings.oracle_address) else { return Ok(()) }; -fn convert_level(l: SdkLogLevel) -> nexum::host::logging::Level { - use nexum::host::logging::Level::*; - match l { - SdkLogLevel::Trace => Trace, - SdkLogLevel::Debug => Debug, - SdkLogLevel::Info => Info, - SdkLogLevel::Warn => Warn, - SdkLogLevel::Error => Error, + if price > settings.trigger_price_scaled { + // idle - log and wait for the next block + return Ok(()); } -} -struct StopLoss; + let (creation, uid) = build_creation(chain_id, settings)?; + let uid_hex = format!("{uid}"); -impl Guest for StopLoss { - fn init(config: Vec<(String, String)>) -> Result<(), HostError> { - let parsed = strategy::Settings::from_config(&config) - .map_err(|e| HostError { - domain: "stop-loss".into(), - kind: HostErrorKind::InvalidInput, - code: 0, - message: e, - data: None, - })?; - let _ = SETTINGS.set(parsed); - nexum::host::logging::log( - nexum::host::logging::Level::Info, - "stop-loss: init ok", - ); - Ok(()) - } + // Dedup: skip if already submitted OR previously dropped. + if host.get(&format!("submitted:{uid_hex}"))?.is_some() { return Ok(()); } + if host.get(&format!("dropped:{uid_hex}"))?.is_some() { return Ok(()); } - fn on_event(event: nexum::host::types::Event) -> Result<(), HostError> { - let Some(s) = SETTINGS.get() else { - return Ok(()); - }; - if let nexum::host::types::Event::Block(b) = event { - strategy::on_block(&WitBindgenHost, b.chain_id, s).map_err(|e| HostError { - domain: e.domain, - kind: match e.kind { - SdkHostErrorKind::Unsupported => HostErrorKind::Unsupported, - SdkHostErrorKind::Unavailable => HostErrorKind::Unavailable, - SdkHostErrorKind::Denied => HostErrorKind::Denied, - SdkHostErrorKind::RateLimited => HostErrorKind::RateLimited, - SdkHostErrorKind::Timeout => HostErrorKind::Timeout, - SdkHostErrorKind::InvalidInput => HostErrorKind::InvalidInput, - SdkHostErrorKind::Internal => HostErrorKind::Internal, - }, - code: e.code, - message: e.message, - data: e.data, - })?; + let body = serde_json::to_vec(&creation)?; + match host.submit_order(chain_id, &body) { + Ok(server_uid) => { + host.set(&format!("submitted:{server_uid}"), b"")?; + host.log(LogLevel::Warn, &format!("TRIGGERED, uid={server_uid}")); } - Ok(()) + Err(err) => match classify_api_error(err.data.as_deref()) { + RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { + // log and let the next block re-attempt + } + RetryAction::Drop => { + host.set(&format!("dropped:{uid_hex}"), b"")?; + // log + give up - the orderbook will not accept the + // same body on a retry + } + }, } + Ok(()) } - -export!(StopLoss); ``` -The conversion code looks heavy but is one-time boilerplate. Copy -it verbatim into every new module; only the `Guest` impl and -`SETTINGS` initialisation change per module. +The `shepherd_sdk::cow::classify_api_error` helper is the BLEU-829 +retry contract - it maps the orderbook's typed `ApiError` into +`TryNextBlock` / `Backoff` / `Drop`. The module's only role here is +to act on the verdict: log and idle, or persist a `dropped:` flag +so the next block does not re-attempt. -### 3c. Unit tests against `MockHost` (15 minutes) +### 4e. Tests at the bottom -In `src/strategy.rs`, append: +Seven tests cover the dispatch matrix: -```rust -#[cfg(test)] -mod tests { - use super::*; - use shepherd_sdk::host::*; - use shepherd_sdk_test::MockHost; - - fn settings(trigger_scaled: i64) -> Settings { - Settings { - oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306".parse().unwrap(), - trigger_price_scaled: I256::try_from(trigger_scaled).unwrap(), - owner: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse().unwrap(), - sell_token: Address::ZERO, - buy_token: Address::ZERO, - sell_amount: U256::ZERO, - buy_amount: U256::ZERO, - valid_to: 0xffff_ffff, - } - } - - /// Encode a Chainlink `latestRoundData` return for tests. - fn oracle_returns(answer: i64) -> String { - let returns = AggregatorV3::latestRoundDataCall::abi_encode_returns(&( - 0u128, - I256::try_from(answer).unwrap(), - U256::ZERO, - U256::ZERO, - 0u128, - )); - let hex = alloy_primitives::hex::encode_prefixed(returns); - format!("\"{hex}\"") - } - - #[test] - fn idle_when_price_above_trigger() { - let host = MockHost::new(); - let s = settings(/*trigger*/ 1_000); - // Oracle returns 2000 (above the 1000 trigger). - host.chain.respond_to( - "eth_call", - &shepherd_sdk::chain::eth_call_params( - &s.oracle_address, - &AggregatorV3::latestRoundDataCall {}.abi_encode(), - ), - Ok(oracle_returns(2000)), - ); - - on_block(&host, 11_155_111, &s).unwrap(); - - assert_eq!(host.cow_api.call_count(), 0); - assert!(host.logging.contains("stop-loss idle")); - } - - #[test] - fn triggers_below_threshold_once() { - let host = MockHost::new(); - let s = settings(/*trigger*/ 1_000); - host.chain.respond_to( - "eth_call", - &shepherd_sdk::chain::eth_call_params( - &s.oracle_address, - &AggregatorV3::latestRoundDataCall {}.abi_encode(), - ), - Ok(oracle_returns(500)), - ); - host.cow_api.respond(Ok("0xdeadbeef".into())); - - // First block: submits. - on_block(&host, 11_155_111, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(host.logging.contains("triggered")); - - // Second block at the same price: dedup'd by the - // `submitted:` key. - on_block(&host, 11_155_111, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(host.logging.contains("already submitted")); - } -} -``` +- `idle_when_price_above_trigger` +- `triggers_and_submits_once_then_dedups` +- `permanent_submit_error_marks_dropped` (+ confirms dedup on the + next block) +- `transient_submit_error_leaves_state_unchanged` +- `oracle_rpc_error_is_warn_and_continue` +- `parse_config_round_trips_settings` + `parse_config_rejects_ + missing_owner` -Run with `cargo test -p stop-loss`. Both tests should pass on a -plain host - no wasm toolchain involved. +All seven run against `shepherd_sdk_test::MockHost`. `host.chain. +respond_to(...)` programs the oracle return; `host.cow_api.respond +(...)` programs the orderbook response; assertions read +`host.store.snapshot()` and `host.logging.contains(...)`. No +`wasmtime`, no network, no fixture wasm bundle. -The takeaway: any time you can express a behaviour as "given this -host state, do that", the `MockHost` route is faster to iterate -than a full engine restart. +## 5. Build the `.wasm` (5 minutes) -## 4. Build the `.wasm` artefact (5 minutes) +You already did this in §0. Re-build to confirm the strategy edits +compile: ```sh cargo build --target wasm32-wasip2 --release -p stop-loss ls -lh target/wasm32-wasip2/release/stop_loss.wasm ``` -Expected size: 250–350 KB. If it ballooned past ~500 KB, look at +If the file ballooned past ~500 KB, look at `cargo tree -p stop-loss --target wasm32-wasip2` - usually a fresh dependency pulled `reqwest` or `tokio` into the wasm graph. -## 5. Wire `engine.toml` and run it (10 minutes) +## 6. Wire `engine.toml` and run it (10 minutes) -Add an RPC endpoint for Sepolia in `engine.toml`: +Add a Sepolia RPC entry: ```toml [chains.11155111] @@ -518,9 +381,7 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" ``` WebSocket is required because the `[[subscription]]` is `kind = -"block"` and block subscriptions ride `eth_subscribe`. - -Run the engine pointed at your new module: +"block"`. Run: ```sh cargo run -p nexum-engine -- \ @@ -528,35 +389,35 @@ cargo run -p nexum-engine -- \ modules/examples/stop-loss/module.toml ``` -Expected output on first run (one log per: +Expected output: -- `init`: `stop-loss: init ok` -- on each new block: either `stop-loss idle` (price above trigger) - or `stop-loss triggered, uid=0x...` then `already submitted` - on subsequent blocks. +- `init`: `stop-loss init: owner=0x... trigger=...` +- on each new block: `stop-loss idle: price=... > trigger=...` + while the oracle stays above the threshold, then `stop-loss + TRIGGERED: ...` if the price ever drops at or below. If the engine reports `unsupported` for any capability, double- -check that the module's `[capabilities].required` list matches the -imports the strategy actually uses. - -## 6. Where to go from here (10 minutes) - -- **Production hardening**: replace the synthetic `init` with the - per-module fuel + memory limits in `engine.toml::[engine.limits]` - (see [`docs/deployment.md`](./deployment.md)). -- **Real order assembly**: the `build_order_body` `todo!` in §3a - is the only piece this tutorial elided. Cross-reference - [`modules/twap-monitor/src/lib.rs::build_order_creation`] - - it's the canonical assembly path - (`cowprotocol::OrderCreation::from_signed_order_data` + - `serde_json::to_vec`). -- **Tests for the adapter layer**: the wit-bindgen ↔ `Host` - conversion functions are mechanical but worth a smoke test that - forces each enum variant through. See `shepherd-sdk-test`'s own - tests for the pattern. -- **Multi-chain operation**: change `[[subscription]].chain_id` and - the `engine.toml::[chains.]` entry. The strategy stays - unchanged because every host call already passes `chain_id` +check `[capabilities].required` matches the imports the strategy +exercises. + +For multi-module operation (running stop-loss alongside other +strategies), see the BLEU-818 supervisor PR. + +## 7. Where to go from here (10 minutes) + +- **Production hardening**: tune `[engine.limits].fuel_per_event` + and `memory_bytes` for your hardware - see [`docs/deployment.md`]( + ./deployment.md) for the operator runbook. +- **A different strategy**: copy `modules/examples/stop-loss/`, + rename, and change `on_block`. The wit-bindgen adapter in + `lib.rs` is identical for every module; only `strategy.rs` and + `module.toml::[config]` move. +- **Custom signing**: swap `Signature::PreSign` for + `Signature::Eip1271(bytes)` when the owner is a Safe with an + isValidSignature handler - same pattern ethflow-watcher uses. +- **Multi-chain operation**: change `[[subscription]].chain_id` + and add the `engine.toml::[chains.]` entry. The strategy + stays unchanged because every host call passes `chain_id` through. ## Time-budget check @@ -564,17 +425,20 @@ imports the strategy actually uses. If a section ran much longer than the rough estimate above, please file an issue tagged `docs/tutorial` with the section that dragged. The target is **<4h cold from a fresh checkout to a successful run -in §5**, and we tighten the prose against feedback. +in §6**, and we tighten the prose against feedback. ## Reference index - SDK overview: [`docs/sdk.md`](./sdk.md) - Deployment runbook: [`docs/deployment.md`](./deployment.md) +- The example: [`modules/examples/stop-loss/`]( + ../modules/examples/stop-loss/) - ADR-0001 (`engine.toml` vs `module.toml` split) - ADR-0006 (TWAP / EthFlow as guest modules, no specialised WIT interfaces) - ADR-0007 (push protocol primitives to `cow-rs` first) -- Worked examples: [`price-alert`](../modules/examples/price-alert/), +- Worked examples that share the same recipe: + [`price-alert`](../modules/examples/price-alert/), [`balance-tracker`](../modules/examples/balance-tracker/), [`twap-monitor`](../modules/twap-monitor/), [`ethflow-watcher`](../modules/ethflow-watcher/) diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml new file mode 100644 index 0000000..0184851 --- /dev/null +++ b/modules/examples/stop-loss/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "stop-loss" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a pre-signed CoW order when price drops below a configured trigger, dedups via submitted:{uid}." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +shepherd-sdk = { path = "../../../crates/shepherd-sdk" } +cowprotocol = { version = "1.0.0-alpha.3", default-features = false } +alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } +alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } + +[dev-dependencies] +shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } diff --git a/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml new file mode 100644 index 0000000..17cebad --- /dev/null +++ b/modules/examples/stop-loss/module.toml @@ -0,0 +1,41 @@ +# stop-loss example module: watches a Chainlink oracle and submits a +# CoW order when the price drops below the configured trigger. +# Demonstrates eth_call + OrderCreation + cow-api submit + local-store +# dedup, the full M3 SDK surface. + +[module] +name = "stop-loss" +version = "0.1.0" +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["logging", "chain", "local-store", "cow-api"] +optional = [] + +[capabilities.http] +allow = [] + +# --- subscriptions ---------------------------------------------------- + +[[subscription]] +kind = "block" +chain_id = 11155111 # Sepolia + +# --- config ----------------------------------------------------------- + +[config] +# Chainlink AggregatorV3Interface address (ETH/USD on Sepolia). +oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +# Oracle's decimals (Chainlink USD pairs are 8). +decimals = "8" +# Trigger price in the oracle's native decimal units. Below this, sell. +trigger_price = "2500.00" +# Order parameters. The owner pre-signs via GPv2Signing.setPreSignature +# (on-chain, outside this module); the module submits the body with +# Signature::PreSign on trigger. +owner = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +sell_token = "0x6810e776880C02933D47DB1b9fc05908e5386b96" +buy_token = "0xfff9976782d46cc05630d1f6ebab18b2324d6b14" +sell_amount_wei = "1000000000000000000" +buy_amount_wei = "300000000000000000" +valid_to_seconds = "4294967295" diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs new file mode 100644 index 0000000..de7aaf0 --- /dev/null +++ b/modules/examples/stop-loss/src/lib.rs @@ -0,0 +1,154 @@ +//! # stop-loss (example Shepherd module) +//! +//! Watches a Chainlink price oracle on every block. When the price +//! drops at or below `trigger_price`, the module submits a pre-signed +//! CoW order using the parameters from `module.toml::[config]` and +//! persists `submitted:{uid}` to dedup re-poll attempts. The owner is +//! expected to have called `GPv2Signing.setPreSignature` on-chain +//! ahead of the trigger so the orderbook accepts the submission. +//! +//! ## Module layout +//! +//! - `strategy.rs` holds the pure logic and tests against +//! `shepherd_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, the `Guest` impl. +//! +//! Same recipe as `price-alert` (BLEU-851) - the wit-bindgen adapter +//! is intentionally mechanical and is a candidate for a future +//! declarative macro in `shepherd-sdk`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +wit_bindgen::generate!({ + path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], + world: "shepherd:cow/shepherd", + generate_all, +}); + +mod strategy; + +use std::sync::OnceLock; + +use shepherd_sdk::host::{ + ChainHost, CowApiHost, HostError as SdkHostError, HostErrorKind as SdkHostErrorKind, + LocalStoreHost, LogLevel as SdkLogLevel, LoggingHost, +}; + +use nexum::host::types::HostErrorKind; +use nexum::host::{chain, local_store, logging, types}; +use shepherd::cow::cow_api; + +static SETTINGS: OnceLock = OnceLock::new(); + +struct WitBindgenHost; + +impl ChainHost for WitBindgenHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + chain::request(chain_id, method, params).map_err(convert_err) + } +} + +impl LocalStoreHost for WitBindgenHost { + fn get(&self, key: &str) -> Result>, SdkHostError> { + local_store::get(key).map_err(convert_err) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), SdkHostError> { + local_store::set(key, value).map_err(convert_err) + } + fn delete(&self, key: &str) -> Result<(), SdkHostError> { + local_store::delete(key).map_err(convert_err) + } + fn list_keys(&self, prefix: &str) -> Result, SdkHostError> { + local_store::list_keys(prefix).map_err(convert_err) + } +} + +impl CowApiHost for WitBindgenHost { + fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { + cow_api::submit_order(chain_id, body).map_err(convert_err) + } +} + +impl LoggingHost for WitBindgenHost { + fn log(&self, level: SdkLogLevel, message: &str) { + logging::log(convert_level(level), message); + } +} + +fn convert_err(e: HostError) -> SdkHostError { + SdkHostError { + domain: e.domain, + kind: match e.kind { + HostErrorKind::Unsupported => SdkHostErrorKind::Unsupported, + HostErrorKind::Unavailable => SdkHostErrorKind::Unavailable, + HostErrorKind::Denied => SdkHostErrorKind::Denied, + HostErrorKind::RateLimited => SdkHostErrorKind::RateLimited, + HostErrorKind::Timeout => SdkHostErrorKind::Timeout, + HostErrorKind::InvalidInput => SdkHostErrorKind::InvalidInput, + HostErrorKind::Internal => SdkHostErrorKind::Internal, + }, + code: e.code, + message: e.message, + data: e.data, + } +} + +fn sdk_err_into_wit(e: SdkHostError) -> HostError { + HostError { + domain: e.domain, + kind: match e.kind { + SdkHostErrorKind::Unsupported => HostErrorKind::Unsupported, + SdkHostErrorKind::Unavailable => HostErrorKind::Unavailable, + SdkHostErrorKind::Denied => HostErrorKind::Denied, + SdkHostErrorKind::RateLimited => HostErrorKind::RateLimited, + SdkHostErrorKind::Timeout => HostErrorKind::Timeout, + SdkHostErrorKind::InvalidInput => HostErrorKind::InvalidInput, + SdkHostErrorKind::Internal => HostErrorKind::Internal, + }, + code: e.code, + message: e.message, + data: e.data, + } +} + +fn convert_level(l: SdkLogLevel) -> logging::Level { + match l { + SdkLogLevel::Trace => logging::Level::Trace, + SdkLogLevel::Debug => logging::Level::Debug, + SdkLogLevel::Info => logging::Level::Info, + SdkLogLevel::Warn => logging::Level::Warn, + SdkLogLevel::Error => logging::Level::Error, + } +} + +struct StopLoss; + +impl Guest for StopLoss { + fn init(config: Vec<(String, String)>) -> Result<(), HostError> { + let cfg = strategy::parse_config(&config).map_err(sdk_err_into_wit)?; + logging::log( + logging::Level::Info, + &format!( + "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", + cfg.owner, cfg.trigger_price_scaled, cfg.sell_token, cfg.buy_token, + ), + ); + let _ = SETTINGS.set(cfg); + Ok(()) + } + + fn on_event(event: types::Event) -> Result<(), HostError> { + let Some(cfg) = SETTINGS.get() else { + return Ok(()); + }; + if let types::Event::Block(block) = event { + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_err_into_wit)?; + } + Ok(()) + } +} + +export!(StopLoss); diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs new file mode 100644 index 0000000..cbdbe20 --- /dev/null +++ b/modules/examples/stop-loss/src/strategy.rs @@ -0,0 +1,562 @@ +//! Pure stop-loss strategy logic. Reads an oracle, optionally submits +//! a pre-signed CoW order, dedups via local-store. Every interaction +//! with the world flows through the [`Host`] trait so the tests can +//! drive it against `shepherd_sdk_test::MockHost`. + +use alloy_primitives::I256; +use alloy_sol_types::{SolCall, sol}; +use shepherd_sdk::chain::{eth_call_params, parse_eth_call_result}; +use shepherd_sdk::cow::{RetryAction, classify_api_error, gpv2_to_order_data}; +use shepherd_sdk::host::{Host, HostError, HostErrorKind, LogLevel}; +use shepherd_sdk::prelude::{ + Address, BuyTokenDestination, Bytes, Chain, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, + OrderKind, OrderUid, SellTokenSource, Signature, U256, +}; + +sol! { + interface AggregatorV3 { + function latestRoundData() external view returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); + } +} + +/// Resolved configuration parsed from `module.toml::[config]`. +#[derive(Clone, Debug)] +pub struct Settings { + /// Chainlink AggregatorV3Interface address. + pub oracle_address: Address, + /// Trigger price scaled to the oracle's native units. + pub trigger_price_scaled: I256, + /// Order owner (= EIP-712 signer / PreSign caller). + pub owner: Address, + /// Sell side of the order. + pub sell_token: Address, + /// Buy side of the order. + pub buy_token: Address, + /// Sell amount in atomic units of `sell_token`. + pub sell_amount: U256, + /// Buy amount in atomic units of `buy_token`. + pub buy_amount: U256, + /// Order expiry (Unix seconds). + pub valid_to: u32, +} + +/// React to a new block. +/// +/// Returns `Ok(())` on success and on recoverable upstream failures +/// (oracle RPC error, decode failure). Only host-store errors bubble +/// up via `?` so the supervisor can surface persistence issues - all +/// other faults log and let the next block re-poll. +pub fn on_block( + host: &H, + chain_id: u64, + settings: &Settings, +) -> Result<(), HostError> { + let price = match read_oracle(host, chain_id, settings.oracle_address) { + Some(p) => p, + None => return Ok(()), // logged inside read_oracle + }; + + if price > settings.trigger_price_scaled { + host.log( + LogLevel::Info, + &format!( + "stop-loss idle: price={price} > trigger={}", + settings.trigger_price_scaled, + ), + ); + return Ok(()); + } + + // Compute UID up-front so we can dedup before paying for the + // serialise + submit round trip. + let (creation, uid) = match build_creation(chain_id, settings) { + Ok(x) => x, + Err(e) => { + host.log( + LogLevel::Warn, + &format!("stop-loss skipped (build): {e}"), + ); + return Ok(()); + } + }; + let uid_hex = format!("{uid}"); + let dedup_key = format!("submitted:{uid_hex}"); + if host.get(&dedup_key)?.is_some() { + host.log( + LogLevel::Info, + &format!("stop-loss: {uid_hex} already submitted, idle"), + ); + return Ok(()); + } + let dropped_key = format!("dropped:{uid_hex}"); + if host.get(&dropped_key)?.is_some() { + host.log( + LogLevel::Info, + &format!("stop-loss: {uid_hex} previously dropped, idle"), + ); + return Ok(()); + } + + let body = match serde_json::to_vec(&creation) { + Ok(b) => b, + Err(e) => { + host.log( + LogLevel::Error, + &format!("OrderCreation JSON encode failed: {e}"), + ); + return Ok(()); + } + }; + match host.submit_order(chain_id, &body) { + Ok(server_uid) => { + if server_uid != uid_hex { + host.log( + LogLevel::Warn, + &format!("stop-loss uid drift: local={uid_hex} server={server_uid}"), + ); + } + host.set(&format!("submitted:{server_uid}"), b"")?; + host.log( + LogLevel::Warn, + &format!( + "stop-loss TRIGGERED: price={price} <= trigger={}, uid={server_uid}", + settings.trigger_price_scaled, + ), + ); + } + Err(err) => match classify_api_error(err.data.as_deref()) { + RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { + host.log( + LogLevel::Warn, + &format!( + "stop-loss retry on next block ({}): {}", + err.code, err.message + ), + ); + } + RetryAction::Drop => { + host.set(&dropped_key, b"")?; + host.log( + LogLevel::Warn, + &format!( + "stop-loss dropped {uid_hex} ({}): {}", + err.code, err.message + ), + ); + } + }, + } + Ok(()) +} + +/// Fetch the oracle's latest answer, returning `None` (and logging a +/// Warn) on any host or decode failure. The caller treats `None` as +/// "skip this block". +fn read_oracle(host: &H, chain_id: u64, oracle: Address) -> Option { + let call_data = AggregatorV3::latestRoundDataCall {}.abi_encode(); + let params = eth_call_params(&oracle, &call_data); + let result_json = match host.request(chain_id, "eth_call", ¶ms) { + Ok(s) => s, + Err(err) => { + host.log( + LogLevel::Warn, + &format!( + "stop-loss oracle eth_call failed ({}): {}", + err.code, err.message + ), + ); + return None; + } + }; + let bytes = parse_eth_call_result(&result_json)?; + AggregatorV3::latestRoundDataCall::abi_decode_returns(&bytes) + .ok() + .map(|r| r.answer) +} + +/// Assemble the `OrderCreation` body + canonical UID from settings. +/// Uses `Signature::PreSign` so the module ships zero ECDSA - the +/// owner is expected to have called `GPv2Signing.setPreSignature` +/// on-chain ahead of the trigger. +fn build_creation( + chain_id: u64, + settings: &Settings, +) -> Result<(OrderCreation, OrderUid), HostError> { + let chain = Chain::try_from(chain_id).map_err(|_| HostError { + domain: "stop-loss".into(), + kind: HostErrorKind::Unsupported, + code: 0, + message: format!("chain {chain_id} not supported by cowprotocol"), + data: None, + })?; + let domain = chain.settlement_domain(); + let gpv2 = GPv2OrderData { + sellToken: settings.sell_token, + buyToken: settings.buy_token, + receiver: settings.owner, + sellAmount: settings.sell_amount, + buyAmount: settings.buy_amount, + validTo: settings.valid_to, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + }; + let order_data = gpv2_to_order_data(&gpv2).ok_or_else(|| HostError { + domain: "stop-loss".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: "GPv2OrderData carried an unknown enum marker".into(), + data: None, + })?; + let uid = order_data.uid(&domain, settings.owner); + let creation = OrderCreation::from_signed_order_data( + &order_data, + Signature::PreSign, + settings.owner, + EMPTY_APP_DATA_JSON.to_string(), + None, + ) + .map_err(|e| HostError { + domain: "stop-loss".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("cowprotocol rejected the body: {e}"), + data: None, + })?; + // Silence the unused `Bytes` import on builds where `Signature:: + // PreSign` is the only signature variant we construct. + let _: Option = None; + Ok((creation, uid)) +} + +/// Parse `module.toml::[config]` into a typed [`Settings`]. +pub fn parse_config(entries: &[(String, String)]) -> Result { + let oracle_address = require(entries, "oracle_address")? + .parse::
() + .map_err(|e| invalid(format!("oracle_address: {e}")))?; + let decimals = require(entries, "decimals")? + .parse::() + .map_err(|e| invalid(format!("decimals: {e}")))?; + if decimals > 38 { + return Err(invalid(format!( + "decimals={decimals} exceeds the I256 power-of-ten budget" + ))); + } + let trigger_price_scaled = scale_signed(require(entries, "trigger_price")?, decimals)?; + let owner = require(entries, "owner")? + .parse::
() + .map_err(|e| invalid(format!("owner: {e}")))?; + let sell_token = require(entries, "sell_token")? + .parse::
() + .map_err(|e| invalid(format!("sell_token: {e}")))?; + let buy_token = require(entries, "buy_token")? + .parse::
() + .map_err(|e| invalid(format!("buy_token: {e}")))?; + let sell_amount = require(entries, "sell_amount_wei")? + .parse::() + .map_err(|e| invalid(format!("sell_amount_wei: {e}")))?; + let buy_amount = require(entries, "buy_amount_wei")? + .parse::() + .map_err(|e| invalid(format!("buy_amount_wei: {e}")))?; + let valid_to = require(entries, "valid_to_seconds")? + .parse::() + .map_err(|e| invalid(format!("valid_to_seconds: {e}")))?; + Ok(Settings { + oracle_address, + trigger_price_scaled, + owner, + sell_token, + buy_token, + sell_amount, + buy_amount, + valid_to, + }) +} + +fn require<'a>(entries: &'a [(String, String)], key: &str) -> Result<&'a str, HostError> { + entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + .ok_or_else(|| invalid(format!("missing key {key:?}"))) +} + +fn invalid(message: impl Into) -> HostError { + HostError { + domain: "stop-loss".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("stop-loss: invalid [config]: {}", message.into()), + data: None, + } +} + +fn scale_signed(threshold_decimal: &str, decimals: u32) -> Result { + let (sign, body) = if let Some(rest) = threshold_decimal.strip_prefix('-') { + (-1i32, rest) + } else { + (1, threshold_decimal) + }; + let (whole, frac) = match body.split_once('.') { + Some((w, f)) => (w, f), + None => (body, ""), + }; + if whole.is_empty() && frac.is_empty() { + return Err(invalid("trigger_price: empty")); + } + if !whole.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) { + return Err(invalid(format!( + "trigger_price: non-digit character in {threshold_decimal:?}" + ))); + } + let frac_len = frac.len() as u32; + let composed: String = if frac_len <= decimals { + let mut s = String::with_capacity(whole.len() + decimals as usize); + s.push_str(whole); + s.push_str(frac); + for _ in 0..(decimals - frac_len) { + s.push('0'); + } + s + } else { + let mut s = String::with_capacity(whole.len() + decimals as usize); + s.push_str(whole); + s.push_str(&frac[..decimals as usize]); + s + }; + let raw = if composed.is_empty() { "0" } else { &composed }; + let unsigned: U256 = raw + .parse() + .map_err(|e| invalid(format!("trigger_price parse: {e}")))?; + let signed = I256::try_from(unsigned) + .map_err(|e| invalid(format!("trigger_price range: {e}")))?; + Ok(if sign < 0 { -signed } else { signed }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::hex; + use shepherd_sdk::host::HostErrorKind as Kind; + use shepherd_sdk_test::MockHost; + + const SEPOLIA: u64 = 11_155_111; + + fn settings_below(trigger_scaled: i128) -> Settings { + Settings { + oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306".parse().unwrap(), + trigger_price_scaled: I256::try_from(trigger_scaled).unwrap(), + owner: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse().unwrap(), + sell_token: "0x6810e776880C02933D47DB1b9fc05908e5386b96".parse().unwrap(), + buy_token: "0xfff9976782d46cc05630d1f6ebab18b2324d6b14".parse().unwrap(), + sell_amount: U256::from(1_000_000_000_000_000_000_u128), + buy_amount: U256::from(300_000_000_000_000_000_u128), + valid_to: u32::MAX, + } + } + + fn oracle_response_json(answer_scaled: i128) -> String { + use alloy_primitives::aliases::U80; + let returns = AggregatorV3::latestRoundDataReturn { + roundId: U80::ZERO, + answer: I256::try_from(answer_scaled).unwrap(), + startedAt: U256::ZERO, + updatedAt: U256::ZERO, + answeredInRound: U80::ZERO, + }; + let encoded = AggregatorV3::latestRoundDataCall::abi_encode_returns(&returns); + let hex_body = hex::encode_prefixed(encoded); + format!("\"{hex_body}\"") + } + + fn program_oracle(host: &MockHost, oracle: Address, response: Result) { + let call_data = AggregatorV3::latestRoundDataCall {}.abi_encode(); + let params = eth_call_params(&oracle, &call_data); + host.chain.respond_to("eth_call", ¶ms, response); + } + + fn programmed_uid(settings: &Settings) -> String { + let (_creation, uid) = build_creation(SEPOLIA, settings).unwrap(); + format!("{uid}") + } + + #[test] + fn idle_when_price_above_trigger() { + let host = MockHost::new(); + let s = settings_below(/*trigger*/ 250_000_000_000); + program_oracle(&host, s.oracle_address, Ok(oracle_response_json(300_000_000_000))); + + on_block(&host, SEPOLIA, &s).unwrap(); + + assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(host.store.len(), 0); + assert!(host.logging.contains("stop-loss idle")); + } + + #[test] + fn triggers_and_submits_once_then_dedups() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle(&host, s.oracle_address, Ok(oracle_response_json(200_000_000_000))); + let uid = programmed_uid(&s); + host.cow_api.respond(Ok(uid.clone())); + + // First block: submits. + on_block(&host, SEPOLIA, &s).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + assert!(host.logging.contains("TRIGGERED")); + assert!(host.store.snapshot().contains_key(&format!("submitted:{uid}"))); + + // Second block at the same price: dedup'd, no new submit. + on_block(&host, SEPOLIA, &s).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + assert!(host.logging.contains("already submitted")); + } + + #[test] + fn permanent_submit_error_marks_dropped() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle(&host, s.oracle_address, Ok(oracle_response_json(200_000_000_000))); + + // Orderbook returns InvalidSignature - permanent per + // `OrderPostErrorKind::is_retriable`. + let api_body = serde_json::json!({ + "errorType": "InvalidSignature", + "description": "bad sig", + }) + .to_string(); + host.cow_api.respond(Err(HostError { + domain: "cow-api".into(), + kind: Kind::Denied, + code: 400, + message: "InvalidSignature".into(), + data: Some(api_body), + })); + + on_block(&host, SEPOLIA, &s).unwrap(); + let uid = programmed_uid(&s); + assert!(host.store.snapshot().contains_key(&format!("dropped:{uid}"))); + assert!(!host.store.snapshot().contains_key(&format!("submitted:{uid}"))); + assert!(host.logging.contains("dropped")); + + // Second block: dropped marker idles the loop. + on_block(&host, SEPOLIA, &s).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); // no resubmit + assert!(host.logging.contains("previously dropped")); + } + + #[test] + fn transient_submit_error_leaves_state_unchanged() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle(&host, s.oracle_address, Ok(oracle_response_json(200_000_000_000))); + + let api_body = serde_json::json!({ + "errorType": "InsufficientFee", + "description": "fee too low", + }) + .to_string(); + host.cow_api.respond(Err(HostError { + domain: "cow-api".into(), + kind: Kind::Denied, + code: 400, + message: "InsufficientFee".into(), + data: Some(api_body), + })); + + on_block(&host, SEPOLIA, &s).unwrap(); + + // No persistence flag - next block will retry. + assert_eq!(host.store.len(), 0); + assert!(host.logging.contains("retry on next block")); + } + + #[test] + fn oracle_rpc_error_is_warn_and_continue() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle( + &host, + s.oracle_address, + Err(HostError { + domain: "chain".into(), + kind: Kind::Timeout, + code: 504, + message: "upstream timed out".into(), + data: None, + }), + ); + + on_block(&host, SEPOLIA, &s).unwrap(); + + assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(host.store.len(), 0); + assert!(host.logging.contains("oracle eth_call failed")); + } + + #[test] + fn parse_config_round_trips_settings() { + let entries = vec![ + ( + "oracle_address".into(), + "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), + ), + ("decimals".into(), "8".into()), + ("trigger_price".into(), "2500.00".into()), + ( + "owner".into(), + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".into(), + ), + ( + "sell_token".into(), + "0x6810e776880C02933D47DB1b9fc05908e5386b96".into(), + ), + ( + "buy_token".into(), + "0xfff9976782d46cc05630d1f6ebab18b2324d6b14".into(), + ), + ("sell_amount_wei".into(), "1000000000000000000".into()), + ("buy_amount_wei".into(), "300000000000000000".into()), + ("valid_to_seconds".into(), "4294967295".into()), + ]; + let s = parse_config(&entries).unwrap(); + assert_eq!(s.valid_to, u32::MAX); + assert_eq!(s.trigger_price_scaled, I256::try_from(250_000_000_000_i64).unwrap()); + } + + #[test] + fn parse_config_rejects_missing_owner() { + let entries = vec![ + ( + "oracle_address".into(), + "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), + ), + ("decimals".into(), "8".into()), + ("trigger_price".into(), "1.0".into()), + ( + "sell_token".into(), + "0x6810e776880C02933D47DB1b9fc05908e5386b96".into(), + ), + ( + "buy_token".into(), + "0xfff9976782d46cc05630d1f6ebab18b2324d6b14".into(), + ), + ("sell_amount_wei".into(), "1".into()), + ("buy_amount_wei".into(), "1".into()), + ("valid_to_seconds".into(), "1".into()), + ]; + let err = parse_config(&entries).unwrap_err(); + assert!(matches!(err.kind, Kind::InvalidInput)); + assert!(err.message.contains("owner")); + } +}